diff --git a/.gitignore b/.gitignore index a13297832..c13fe4a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ tmp-test/ # Session journals the previous telemetry plugin wrote into a project: private, never tracked. aidd_docs/runs/ + +# Personal make reload agent opt-in (see scripts/reload-agents.example.json) +scripts/reload-agents.json diff --git a/AGENTS.md b/AGENTS.md index 53ad41f8b..0e5096288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,10 +28,22 @@ - **Don't guess** APIs, signatures, flags, or behavior - read the source or docs to confirm before relying on them. - **Ambiguous or expensive task:** ask one sharp question to pin down scope before building, rather than guess. - **Batch independent operations** in one pass, not one at a time. -- **Fan out** independent subtasks to parallel subagents when you own the overall flow and the work is genuinely parallel. - **Before adding any instruction, finding, or rule, check whether an existing one already covers or contradicts it.** If so, don't add a parallel: delete it, merge it into the stronger one, or rewrite with explicit scope and priority. - **Name by intention, not mechanism:** describe the goal or responsibility, not the tool or file format. +## Agents + +When you own the overall flow, fan out genuinely independent subtasks to available host agents by responsibility when coordination costs less than serial work; custom agents are optional. The caller owns scope, delegation, and final acceptance. + +| Responsibility | Agent when installed | +| --- | --- | +| Answer a scoped codebase question with sources | `aidd-dev-explorer` | +| Implement assigned work and verify it | `aidd-dev-executor` | +| Confirm the outcome meets its acceptance criteria | `aidd-dev-validator` | +| Review quality, coherence, and needless complexity | `aidd-dev-reviewer` | + +Without them, give the same brief to a general-purpose agent. + ## Memory Management Project docs, memory, specs, and plans live in `aidd_docs/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d73b5f1a6..2b6af17a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ make setup # deps, git hooks, registers the marketplace, installs plugins into ## ✏️ Make your change - **Follow the [Principles](#-principles).** -- **Test locally** — run `make reload`, restart your session(s). Test in Claude *and* one other tool (e.g. Codex). +- **Test locally** — run `make reload`, restart your session(s). Test in Claude *and* one other tool (e.g. Codex). To load the optional agents too, see [reload opt-in](docs/SUBAGENTS.md#-update-or-remove). - **Commit** — `(): description`, one scope per commit → [convention](aidd_docs/memory/vcs.md#commits). ## 🔀 Open a pull request diff --git a/Makefile b/Makefile index 67fc69349..24aa053b7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,3 @@ -PLUGIN ?= all - .DEFAULT_GOAL := help .PHONY: help setup doctor check reload @@ -17,5 +15,6 @@ doctor: ## Check the local toolchain check: ## Run the pre-commit checks pnpm exec lefthook run pre-commit -reload: ## Dev: reload this checkout into Claude, Codex, and OpenCode (PLUGIN="aidd-refine aidd-pm", default all) - scripts/dev-sync.sh $(PLUGIN) +reload: ## Dev: reload all plugins into Claude, Codex, OpenCode, Copilot, and Cursor + pnpm --dir cli build + scripts/dev-sync.sh diff --git a/README.md b/README.md index d298d9d98..efe065a67 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best 🧱 **IDE agnostic** · 🏗️ **Legacy systems** · 🌱 **Token-optimized** · 🇫🇷 **Made in France**

- 9 plugins · 51 skills · 2 agents + 9 plugins · 51 skills

[![Open Source](https://img.shields.io/badge/Open_Source-Yes-yellow?logo=open-source-initiative&logoColor=white)](https://opensource.org/) @@ -26,7 +26,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best --- -The **AI-Driven Dev Framework** installs a working SDLC (Software Development Life Cycle) into your AI coding tool — **skills, agents, commands, rules** — that turns a rough idea into a reviewed, shipped pull request: +The **AI-Driven Dev Framework** installs a working SDLC (Software Development Life Cycle) into your AI coding tool — **skills, commands, rules** — that turns a rough idea into a reviewed, shipped pull request: ```text /aidd-orchestrator:01-sdlc "add rate limiting to the /login endpoint" @@ -185,6 +185,10 @@ codex plugin add aidd-context@aidd-framework # per plugin +### Optional subagents (beta) + +Custom agents are a separate opt-in, per project or per user. Plugins work without them. See the [subagent guide](docs/SUBAGENTS.md) for installation, a native trial, and removal by IDE. + ## 🚀 Quick start Three ways in — pick one: diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 7a65597c2..c91b34f63 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -56,6 +56,10 @@ Config: `release-please-config.json`, eleven packages. Manifest: `.release-pleas - `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`. +## Local reload + +`make reload` syncs all plugins and generic delegation guidance to installed hosts, and the agents opted in by the gitignored `reload-agents.json` beside `scripts/reload-agents.example.json`; details in `scripts/dev-sync.sh`. + ## Monitoring None. Failures surface as a red run; `back-merge.yml` opens a tracking issue when it cannot push, so drift is never silent. diff --git a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/review.md b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/review.md new file mode 100644 index 000000000..0bfb96805 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/review.md @@ -0,0 +1,84 @@ +# Review: scoped sub-agents + +- Rounds: 5 +- Started: 2026-10-15 + +## Round 1 · r-1fd6 + +- Date: 2026-10-15 +- By: alexsoyes +- Diff: `origin/next...513d323c0dcb431aa936e56bf4be16945e39ec57` +- Axes: code, relevancy +- Verdict: changes-requested +- Score: not scored + +### Findings + +- `plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md:49` : Delegated delivery sends the required final journey into Finalize, whose first step runs plan validation again (`02-implement/actions/03-finalize.md:13`). The journey is therefore no longer last → perform integrated validation before that journey and mark completion after it passes; major, fit +- `plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md:25` : The changed contract requires canonical recipe addresses, while the copied scaffold still prescribes capability discovery for cross-plugin recipes (`assets/agent-template.md:18`; `actions/02-write-agent.md:15` copies it) → align the scaffold with the contract; major, architecture +- `plugins/aidd-dev/skills/02-implement/actions/02-execute.md:17` : Missing dependencies demand replanning, while line 15 gives undeclared dependencies a serial fallback → distinguish absent declarations from unresolved referenced prerequisites; minor, error-handling +- `docs/ARCHITECTURE.md:145` : Rewriting general cross-plugin provider discovery is unnecessary for adding sub-agents; agent permission addressing already has its own row → retain the existing provider rule; minor, fit + +## Round 2 · r-7c2a + +- Date: 2026-10-15 +- By: alexsoyes +- Diff: `origin/next...513d323c0dcb431aa936e56bf4be16945e39ec57` plus the working tree +- Axes: code, relevancy +- Verdict: changes-requested +- Score: not scored + +### Findings + +- `plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md:7` : The local correction makes every executor stop after Execute, including whole-plan executors, while the implementation router still instructs whole-plan calls to run Prepare, Execute and Finalize (`02-implement/SKILL.md:26`). The caller must override that routing and resume inside another action → preserve the normal whole-plan route and keep shared integration/finalization ownership specific to delegated phases; major, architecture +- `plugins/aidd-dev/skills/02-implement/actions/02-execute.md:16` : Distinct checkouts/Git indexes are mandatory for parallel execution even when workers own disjoint files and leave Git operations to the caller → require independent writes and validation resources; require separate indexes only when workers also perform Git operations; minor, fit +- `plugins/aidd-dev/agents/validator.md:19` : A two-sentence maximum can hide distinct failures or verification blockers; the requested concise verdict does not need a fixed sentence count → retain one verdict and concise decisive evidence without the numeric cap; minor, fit + + +## Round 3 · r-983d + +- Date: 2026-10-15 +- By: independent reviewer for CLI; caller for workflow and scope +- Diff: final working candidate +- Axes: code, relevancy +- Verdict: approve +- Score: not scored + +### Resolved + +- **Workflow:** whole-plan execution retains its normal route. Assigned phases return for integration and one finalization; the required journey runs last before completion. The native whole-plan probe confirms that order. +- **Boundaries:** uncertain dependencies run serially; separate Git indexes are required only when workers perform Git operations. Validator retains one concise verdict without an arbitrary sentence cap. The template describes capabilities rather than ordered steps. +- **Native discovery:** independent rereview confirms the four original CLI findings are resolved: Copilot catalogue/cache paths, Claude activation, Copilot personal precedence and native configuration homes. The generated-catalogue regression also covers `metadata.pluginRoot`. +- **Reload safety:** an empty Claude cache glob now reaches the existing missing-install-path error. Regression fixtures run inside their temporary home; both cases failed before correction and pass afterward. +- **Evidence:** real five-host resolution succeeds without configuration writes. Ambiguous Copilot cross-tool settings fail actionably; higher-scope overrides resolve them. Effective runtime remains unknown until native traces establish it. +- **Scope:** unrelated cross-plugin provider wording is restored. Gather, wireframe and generic skill generation remain unchanged against `next`; Batch drops the mandatory refine pipeline that exceeded a bounded executor's responsibility. + +No consequential new findings in the scoped rereview. Full test results and native limitations are recorded in [verification.md](verification.md). + + +## Round 4 · r-ci + +- Date: 2026-10-15 +- By: independent mutation reviewer; caller for verification +- Diff: CI follow-up after `6b5f5f58` +- Axes: verification, relevancy +- Verdict: approve local corrections +- Score: not scored + +- **CI coverage:** register the new smoke command, require the four current roles in the real Kilo probe, and serialize Windows fixture paths correctly. +- **False-positive assertion:** replace the malformed-error assertion that accepted an undefined rejection with an actual error-message check. +- **Native boundaries:** add 15 meaningful cases in existing profile tests. Keep equivalent object serialization and unchanged baseline metadata out of scope. +- **Results:** full mutation scopes pass at Cursor 97.4% / floor 97 and OpenCode 95.0% / floor 94. No production or threshold changes in these corrections. + +## Round 5 · native model configuration + +- Date: 2026-10-15 +- By: independent reviewer +- Diff: replacement of runtime resolution with CLI-installed native headers +- Axes: code, relevancy +- Verdict: no actionable findings +- Score: not scored + +- **Configuration:** one CLI JSON owns provider defaults, native fields and the Copilot alias. Explicit native choices survive; defaults stay scoped to the four AIDD roles. +- **Simplicity:** resolver machinery and workflow dependencies are removed. Agent behavior is limited to three capabilities per role. +- **Evidence:** 6,499 CLI tests and 498 script tests pass, including nine built-CLI header cases. Existing Codex/Cursor plugin limitations remain explicit; effective runtime selection is not claimed from generated files. diff --git a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md new file mode 100644 index 000000000..e9d66c553 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md @@ -0,0 +1,25 @@ +# Sub-agent verification + +Current state, 2026-10-21, merged with next. Behavior and sources are in [the agent guide](../../../../docs/SUBAGENTS.md). + +## Scope + +- **Agents:** explorer, executor, validator and reviewer ship with the CLI, outside plugins. `aidd agents install --provider --ide` writes them per project; `--scope user` writes them to the IDE's user directory (Claude Code, Codex, OpenCode). Ownership hashes guard every write; retired roles are removed when unmodified. +- **Models:** [agent-models.json](../../../../cli/src/contexts/tools/domain/formats/agent-models.json) maps roles to `main`, `standard` and `light` profiles per provider. +- **Main model:** opt-in with `--main`. The prior value is recorded and restored on removal; a value changed since, or TOML that cannot be edited line by line, is refused. OpenCode's root model takes no variant. +- **Reload:** `make reload` runs `agents install --scope user` for each IDE listed in the gitignored `scripts/reload-agents.json`, before any plugin change. + +## Checks + +- **CLI:** 7,070 tests pass, one skipped. Typecheck, lint, architecture rules, knip and duplication (1.44%) pass. Bundle 733.5 KB against 742 KB. +- **Smoke:** 140 checks pass, one remote check skipped; 34/34 leaf commands exercised. +- **Reload:** 539 script tests pass, including reload opt-in order, skips with a reason, and refusal before plugin changes. +- **Edge cases:** unit cases cover multi-line strings, quoted and dotted keys, nested arrays, inline comments, CRLF and a JSON BOM; integration cases cover user scope, restore on removal, retired roles and rollback of both registries. + +## Earlier runtime probes + +Native probes on 2026-10-14 and 2026-10-15 confirmed that Claude Code, Codex, OpenCode, Copilot and Cursor dispatch the four roles in fresh contexts. They predate the current agent bodies and model profiles. One delegated-versus-direct observation per task gave no general cost or speed claim. + +## Limits + +Headers and files do not prove account access or the model and effort a host applies at runtime. diff --git a/cli/README.md b/cli/README.md index ea60e90e5..508eaacb0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -121,6 +121,10 @@ aidd setup --scope user --ai claude,codex,copilot --plugins none --yes | `aidd auth status` | Show which credential resolves, from where, at which level | | `aidd auth logout` | Delete the stored credential | +## Agents (beta) + +Install optional agents per project with the [subagent guide](../docs/SUBAGENTS.md). + ## Translate `aidd translate --to --out ` converts a framework source tree into a target-native plugin tree. diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index 86da7d334..acc823753 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -13,6 +13,7 @@ src/ │ │ └── infrastructure/ │ ├── framework/ # the installation record and everything done to a project │ │ ├── application/ +│ │ │ ├── agents/ # opt-in native agents and file ownership │ │ │ ├── clean/ │ │ │ ├── doctor/ │ │ │ ├── flows/ diff --git a/cli/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md index a48f68392..893160b4b 100644 --- a/cli/aidd_docs/memory/testing.md +++ b/cli/aidd_docs/memory/testing.md @@ -18,6 +18,7 @@ How this package is tested: the layers, the tools, the conventions. ## Conventions - `tests/` mirrors `src/`; where it does not, the mirror is wrong. +- A tool profile's tests split by feature: `tests/contexts/tools/domain/profiles//.unit.test.ts` (`agents`, `skills`, `rules`, `commands`, `mcp`, `hooks`, `plugins`, `settings`, `content`), with the profile's own declarations in `profile.unit.test.ts`. - 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. @@ -34,6 +35,7 @@ How this package is tested: the layers, the tools, the conventions. - `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 test:e2e:agents` installs the optional agents into a throwaway project and asks each installed host (Claude Code, Codex, OpenCode, Copilot, Cursor) which agents it loaded, through the person's signed-in hosts; Cursor and Codex spend one short model turn, and a Cursor usage limit skips its case. - `pnpm smoke:real` reaches real host registries: [`smoke-real.md`](internal/smoke-real.md). - `pnpm smoke:collision` runs the real `codex` and `copilot` binaries in a throwaway HOME against a person's own install under the keys aidd uses, and checks `setup` and `clean` leave it as seeded. `smoke:real`'s unique names cannot meet that case. - `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. diff --git a/cli/assets/agents/executor.md b/cli/assets/agents/executor.md new file mode 100644 index 000000000..4c28cb949 --- /dev/null +++ b/cli/assets/agents/executor.md @@ -0,0 +1,27 @@ +--- +name: executor +description: Implements assigned work within defined scope. Use to write or revise code and documentation, verify the result, and repair failures against the requested outcome. +--- + +# Role + +Deliver assigned code or documentation within the agreed outcome and criteria. + +# Behavior + +- **Choose the simplest solution within owned scope**, minimizing changes; escalate material changes to scope or dependencies. +- **Express clear intent with few words** in names, code, and documentation. Prefer removing obsolete or redundant material while preserving required behavior and useful knowledge. +- **Verify and repair the result**; report unmet criteria and missing checks. +- **Wait for every check or commit you started** before ending a turn. +- **Respect file and shared-operation ownership**; return changes and evidence to the caller. + +# Output + +Return a concise result; omit empty fields. + +```markdown +**Status:** +**Changes:** +**Checks:** +**Remaining:** +``` diff --git a/cli/assets/agents/explorer.md b/cli/assets/agents/explorer.md new file mode 100644 index 000000000..de7f76b9e --- /dev/null +++ b/cli/assets/agents/explorer.md @@ -0,0 +1,28 @@ +--- +name: explorer +description: Explores a codebase to answer scoped questions. Use to locate files and symbols, trace execution or dependencies, and return relevant code with an explanation before changes. +--- + +# Role + +Find the code and context that answer the caller's question. + +# Behavior + +- **Read relevant code, callers, and dependencies**; return sourced paths, symbols, patterns, or short excerpts and explain what they establish. +- **Distinguish facts from hypotheses** and state unresolved questions. Suggest changes only when asked. +- **Keep sources unchanged** and return findings directly to the caller. + +# Output + +Keep the answer proportional to the question; omit unused fields. + +```markdown +**Answer:** + +- **:** + + + +**Unknowns:** +``` diff --git a/cli/assets/agents/reviewer.md b/cli/assets/agents/reviewer.md new file mode 100644 index 000000000..34d627df3 --- /dev/null +++ b/cli/assets/agents/reviewer.md @@ -0,0 +1,24 @@ +--- +name: reviewer +description: Reviews the quality and coherence of finished work. Use to find concrete defects, contradictions, duplication, and unnecessary complexity against project conventions and the real need. +--- + +# Role + +Review finished work independently and identify quality or coherence gaps that acceptance checks may miss. + +# Behavior + +- **Review the actual work and need independently**, using project conventions and caller-supplied criteria. Check defects, contradictions, duplication, and unnecessary complexity. +- **Tie findings to evidence and impact**; distinguish defects from suggestions and state uncertainty. Recommend the smallest correction, favoring removal. +- **Keep the work unchanged** and return findings to the caller. + +# Output + +Return a concise review. + +```markdown +**Review:** + +- **:** +``` diff --git a/cli/assets/agents/validator.md b/cli/assets/agents/validator.md new file mode 100644 index 000000000..c04f16414 --- /dev/null +++ b/cli/assets/agents/validator.md @@ -0,0 +1,23 @@ +--- +name: validator +description: Validates that delivered work satisfies the request and acceptance criteria. Use to independently confirm completion and explain failures or missing verification. +--- + +# Role + +Verify whether the requested outcome has been delivered. + +# Behavior + +- **Check the requested outcome, acceptance criteria, and mandatory project checks** using observable evidence. Reuse results only for the same unchanged candidate; run missing checks when possible. +- **Report yes only when the assigned scope passes**; otherwise explain what failed or could not be checked and why. +- **Keep the work and criteria unchanged**; return the verdict to the caller for repairs or decisions. + +# Output + +Return one concise verdict for the assigned scope. + +```markdown +**Validated:** + +``` diff --git a/cli/package.json b/cli/package.json index 5fdddf646..784852b99 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 709, + "bundleBudgetKB": 742, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", @@ -47,6 +47,7 @@ "test:integration": "vitest run --project=integration", "test:e2e": "vitest run --project=e2e", "test:foreign-clock": "node scripts/test-foreign-clock.mjs", + "test:e2e:agents": "AGENTS_RUNTIME_SMOKE=1 vitest run --project=e2e tests/e2e/agents-runtime.e2e.test.ts", "test:e2e:kilo": "KILO_RUNTIME_SMOKE=1 vitest run --project=e2e tests/e2e/kilo-runtime.e2e.test.ts", "test:coverage": "vitest run --coverage", "test:kanban": "pnpm --dir ../kanban test", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index bbec74123..e3fd92a72 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -46,6 +46,14 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 709 KB: 695.3 KB, telemetry keeping what an ingest measured and could not store, ledger lines // no reader knows, and a bounded re-read of a call whose consent cannot be read yet (#1046, // +1.3 KB over next's 694.0 KB, after 1.7 KB of savings in telemetry code); 2.0% measured headroom. +// 705 KB: 697.0 KB, optional native agents and model policy measured on #1027's CI merge +// with next ae8705f7; 1.1% headroom. The branch alone measured 694.7 KB. +// 715 KB: 706.36 KB, #1027 merge 49e5cae4 with next 7084a88c after agent lifecycle fixes; +// next's telemetry filtering adds 8,839 bytes over the 697.73 KB branch; 1.2% headroom. +// 726 KB: 717.3 KB, #1027 main-session model per provider written into host settings, with +// ownership-checked removal (+6.9 KB over the 710.4 KB branch); 1.2% headroom. +// 742 KB: 733.0 KB, #1027 with user-scope agents merged with next da3a99c5 (+13.0 KB over the +// 720.0 KB branch, next's telemetry work); 1.2% headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index 63afd9111..f534c483e 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -25,6 +25,7 @@ ALL_COMMANDS=( "setup" "doctor" "sync" "translate" "update" "clean" "framework install" "framework update" "framework remove" "framework rules" "framework validate-recipes" + "agents install" "agents remove" "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" @@ -64,7 +65,7 @@ project_tree_hash() { ' ) } -PARENTS=" plugin marketplace auth framework telemetry " +PARENTS=" plugin marketplace auth framework telemetry agents " # A parent one level deeper than PARENTS, so a covered key needs three words there, not two. GRANDPARENTS=" " derive_key() { @@ -179,6 +180,34 @@ section "recipe validation (bundled)" run "framework validate-recipes (bundled)" 0 "PASS: 3 recipe(s) validated." "$ROOT" -- \ framework validate-recipes --bundled "$ROOT/../plugins/aidd-context/skills/12-cook/assets/recipes" +section "agents (explicit opt-in)" +P_AGENTS=$(new_project) +run "agents install (OpenAI / Codex)" 0 "4 agent files installed" "$P_AGENTS" -- \ + agents install --provider openai --ide codex +codex_agent="$P_AGENTS/.codex/agents/aidd-dev-explorer.toml" +if [[ -f "$codex_agent" ]] && grep -qF 'model = "gpt-6-luna"' "$codex_agent" \ + && grep -qF 'model_reasoning_effort = "medium"' "$codex_agent"; then + ok "Codex agent carries the OpenAI model and medium effort" +else + bad "Codex agent lacks the requested native model or effort" +fi +run "agents install (unchanged repeat)" 0 "0 agent files installed" "$P_AGENTS" -- \ + agents install --provider openai --ide codex +run "agents remove (Codex)" 0 "4 agent files removed" "$P_AGENTS" -- agents remove --ide codex +run "agents install (Anthropic / Claude Code)" 0 "4 agent files installed" "$P_AGENTS" -- \ + agents install --provider anthropic --ide claude-code +claude_agent="$P_AGENTS/.claude/agents/aidd-dev-explorer.md" +if [[ -f "$claude_agent" ]] && grep -qF 'claude-haiku-5-5' "$claude_agent" \ + && grep -q 'effort: .*medium' "$claude_agent"; then + ok "Claude Code agent carries the Anthropic model and medium effort" +else + bad "Claude Code agent lacks the requested native model or effort" +fi +run "agents remove (Claude Code)" 0 "4 agent files removed" "$P_AGENTS" -- agents remove --ide claude-code +[[ ! -f "$codex_agent" && ! -f "$claude_agent" && ! -f "$P_AGENTS/.aidd/agents.json" ]] \ + && ok "agents remove clears owned files and the empty ledger" \ + || bad "agents remove left native files or an empty ledger" + section "translate (local fixture)" FW_OUT="$TMPROOT/fw-out" if run "translate --to claude" 0 "" "$ROOT" -- \ diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 3c2ce3bb3..9255f17cf 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,5 +1,6 @@ import { platform } from "node:os"; import { Command } from "commander"; +import { registerAgentsCommand } from "./presentation/commands/agents.js"; import { registerAuthCommand } from "./presentation/commands/auth.js"; import { registerCleanCommand } from "./presentation/commands/clean.js"; import { registerDoctorCommand } from "./presentation/commands/doctor.js"; @@ -32,6 +33,7 @@ program registerSetupCommand(program); registerFrameworkCommand(program); +registerAgentsCommand(program); registerTranslateCommand(program); registerPluginCommand(program); registerMarketplaceCommand(program); diff --git a/cli/src/contexts/framework/application/agents/agent-location.ts b/cli/src/contexts/framework/application/agents/agent-location.ts new file mode 100644 index 000000000..be417e20a --- /dev/null +++ b/cli/src/contexts/framework/application/agents/agent-location.ts @@ -0,0 +1,71 @@ +import { basename, join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { AgentsCapability } from "../../../tools/domain/capabilities/agents-capability.js"; +import type { EnvironmentReader } from "../../../tools/domain/capabilities/plugins-capability.js"; +import type { HasAgents } from "../../../tools/domain/contracts.js"; +import type { AgentHost } from "../../../tools/domain/formats/agent-models.js"; +import { mainModelTarget } from "../../../tools/domain/formats/main-model-config.js"; +import { getAiToolConfig } from "../../../tools/domain/registry.js"; +import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js"; + +/** The user's home, environment and AIDD configuration directory, for user-scoped agents. */ +export interface UserAgentRoot { + home: string; + env: EnvironmentReader; + configDir: string; +} + +export interface AgentTarget { + projectRoot: string; + host: AgentHost; + /** Targets the IDE's user configuration directory instead of the project. */ + user?: UserAgentRoot; +} + +/** Where one host's agents, main settings and ownership registry live for a scope. */ +export interface AgentLocation { + registryRoot: string; + /** Creates the registry root when the scope owns it. */ + prepare?: () => Promise; + agentPath(name: string): string; + settingsPath: string | null; + guard(path: string): Promise; +} + +export function agentsCapability(host: AgentHost): AgentsCapability { + return (getAiToolConfig(host).capabilities as HasAgents).agents; +} + +export function resolveAgentLocation( + fs: FileReader & FileWriter, + target: AgentTarget +): AgentLocation { + const capability = agentsCapability(target.host); + const settings = mainModelTarget(target.host); + const file = (name: string) => { + const path = capability.buildInstallPath(`${name}.md`); + if (path === null) throw new Error(`Agent ${name} has no native install path.`); + return path; + }; + const user = target.user; + if (user === undefined) { + const root = target.projectRoot; + return { + registryRoot: root, + agentPath: (name) => join(root, file(name)), + settingsPath: settings === null ? null : join(root, settings.path), + guard: (path) => assertProjectPathWithinRoot(fs, root, path), + }; + } + const directory = capability.params.userDirectory?.(user.home, user.env); + if (directory === undefined) throw new Error(`${target.host} has no user-level agents.`); + const configDir = user.configDir; + return { + registryRoot: configDir, + prepare: () => fs.createDirectory(configDir), + agentPath: (name) => join(directory, "agents", basename(file(name))), + settingsPath: settings === null ? null : join(directory, settings.userPath), + guard: async () => {}, + }; +} diff --git a/cli/src/contexts/framework/application/agents/main-model-changes.ts b/cli/src/contexts/framework/application/agents/main-model-changes.ts new file mode 100644 index 000000000..0c15cb90c --- /dev/null +++ b/cli/src/contexts/framework/application/agents/main-model-changes.ts @@ -0,0 +1,76 @@ +import { basename, 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 { AgentHost, AgentProvider } from "../../../tools/domain/formats/agent-models.js"; +import { + mainModelTarget, + mainModelValues, + planMainModelInstall, + planMainModelRemoval, +} from "../../../tools/domain/formats/main-model-config.js"; +import type { MainModelInstallations } from "../../domain/ports/agent-installation-store.js"; +import { type AgentLocation, type AgentTarget, resolveAgentLocation } from "./agent-location.js"; + +/** A file's next content; null deletes it. */ +export interface FileChange { + path: string; + content: string | null; +} + +export async function readOptional(fs: FileReader, path: string): Promise { + return (await fs.fileExists(path)) ? fs.readFile(path) : null; +} + +/** The provider's main model written into the host settings, recording what it replaces. */ +export async function mainModelInstall( + fs: FileReader & FileWriter, + options: AgentTarget & { provider: AgentProvider }, + location: AgentLocation, + mainModels: MainModelInstallations +): Promise { + const target = mainModelTarget(options.host); + const path = location.settingsPath; + if (target === null || path === null) return null; + await location.guard(path); + if ((await fs.fileExists(path, false)) && !(await fs.fileExists(path))) + throw new Error(`Settings link is broken; fix it before installing agents: ${path}`); + const content = await readOptional(fs, path); + for (const alternative of content === null ? (target.alternatives ?? []) : []) + if (await fs.fileExists(join(dirname(path), alternative))) + throw new Error( + `Settings live in ${alternative}; move them to ${basename(path)} before installing agents.` + ); + try { + const plan = planMainModelInstall( + content, + target.format, + mainModelValues(options.host, options.provider), + mainModels[options.host] + ); + mainModels[options.host] = plan.record; + return { path, content: plan.content }; + } catch (error) { + throw new Error(`Cannot set the main model in ${path}: ${(error as Error).message}`); + } +} + +/** The settings each recorded main model replaced, restored. */ +export async function mainModelRemovals( + fs: FileReader & FileWriter, + target: Omit, + mainModels: MainModelInstallations, + onlyHost?: AgentHost +): Promise { + const changes: FileChange[] = []; + for (const [host, record] of Object.entries(mainModels)) { + if (onlyHost !== undefined && host !== onlyHost) continue; + const settings = mainModelTarget(host as AgentHost); + const location = resolveAgentLocation(fs, { ...target, host: host as AgentHost }); + if (settings === null || location.settingsPath === null || record === undefined) continue; + await location.guard(location.settingsPath); + const content = await readOptional(fs, location.settingsPath); + const next = planMainModelRemoval(content, settings.format, record); + if (next !== content) changes.push({ path: location.settingsPath, content: next }); + } + return changes; +} diff --git a/cli/src/contexts/framework/application/agents/manage-agents-use-case.ts b/cli/src/contexts/framework/application/agents/manage-agents-use-case.ts new file mode 100644 index 000000000..e566a5523 --- /dev/null +++ b/cli/src/contexts/framework/application/agents/manage-agents-use-case.ts @@ -0,0 +1,257 @@ +import { dirname, join } from "node:path"; +import { parseFrontmatter } from "../../../../kernel/markdown.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 AgentHost, + type AgentProvider, + agentModelOptions, +} from "../../../tools/domain/formats/agent-models.js"; +import { + AGENT_INSTALLATIONS_PATH, + type AgentInstallationStore, + type AgentInstallations, + MAIN_MODEL_INSTALLATIONS_PATH, + type MainModelInstallations, +} from "../../domain/ports/agent-installation-store.js"; +import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js"; +import { + type AgentLocation, + type AgentTarget, + agentsCapability, + resolveAgentLocation, +} from "./agent-location.js"; +import { + type FileChange, + mainModelInstall, + mainModelRemovals, + readOptional, +} from "./main-model-changes.js"; + +/** A registry name maps to one file inside the agents directory, never a path. */ +const AGENT_NAME = /^[a-z0-9][a-z0-9-]*$/; + +/** Installs, refreshes and removes the bundled agents, owning only the files it wrote. */ +export class ManageAgentsUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly store: AgentInstallationStore, + private readonly hasher: Hasher, + private readonly sources: Readonly> + ) {} + + /** `main` opts in to setting the main session model; without it the host settings stay as they are. */ + async install( + options: AgentTarget & { provider: AgentProvider; main?: boolean } + ): Promise { + const capability = agentsCapability(options.host); + const location = resolveAgentLocation(this.fs, options); + if (options.main === true && location.settingsPath === null) + throw new Error(`${options.host} has no main model setting AIDD can write.`); + const entries = Object.entries(this.sources).map(([name, source]) => { + const { frontmatter, body } = parseFrontmatter(source); + const role = typeof frontmatter.name === "string" ? frontmatter.name : name; + const model = agentModelOptions(options.host, options.provider, role); + return { + name, + path: location.agentPath(name), + content: capability.serialize( + { ...capability.convertFrontmatter({ ...frontmatter, name }, `${name}.md`), ...model }, + body + ), + }; + }); + await location.prepare?.(); + return this.exclusively(location.registryRoot, async () => { + const installed = await this.load(location.registryRoot); + const owned = installed[options.host] ?? {}; + for (const entry of entries) await this.assertOwned(location, entry.path, owned[entry.name]); + // Roles no longer bundled: remove their unmodified files. + const retired: FileChange[] = []; + for (const [name, hash] of Object.entries(owned)) { + if (Object.hasOwn(this.sources, name)) continue; + const path = location.agentPath(name); + await this.assertOwned(location, path, hash); + retired.push({ path, content: null }); + } + installed[options.host] = Object.fromEntries( + entries.map(({ name, content }) => [name, this.hasher.hash(content)]) + ); + const mainModels = await this.store.loadMainModels(location.registryRoot); + const main = + options.main === true + ? await mainModelInstall(this.fs, options, location, mainModels) + : null; + return this.save( + location.registryRoot, + installed, + mainModels, + [...entries, ...retired], + main === null ? [] : [main] + ); + }); + } + + async remove(options: AgentTarget): Promise { + const location = resolveAgentLocation(this.fs, options); + await location.prepare?.(); + return this.exclusively(location.registryRoot, async () => { + const installed = await this.load(location.registryRoot); + const entries = await this.removable(options, installed, options.host); + const mainModels = await this.store.loadMainModels(location.registryRoot); + const main = await mainModelRemovals(this.fs, options, mainModels, options.host); + delete installed[options.host]; + delete mainModels[options.host]; + return this.save( + location.registryRoot, + installed, + mainModels, + entries.map((path) => ({ path, content: null })), + main + ); + }); + } + + async preview(projectRoot: string): Promise> { + const installed = await this.load(projectRoot); + const tools: Array<{ toolId: AgentHost; fileCount: number }> = []; + for (const host of Object.keys(installed)) { + const entries = installed[host as AgentHost]; + if (entries !== undefined) + tools.push({ toolId: host as AgentHost, fileCount: Object.keys(entries).length }); + } + return tools; + } + + async assertRemovable(projectRoot: string): Promise { + await this.removable({ projectRoot }, await this.load(projectRoot)); + } + + async removeAll(projectRoot: string, afterRemoval?: () => Promise): Promise { + return this.exclusively(projectRoot, async () => { + const entries = await this.removable({ projectRoot }, await this.load(projectRoot)); + const main = await mainModelRemovals( + this.fs, + { projectRoot }, + await this.store.loadMainModels(projectRoot) + ); + const count = await this.save( + projectRoot, + {}, + {}, + entries.map((path) => ({ path, content: null })), + main + ); + await afterRemoval?.(); + return count; + }); + } + + private async exclusively(root: string, action: () => Promise): Promise { + await assertProjectPathWithinRoot(this.fs, root, join(root, AGENT_INSTALLATIONS_PATH)); + return this.store.withExclusiveAccess(root, action); + } + + /** Applies every change, then both registries; any failure restores all of them. */ + private async save( + root: string, + installed: AgentInstallations, + mainModels: MainModelInstallations, + changes: readonly FileChange[], + mainChanges: readonly FileChange[] = [] + ): Promise { + const previous: FileChange[] = []; + const agentPaths = new Set(changes.map((change) => change.path)); + try { + for (const change of [...changes, ...mainChanges]) { + const content = await readOptional(this.fs, change.path); + if (content === change.content) continue; + previous.push({ path: change.path, content }); + await this.write(change); + } + for (const registry of [AGENT_INSTALLATIONS_PATH, MAIN_MODEL_INSTALLATIONS_PATH]) { + const path = join(root, registry); + previous.push({ path, content: await readOptional(this.fs, path) }); + } + await this.store.save(root, installed); + await this.store.saveMainModels(root, mainModels); + return previous.filter((change) => agentPaths.has(change.path)).length; + } catch (error) { + const restored = await Promise.allSettled( + previous.reverse().map(async (change) => { + if ((await readOptional(this.fs, change.path)) !== change.content) + await this.write(change); + }) + ); + const failures = restored.filter((result) => result.status === "rejected"); + if (failures.length > 0) + throw new AggregateError( + [error, ...failures.map((result) => result.reason)], + "Agent update failed and rollback was incomplete. Preserve the registry and agent files before retrying." + ); + throw error; + } + } + + /** Writes through an existing link, so linked settings files stay linked. */ + private async write({ path, content }: FileChange): Promise { + if (content !== null) { + const target = (await this.fs.fileExists(path)) ? await this.fs.realpath(path) : path; + await this.fs.writeFile(target, content); + } else { + await this.fs.deleteFile(path); + if (await this.fs.fileExists(path)) throw new Error(`Could not remove agent: ${path}`); + } + } + + private async load(root: string): Promise { + await assertProjectPathWithinRoot(this.fs, root, join(root, AGENT_INSTALLATIONS_PATH)); + const installed = await this.store.load(root); + for (const entries of Object.values(installed)) { + for (const name of Object.keys(entries)) { + if (!AGENT_NAME.test(name)) throw new Error(`Unknown managed agent: ${name}`); + } + } + return installed; + } + + private async assertOwned( + location: AgentLocation, + path: string, + expected: ReturnType | undefined + ): Promise { + await location.guard(path); + if (!(await this.fs.fileExists(path, false))) return; + if (expected === undefined) throw new Error(`Agent file is not owned by AIDD: ${path}`); + if (!(await this.fs.readFileHash(path)).equals(expected)) + throw new Error(`Agent file was modified; preserve or move it before continuing: ${path}`); + } + + private async removable( + target: Omit, + installed: AgentInstallations, + onlyHost?: AgentHost + ): Promise { + const paths: string[] = []; + for (const [host, entries] of Object.entries(installed)) { + if (onlyHost !== undefined && host !== onlyHost) continue; + const location = resolveAgentLocation(this.fs, { ...target, host: host as AgentHost }); + // A user directory resolved elsewhere than at install would drop the record unseen. + const names = Object.keys(entries); + if (target.user !== undefined && names.length > 0) { + const directory = dirname(location.agentPath(names[0] as string)); + if (!(await this.fs.fileExists(directory))) + throw new Error( + `No agents directory at ${directory}; restore the ${host} configuration directory used at install.` + ); + } + for (const [name, hash] of Object.entries(entries)) { + const path = location.agentPath(name); + await this.assertOwned(location, path, hash); + if (await this.fs.fileExists(path)) paths.push(path); + } + } + return paths; + } +} diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index 237c32067..dc7eb09fe 100644 --- a/cli/src/contexts/framework/application/clean-use-case.ts +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -35,8 +35,10 @@ import type { NativeRegistrations } from "../domain/manifest/native-registration 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 { AGENT_INSTALLATIONS_LOCK_PATH } from "../domain/ports/agent-installation-store.js"; import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../domain/ports/user-source-references.js"; +import type { ManageAgentsUseCase } from "./agents/manage-agents-use-case.js"; import type { GitignoreUseCase } from "./gitignore-use-case.js"; import { assertNoForeignNativeRefs, @@ -149,19 +151,42 @@ export class CleanUseCase { HostPluginRegistryReader > = new Map(), private readonly userManifestRepo?: ManifestRepository, - private readonly nativeSources: ReadonlyMap = new Map() + private readonly nativeSources: ReadonlyMap< + AiToolId, + NativeMarketplaceSourceReader + > = new Map(), + private readonly agents?: Pick ) {} async execute(options: CleanOptions): Promise { const manifest = await this.manifestRepo.load(); + const agentTools = (await this.agents?.preview(options.projectRoot)) ?? []; if (manifest === null) { + if (agentTools.length > 0) { + const preview: CleanPreview = { + tools: agentTools, + totalFileCount: agentTools.reduce((sum, tool) => sum + tool.fileCount, 0), + nativeRegistrations: [], + }; + const dryRun = await this.confirmOrDryRun(options, preview); + if (dryRun !== null) return dryRun; + const count = (await this.agents?.removeAll(options.projectRoot)) ?? 0; + return { dryRun: false, manifestFound: true, preview, fileCount: count }; + } 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); + for (const agentTool of agentTools) { + const tool = preview.tools.find((entry) => entry.toolId === agentTool.toolId); + if (tool === undefined) preview.tools.push(agentTool); + else tool.fileCount += agentTool.fileCount; + preview.totalFileCount += agentTool.fileCount; + } const dryRunResult = await this.confirmOrDryRun(options, preview); if (dryRunResult !== null) return dryRunResult; + await this.agents?.assertRemovable(options.projectRoot); await this.assertNativeSourcesUnchanged(manifest, options.projectRoot); for (const toolId of manifest.getInstalledToolIds()) { if (!isAiToolId(toolId)) continue; @@ -197,7 +222,9 @@ export class CleanUseCase { 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); + const removeState = () => this.removeAiddState(options.projectRoot); + if (this.agents) deleted += await this.agents.removeAll(options.projectRoot, removeState); + else await removeState(); // Exactly what the pipeline added on install, never a subset of it. await this.gitignoreUseCase.remove(options.projectRoot, aiddGitignoreEntries(manifest)); // One project-level decrement, only after the local projection has been cleaned successfully. @@ -284,7 +311,10 @@ export class CleanUseCase { if (!(await this.fs.fileExists(aiddDir))) return; const remaining = await this.fs.listDirectory(aiddDir); - if (remaining.length === 0) { + if ( + remaining.length === 0 && + !(await this.fs.fileExists(join(projectRoot, AGENT_INSTALLATIONS_LOCK_PATH))) + ) { await this.fs.deleteDirectory(aiddDir); return; } diff --git a/cli/src/contexts/framework/domain/ports/agent-installation-store.ts b/cli/src/contexts/framework/domain/ports/agent-installation-store.ts new file mode 100644 index 000000000..ec10d3e4a --- /dev/null +++ b/cli/src/contexts/framework/domain/ports/agent-installation-store.ts @@ -0,0 +1,20 @@ +import type { FileHash } from "../../../../kernel/file.js"; +import type { AgentHost } from "../../../tools/domain/formats/agent-models.js"; +import type { MainModelRecord } from "../../../tools/domain/formats/main-model-config.js"; + +export type AgentInstallations = Partial>>; + +/** Main-session settings AIDD wrote per host, so removal restores what was there before. */ +export type MainModelInstallations = Partial>; + +export interface AgentInstallationStore { + withExclusiveAccess(projectRoot: string, action: () => Promise): Promise; + load(projectRoot: string): Promise; + save(projectRoot: string, installations: AgentInstallations): Promise; + loadMainModels(projectRoot: string): Promise; + saveMainModels(projectRoot: string, installations: MainModelInstallations): Promise; +} + +export const AGENT_INSTALLATIONS_PATH = ".aidd/agents.json"; +export const MAIN_MODEL_INSTALLATIONS_PATH = ".aidd/main-model.json"; +export const AGENT_INSTALLATIONS_LOCK_PATH = `${AGENT_INSTALLATIONS_PATH}.lock`; diff --git a/cli/src/contexts/framework/infrastructure/agent-installation-store-adapter.ts b/cli/src/contexts/framework/infrastructure/agent-installation-store-adapter.ts new file mode 100644 index 000000000..bb545eed4 --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/agent-installation-store-adapter.ts @@ -0,0 +1,134 @@ +import { mkdir, rmdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { setTimeout as pause } from "node:timers/promises"; +import { FileHash } from "../../../kernel/file.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import { isAgentHost } from "../../tools/domain/formats/agent-models.js"; +import { + AGENT_INSTALLATIONS_LOCK_PATH, + AGENT_INSTALLATIONS_PATH, + type AgentInstallationStore, + type AgentInstallations, + MAIN_MODEL_INSTALLATIONS_PATH, + type MainModelInstallations, +} from "../domain/ports/agent-installation-store.js"; + +export class AgentInstallationStoreAdapter implements AgentInstallationStore { + constructor(private readonly fs: FileReader & FileWriter) {} + + async withExclusiveAccess(projectRoot: string, action: () => Promise): Promise { + const lock = join(projectRoot, AGENT_INSTALLATIONS_LOCK_PATH); + const deadline = Date.now() + 30_000; + while (true) { + try { + await mkdir(dirname(lock), { recursive: true }); + await mkdir(lock); + break; + } catch (error) { + if (!["EEXIST", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + if (Date.now() >= deadline) + throw new Error( + `Agent ownership registry is busy: ${lock}. Retry after the other AIDD operation finishes; remove this lock only if that operation has stopped.` + ); + await pause(50); + } + } + try { + return await action(); + } finally { + await this.release(lock); + } + } + + private async release(lock: string): Promise { + await rmdir(lock); + try { + await rmdir(dirname(lock)); + } catch (error) { + if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + } + } + + async load(projectRoot: string): Promise { + const path = join(projectRoot, AGENT_INSTALLATIONS_PATH); + if (!(await this.fs.fileExists(path, false))) return {}; + const parsed: unknown = JSON.parse(await this.fs.readFile(path)); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + throw new Error(`Invalid agent ownership file: ${path}`); + const installations: AgentInstallations = {}; + for (const [host, entries] of Object.entries(parsed)) { + if ( + !isAgentHost(host) || + typeof entries !== "object" || + entries === null || + Array.isArray(entries) + ) + throw new Error(`Invalid agent ownership file: ${path}`); + installations[host] = Object.fromEntries( + Object.entries(entries).map(([name, hash]) => { + if (typeof hash !== "string") throw new Error(`Invalid agent hash: ${name}`); + return [name, new FileHash(hash)]; + }) + ); + } + return installations; + } + + async save(projectRoot: string, installations: AgentInstallations): Promise { + const path = join(projectRoot, AGENT_INSTALLATIONS_PATH); + if (Object.keys(installations).length === 0) { + await this.fs.deleteFile(path); + if (await this.fs.fileExists(path)) + throw new Error(`Could not remove agent ownership file: ${path}`); + return; + } + const data = Object.fromEntries( + Object.entries(installations).map(([host, entries]) => [ + host, + Object.fromEntries(Object.entries(entries).map(([name, hash]) => [name, hash.value])), + ]) + ); + await this.fs.writeFile(path, `${JSON.stringify(data, null, 2)}\n`); + } + + async loadMainModels(projectRoot: string): Promise { + const path = join(projectRoot, MAIN_MODEL_INSTALLATIONS_PATH); + if (!(await this.fs.fileExists(path, false))) return {}; + const parsed: unknown = JSON.parse(await this.fs.readFile(path)); + const invalid = new Error(`Invalid main model ownership file: ${path}`); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw invalid; + const installations: MainModelInstallations = {}; + const strings = (value: unknown, nullable = false) => + typeof value === "object" && + value !== null && + Object.values(value).every( + (entry) => typeof entry === "string" || (nullable && entry === null) + ); + for (const [host, record] of Object.entries(parsed)) { + if ( + !isAgentHost(host) || + !strings(record?.written) || + !strings(record?.previous, true) || + typeof record?.created !== "boolean" + ) + throw invalid; + installations[host] = record; + } + return installations; + } + + async saveMainModels(projectRoot: string, installations: MainModelInstallations): Promise { + const path = join(projectRoot, MAIN_MODEL_INSTALLATIONS_PATH); + if (Object.keys(installations).length > 0) { + await this.fs.writeFile(path, `${JSON.stringify(installations, null, 2)}\n`); + return; + } + if (!(await this.fs.fileExists(path, false))) return; + await this.fs.deleteFile(path); + if (await this.fs.fileExists(path)) + throw new Error(`Could not remove main model ownership file: ${path}`); + } +} diff --git a/cli/src/contexts/tools/domain/capabilities/agents-capability.ts b/cli/src/contexts/tools/domain/capabilities/agents-capability.ts index 625db1ae6..2e3cc2b6b 100644 --- a/cli/src/contexts/tools/domain/capabilities/agents-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/agents-capability.ts @@ -1,4 +1,5 @@ import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; +import type { EnvironmentReader } from "./plugins-capability.js"; function agentNameFromFrontmatter( fm: Record, @@ -21,6 +22,11 @@ function buildTomlContent(frontmatter: Record, body: string): s if (frontmatter.model !== undefined) { lines.push(`model = ${tomlString(String(frontmatter.model))}`); } + if (frontmatter.model_reasoning_effort !== undefined) { + lines.push( + `model_reasoning_effort = ${tomlString(String(frontmatter.model_reasoning_effort))}` + ); + } lines.push(`developer_instructions = """\n${body}\n"""`); return `${lines.join("\n")}\n`; } @@ -51,7 +57,12 @@ export class AgentsCapability { directory: string; toolSuffix: string; format: "markdown" | "toml"; + effortField?: string; userFileExt?: string; + /** The IDE's user configuration directory, holding `agents/` and the main settings file. */ + userDirectory?: (home: string, env: EnvironmentReader) => string; + /** How frontmatter strings are quoted; `double` for readers that keep single quotes. */ + quote?: "single" | "double"; buildInstallPath?: (fileName: string) => string | null; convertFrontmatter?: ( fm: Record, @@ -84,21 +95,32 @@ export class AgentsCapability { } convertFrontmatter(fm: Record, fileName?: string): Record { + const converted = this.convertAgentFrontmatter(fm, fileName); + if (fm.model !== undefined) converted.model = fm.model; + const effortField = this.params.effortField; + if (effortField !== undefined && fm[effortField] !== undefined) { + converted[effortField] = fm[effortField]; + } + return converted; + } + + private convertAgentFrontmatter( + fm: Record, + fileName?: string + ): Record { if (this.params.convertFrontmatter) return this.params.convertFrontmatter(fm, fileName); const name = agentNameFromFrontmatter(fm, fileName); - if (this.params.format === "toml") { - const result: Record = { name, description: fm.description }; - if (fm.model !== undefined) result.model = fm.model; - return result; - } - return { name, description: fm.description }; + return { + name, + description: fm.description, + }; } serialize(frontmatter: Record, body: string): string { if (this.params.format === "toml") { return buildTomlContent(frontmatter, body); } - return serializeFrontmatter(frontmatter, body); + return serializeFrontmatter(frontmatter, body, this.params.quote); } deserialize(content: string): { frontmatter: Record; body: string } { diff --git a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts index 6aff4c5ad..01029d4a1 100644 --- a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts +++ b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts @@ -4,10 +4,21 @@ export const COPILOT_AGENT_FRONTMATTER_KEYS: readonly [ "name", "description", "model", + "reasoningEffort", + "include-custom-instructions", "tools", "agents", "argument-hint", -] = ["name", "description", "model", "tools", "agents", "argument-hint"]; +] = [ + "name", + "description", + "model", + "reasoningEffort", + "include-custom-instructions", + "tools", + "agents", + "argument-hint", +]; /** Cursor-supported frontmatter keys: Cursor documents only name, description and model — * never tools or color. */ diff --git a/cli/src/contexts/tools/domain/formats/agent-models.json b/cli/src/contexts/tools/domain/formats/agent-models.json new file mode 100644 index 000000000..c2058b19f --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-models.json @@ -0,0 +1,106 @@ +{ + "roles": { + "main": "main", + "explorer": "light", + "executor": "light", + "validator": "light", + "reviewer": "standard" + }, + "providers": { + "openai": { + "main": { + "model": "gpt-6.1-sol", + "effort": "medium" + }, + "standard": { + "model": "gpt-6.1-sol", + "effort": "medium" + }, + "light": { + "model": "gpt-6-luna", + "effort": "medium" + } + }, + "anthropic": { + "main": { + "model": "claude-opus-5-5", + "effort": "medium", + "nativeModels": { + "copilot": "claude-opus-5.5" + } + }, + "standard": { + "model": "claude-sonnet-5-5", + "effort": "medium", + "nativeModels": { + "copilot": "claude-sonnet-5.5" + } + }, + "light": { + "model": "claude-haiku-5-5", + "effort": "medium", + "nativeModels": { + "copilot": "claude-haiku-5.5" + } + } + }, + "mistral": { + "main": { + "model": "mistral-large-4", + "effort": "high" + }, + "standard": { + "model": "mistral-medium-latest", + "effort": "high" + }, + "light": { + "model": "mistral-small-latest", + "effort": "high" + } + } + }, + "hosts": { + "claude": { + "ide": "claude-code", + "providers": ["anthropic"], + "effortField": "effort", + "mainSettings": { + "path": ".claude/settings.json", + "userPath": "settings.json", + "format": "json", + "effortKey": "effortLevel" + } + }, + "codex": { + "providers": ["openai"], + "effortField": "model_reasoning_effort", + "mainSettings": { + "path": ".codex/config.toml", + "userPath": "config.toml", + "format": "toml", + "effortKey": "model_reasoning_effort" + } + }, + "copilot": { + "providers": ["openai", "anthropic"], + "effortField": "reasoningEffort" + }, + "opencode": { + "providers": ["openai", "anthropic", "mistral"], + "effortPrefix": "#", + "effortSuffix": "", + "providerPrefix": true, + "mainSettings": { + "path": "opencode.json", + "userPath": "opencode.json", + "format": "json", + "alternatives": ["opencode.jsonc"] + } + }, + "cursor": { + "providers": ["anthropic"], + "effortPrefix": "[effort=", + "effortSuffix": "]" + } + } +} diff --git a/cli/src/contexts/tools/domain/formats/agent-models.ts b/cli/src/contexts/tools/domain/formats/agent-models.ts new file mode 100644 index 000000000..34bdf6e6d --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-models.ts @@ -0,0 +1,71 @@ +import models from "./agent-models.json" with { type: "json" }; + +export type AgentHost = keyof typeof models.hosts; +export type AgentProvider = keyof typeof models.providers; + +interface AgentModelProfile { + model: string; + effort: string; + nativeModels?: Partial>; +} + +export function isAgentHost(value: unknown): value is AgentHost { + return typeof value === "string" && Object.hasOwn(models.hosts, value); +} + +export function isAgentProvider(value: unknown): value is AgentProvider { + return typeof value === "string" && Object.hasOwn(models.providers, value); +} + +export function agentProviderNames(): string[] { + return Object.keys(models.providers); +} + +export function agentIdeNames(): string[] { + return Object.entries(models.hosts).map(([host, target]) => + "ide" in target ? target.ide : host + ); +} + +export function resolveAgentHost(ide: string): AgentHost { + const host = Object.entries(models.hosts).find( + ([name, target]) => name === ide || ("ide" in target && target.ide === ide) + )?.[0]; + if (!isAgentHost(host)) throw new Error(`IDE "${ide}" is not supported.`); + return host; +} + +/** The host's native model id and the effort for a configured role. */ +export function agentModel( + host: AgentHost, + provider: AgentProvider, + role: string +): { model: string; effort: string } { + const target = models.hosts[host]; + if (!target.providers.includes(provider)) { + throw new Error(`Provider "${provider}" is not supported by "${host}".`); + } + const profile = Object.entries(models.roles).find(([name]) => name === role)?.[1]; + if (profile === undefined) throw new Error(`Agent role "${role}" is not configured.`); + const source: AgentModelProfile | undefined = Object.entries(models.providers[provider]).find( + ([name]) => name === profile + )?.[1]; + if (source === undefined) + throw new Error(`Agent profile "${profile}" is not configured for "${provider}".`); + const nativeModel = source.nativeModels?.[host] ?? source.model; + const prefix = "providerPrefix" in target && target.providerPrefix ? `${provider}/` : ""; + return { model: `${prefix}${nativeModel}`, effort: source.effort }; +} + +/** Native fields for a configured role on the selected host and provider. */ +export function agentModelOptions( + host: AgentHost, + provider: AgentProvider, + role: string +): Record { + const target = models.hosts[host]; + const { model, effort } = agentModel(host, provider, role); + return "effortField" in target + ? { model, [target.effortField]: effort } + : { model: `${model}${target.effortPrefix}${effort}${target.effortSuffix}` }; +} diff --git a/cli/src/contexts/tools/domain/formats/main-model-config.ts b/cli/src/contexts/tools/domain/formats/main-model-config.ts new file mode 100644 index 000000000..5d4077d98 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/main-model-config.ts @@ -0,0 +1,170 @@ +import { parse } from "smol-toml"; +import { type AgentHost, type AgentProvider, agentModel } from "./agent-models.js"; +import models from "./agent-models.json" with { type: "json" }; + +/** Main-session settings AIDD writes, keyed by the host's native config key. */ +export type MainModelValues = Record; + +export interface MainModelTarget { + /** Project-relative settings file. */ + path: string; + /** The same file, relative to the IDE's user configuration directory. */ + userPath: string; + format: "json" | "toml"; + effortKey?: string; + /** Equivalent files the host may use instead, which AIDD does not edit. */ + alternatives?: readonly string[]; +} + +/** What AIDD wrote and what each key held before, so removal restores it. */ +export interface MainModelRecord { + written: MainModelValues; + previous: Record; + /** The settings file did not exist before AIDD wrote it. */ + created: boolean; +} + +/** Hosts without a file-based main model return null. */ +export function mainModelTarget(host: AgentHost): MainModelTarget | null { + const target = models.hosts[host]; + return "mainSettings" in target ? (target.mainSettings as MainModelTarget) : null; +} + +/** The provider's main model; effort only where the host has a settings key for it. */ +export function mainModelValues(host: AgentHost, provider: AgentProvider): MainModelValues { + const target = mainModelTarget(host); + if (target === null) return {}; + const { model, effort } = agentModel(host, provider, "main"); + return target.effortKey === undefined ? { model } : { model, [target.effortKey]: effort }; +} + +/** Sets the main model, recording prior values; refuses values changed since AIDD wrote them. */ +export function planMainModelInstall( + content: string | null, + format: MainModelTarget["format"], + values: MainModelValues, + record: MainModelRecord | undefined +): { content: string; record: MainModelRecord } { + const current = readSettings(content, format); + for (const [key, value] of Object.entries(record?.written ?? {})) + if (current[key] !== value) + throw new Error(`"${key}" changed since AIDD set it; restore it or edit it yourself.`); + const previous: Record = {}; + for (const key of Object.keys(values)) { + const value = current[key]; + if (value !== undefined && typeof value !== "string") + throw new Error(`"${key}" is not a string; edit it yourself.`); + previous[key] = value ?? null; + } + return { + content: editSettings(content, format, values, []), + record: { + written: values, + previous: { ...previous, ...record?.previous }, + created: record?.created ?? content === null, + }, + }; +} + +/** Restores unchanged keys; leaves unreadable or user-edited settings as they are. */ +export function planMainModelRemoval( + content: string | null, + format: MainModelTarget["format"], + record: MainModelRecord +): string | null { + if (content === null) return null; + try { + const current = readSettings(content, format); + const set: MainModelValues = {}; + const unset: string[] = []; + for (const [key, value] of Object.entries(record.written)) { + if (current[key] !== value) continue; + const previous = record.previous[key] ?? null; + if (previous === null) unset.push(key); + else set[key] = previous; + } + if (Object.keys(set).length === 0 && unset.length === 0) return content; + const next = editSettings(content, format, set, unset); + return record.created && Object.keys(readSettings(next, format)).length === 0 ? null : next; + } catch { + return content; + } +} + +function readSettings( + content: string | null, + format: MainModelTarget["format"] +): Record { + if (content === null || content.trim() === "") return {}; + if (format === "toml") return parse(content); + const parsed: unknown = JSON.parse(content.replace(/^/, "")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + throw new Error("Settings file must contain a JSON object."); + return parsed as Record; +} + +function editSettings( + content: string | null, + format: MainModelTarget["format"], + set: MainModelValues, + unset: readonly string[] +): string { + const settings = readSettings(content, format); + for (const [key, value] of Object.entries(set)) settings[key] = value; + for (const key of unset) delete settings[key]; + if (format === "json") { + const bom = content?.startsWith("") ? "" : ""; + const indent = /\n([ \t]+)"/.exec(content ?? "")?.[1] ?? " "; + return `${bom}${JSON.stringify(settings, null, indent)}\n`; + } + const next = editTomlLines(content ?? "", set, unset); + const unsafe = new Error("Cannot edit this TOML safely; set the model yourself."); + let edited: unknown; + try { + edited = parse(next); + } catch { + throw unsafe; + } + if (canonical(edited) !== canonical(settings)) throw unsafe; + return next; +} + +/** Rewrites only simple top-level `key = "value"` lines, keeping comments and line endings. */ +function editTomlLines(content: string, set: MainModelValues, unset: readonly string[]): string { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const lines = content === "" ? [""] : content.split(/\r?\n/); + const inserted: string[] = []; + for (const [key, value] of Object.entries(set)) { + const index = topLevelKeyIndex(lines, key); + const line = `${key} = ${JSON.stringify(value)}`; + if (index === -1) inserted.push(line); + else lines[index] = (lines[index] as string).replace(valuePattern(key), `${line}$1`); + } + for (const key of unset) { + const index = topLevelKeyIndex(lines, key); + if (index !== -1) lines.splice(index, 1); + } + return [...inserted, ...lines].join(eol); +} + +function valuePattern(key: string): RegExp { + return new RegExp(`^\\s*${key}\\s*=\\s*(?:"(?:[^"\\\\]|\\\\.)*"|'[^']*')(\\s*(?:#.*)?)$`); +} + +/** Index of `key = ...` before the first table header, or -1. */ +function topLevelKeyIndex(lines: readonly string[], key: string): number { + const assignment = new RegExp(`^\\s*${key}\\s*=`); + for (const [index, line] of lines.entries()) { + if (/^\s*\[/.test(line)) return -1; + if (assignment.test(line)) return index; + } + return -1; +} + +function canonical(value: unknown): string { + return JSON.stringify(value, (_, entry) => + entry !== null && typeof entry === "object" && !Array.isArray(entry) && !(entry instanceof Date) + ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) + : entry + ); +} 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 index 79f069c54..1c784225b 100644 --- a/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts +++ b/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts @@ -3,12 +3,8 @@ 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. + * Readers use the `@` refs their hosts record. A host with no reader + * is absent from the map, 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 @@ -35,8 +31,5 @@ export interface HostPluginRegistryReading { } 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/profiles/claude/build.ts b/cli/src/contexts/tools/domain/profiles/claude/build.ts index 7bcdb38f2..145dd8758 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/build.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/build.ts @@ -108,7 +108,13 @@ function transformClaudeFlatAgent(content: string, plugin: string, outName: stri resolveTargetPath: (rel) => claudeFlatResolveTarget(plugin, rel), }); const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...frontmatter, name: prefixedName }, rewrittenBody); + return serializeFrontmatter( + { + ...frontmatter, + name: prefixedName, + }, + rewrittenBody + ); } export function buildClaudeFlatContract(): ToolBuildContract { diff --git a/cli/src/contexts/tools/domain/profiles/claude/claude-paths.ts b/cli/src/contexts/tools/domain/profiles/claude/claude-paths.ts new file mode 100644 index 000000000..676d62632 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/claude/claude-paths.ts @@ -0,0 +1,9 @@ +import { join } from "node:path"; + +export function claudeConfigDir(home: string, env: NodeJS.ProcessEnv): string { + return env.CLAUDE_CONFIG_DIR || join(home, ".claude"); +} + +export function claudePluginsDir(home: string, env: NodeJS.ProcessEnv): string { + return env.CLAUDE_CODE_PLUGIN_CACHE_DIR || join(claudeConfigDir(home, env), "plugins"); +} diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts index 2eeeeb2c6..08b3d52b2 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -48,6 +48,8 @@ export const claude: AiTool env("CLAUDE_CONFIG_DIR") || join(home, ".claude"), }), skills: new SkillsCapability({ directory: DIRECTORY, 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 index 3ed7225db..36346d2c8 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts @@ -1,10 +1,13 @@ import { parseFrontmatter } from "../../../../../kernel/markdown.js"; import { stringifyToml } from "./toml.js"; +const CLAUDE_MODEL_SELECTOR = + /^(?:default|inherit|best|fable|haiku|opus|opusplan|sonnet)(?:\[1m\])?$/i; + /** * 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 + * 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 @@ -44,7 +47,13 @@ function buildTomlObject( 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. + // Claude shorthand selectors are not native model IDs; leave the model inherited. + if (typeof frontmatter.model === "string" && !CLAUDE_MODEL_SELECTOR.test(frontmatter.model)) { + obj.model = frontmatter.model; + } + if (frontmatter.model_reasoning_effort !== undefined) { + obj.model_reasoning_effort = frontmatter.model_reasoning_effort; + } obj.developer_instructions = body; return obj; } diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index 1488b185d..0b47c5014 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -138,7 +138,13 @@ export const codex: AiTool< buildContracts: { marketplace: buildCodexContract, flat: buildCodexFlatContract }, capabilities: { - agents: new AgentsCapability({ directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, format: "toml" }), + agents: new AgentsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + format: "toml", + effortField: "model_reasoning_effort", + userDirectory: (home, env) => env("CODEX_HOME") || join(home, ".codex"), + }), skills: new SkillsCapability({ prefix: "aidd-", buildInstallPath: buildCodexSkillFilePath, diff --git a/cli/src/contexts/tools/domain/profiles/copilot/build.ts b/cli/src/contexts/tools/domain/profiles/copilot/build.ts index 4bb9deaff..06f918172 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/build.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/build.ts @@ -23,11 +23,15 @@ import { synthesizeClaudeStyleManifest, transformClaudeAgent, } from "../../marketplace-catalog.js"; -import { COPILOT_VSCODE_MCP_PATH, COPILOT_WORKSPACE_DIR } from "./copilot-paths.js"; +import { + COPILOT_MARKETPLACE_RELATIVE, + 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"; +const OUTPUT_MARKETPLACE_RELATIVE = COPILOT_MARKETPLACE_RELATIVE; /** Output prefix for agents in flat mode: .github/agents//.agent.md */ const FLAT_GITHUB_AGENTS_PREFIX = `${COPILOT_WORKSPACE_DIR}agents/`; @@ -131,7 +135,13 @@ function transformCopilotFlatAgent(content: string, plugin: string, outName: str resolveTargetPath: (rel) => copilotFlatResolveTarget(plugin, rel), }); const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); + return serializeFrontmatter( + { + ...stripped, + name: prefixedName, + }, + rewrittenBody + ); } function copilotFlatResolveTarget(plugin: string, rel: string): string { diff --git a/cli/src/contexts/tools/domain/profiles/copilot/copilot-paths.ts b/cli/src/contexts/tools/domain/profiles/copilot/copilot-paths.ts index f74a33429..e7b26aea5 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/copilot-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/copilot-paths.ts @@ -1,3 +1,5 @@ +import { join } from "node:path"; + /** 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. */ @@ -5,3 +7,8 @@ export const COPILOT_WORKSPACE_DIR = ".github/"; export const COPILOT_VSCODE_MCP_PATH = ".vscode/mcp.json"; + +export const COPILOT_MARKETPLACE_RELATIVE = ".plugin/marketplace.json"; +export function copilotConfigDir(home: string, env: NodeJS.ProcessEnv): string { + return env.COPILOT_HOME || join(home, ".copilot"); +} diff --git a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts index 46c17c0d5..ebb7da399 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts @@ -98,7 +98,11 @@ const agentsHandler = { 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 }; + return { + name: typeof name === "string" ? name : undefined, + description: fm.description, + "include-custom-instructions": fm["include-custom-instructions"] ?? true, + }; }, }; @@ -224,7 +228,9 @@ export const copilot: AiTool< directory: DIRECTORY, toolSuffix: EXT_AGENT, format: "markdown", + effortField: "reasoningEffort", userFileExt: EXT_AGENT, + userDirectory: (home, env) => env("COPILOT_HOME") || join(home, ".copilot"), buildInstallPath: (fileName) => agentsHandler.buildFilePath(fileName), convertFrontmatter: (fm, fileName) => agentsHandler.convertFrontmatter(fm, fileName), }), diff --git a/cli/src/contexts/tools/domain/profiles/cursor/build.ts b/cli/src/contexts/tools/domain/profiles/cursor/build.ts index 55c90fe60..286828678 100644 --- a/cli/src/contexts/tools/domain/profiles/cursor/build.ts +++ b/cli/src/contexts/tools/domain/profiles/cursor/build.ts @@ -27,13 +27,16 @@ import { OUTPUT_CURSOR_MARKETPLACE_RELATIVE, } from "./cursor-paths.js"; -function transformCursorAgent(content: string, _plugin: string, outName: string): string { +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); + return serializeFrontmatter( + { ...stripped, name: `${plugin}-${outName.replace(/\.md$/, "")}` }, + rewrittenBody + ); } export function buildCursorContract(): ToolBuildContract { @@ -115,7 +118,13 @@ function transformCursorFlatAgent(content: string, plugin: string, outName: stri resolveTargetPath: (rel) => cursorFlatResolveTarget(plugin, rel), }); const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); + return serializeFrontmatter( + { + ...stripped, + name: prefixedName, + }, + rewrittenBody + ); } export function buildCursorFlatContract(): ToolBuildContract { diff --git a/cli/src/contexts/tools/domain/profiles/cursor/profile.ts b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts index c7556a36d..f1e14178a 100644 --- a/cli/src/contexts/tools/domain/profiles/cursor/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts @@ -52,6 +52,8 @@ export const cursor: AiTool ({ description: fm.description, mode: "subagent" }), + userDirectory: (home, env) => + join(env("XDG_CONFIG_HOME") || join(home, ".config"), "opencode"), }), skills: new SkillsCapability({ directory: DIRECTORY, 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 index 60d84bd63..0a789d538 100644 --- a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts @@ -9,6 +9,8 @@ import type { HostPluginRegistryReader, HostPluginRegistryReading, } from "../domain/ports/host-plugin-registry-reader.js"; +import { claudePluginsDir } from "../domain/profiles/claude/claude-paths.js"; +import { copilotConfigDir } from "../domain/profiles/copilot/copilot-paths.js"; function failedRead(location: string, error: unknown): HostPluginRegistryReading { if (error instanceof Error && "code" in error && error.code === "ENOENT") @@ -30,15 +32,19 @@ function failedRead(location: string, error: unknown): HostPluginRegistryReading * rather than deleting it, so registered-but-off is an ordinary state, not a Codex-only one. */ export function hostPluginRegistryReaders( - home: string = resolveHomeDir() + home: string = resolveHomeDir(), + env: NodeJS.ProcessEnv = process.env ): ReadonlyMap { return new Map([ [ "claude", - new ClaudeInstalledPluginsReader(join(home, ".claude", "plugins", "installed_plugins.json")), + new ClaudeInstalledPluginsReader(join(claudePluginsDir(home, env), "installed_plugins.json")), ], ["codex", new CodexConfigPluginsReader(join(home, ".codex", "config.toml"))], - ["copilot", new CopilotSettingsPluginsReader(join(home, ".copilot", "settings.json"))], + [ + "copilot", + new CopilotSettingsPluginsReader(join(copilotConfigDir(home, env), "settings.json")), + ], ]); } @@ -69,7 +75,9 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { 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 }); + if (scope !== null) { + refs.set(ref, { enabled: true, scope }); + } } return { location: this.path, refs }; } catch (error) { @@ -104,31 +112,23 @@ class CodexConfigPluginsReader implements HostPluginRegistryReader { /** * 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. + * `@` ref as every other host and carrying a boolean. It answers the user registry; project activation is owned by the native host. */ 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 failedRead(this.path, 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 }])), + const parsed = JSON.parse(await readFile(this.path, "utf8")) as { + enabledPlugins?: Record; }; + const refs = new Map(); + for (const [ref, enabled] of Object.entries(parsed.enabledPlugins ?? {})) { + refs.set(ref, { enabled: enabled !== false }); + } + return { location: this.path, refs }; } catch (error) { - return { location: this.path, unreadable: describeError(error) }; + return failedRead(this.path, error); } } } @@ -201,6 +201,17 @@ interface ClaudeEntry { readonly projectPath?: string; } +async function projectEntry( + entries: readonly ClaudeEntry[], + projectRoot: string +): Promise { + for (const entry of entries) { + if (entry.projectPath !== undefined && (await resolvedPath(entry.projectPath)) === projectRoot) + return entry; + } + return undefined; +} + /** 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 @@ -212,11 +223,7 @@ async function scopeForProject( ): 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; + return (await projectEntry(entries, projectRoot)) === undefined ? null : "project"; } /** diff --git a/cli/src/contexts/translate/domain/content-translator.ts b/cli/src/contexts/translate/domain/content-translator.ts index bcd8b9c9f..bc8525d4c 100644 --- a/cli/src/contexts/translate/domain/content-translator.ts +++ b/cli/src/contexts/translate/domain/content-translator.ts @@ -349,6 +349,15 @@ export class PluginContentTranslator { // 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; + if (section === "agents" && hasAgents(tool)) { + const artifact = tool.buildContracts?.flat?.().artifacts.agents; + if (artifact?.supported) { + return this.makeFile( + artifact.path(pluginName, file.relativePath), + artifact.transform?.(content, pluginName, fileName) ?? content + ); + } + } return this.makeFile(`${tool.directory}${section}/${pluginName}/${fileName}`, content); } diff --git a/cli/src/kernel/markdown.ts b/cli/src/kernel/markdown.ts index 2be1507cc..7b55fd2aa 100644 --- a/cli/src/kernel/markdown.ts +++ b/cli/src/kernel/markdown.ts @@ -28,7 +28,12 @@ export function parseFrontmatter(content: string): { return { frontmatter, body }; } -export function serializeFrontmatter(frontmatter: Record, body: string): string { +/** `quote: "double"` writes strings as YAML double-quoted scalars, for readers that keep single quotes literally. */ +export function serializeFrontmatter( + frontmatter: Record, + body: string, + quote: "single" | "double" = "single" +): string { if (Object.keys(frontmatter).length === 0) { return body.replace(/^\n/, ""); } @@ -53,7 +58,11 @@ export function serializeFrontmatter(frontmatter: Record, body: if (s.startsWith("[") && s.endsWith("]")) { lines.push(`${key}: ${s}`); } else { - lines.push(`${key}: '${s.replaceAll("'", "''")}'`); + lines.push( + quote === "double" + ? `${key}: ${JSON.stringify(s)}` + : `${key}: '${s.replaceAll("'", "''")}'` + ); } } } diff --git a/cli/src/kernel/ports/file-reader.ts b/cli/src/kernel/ports/file-reader.ts index 5f673d041..3df1102ea 100644 --- a/cli/src/kernel/ports/file-reader.ts +++ b/cli/src/kernel/ports/file-reader.ts @@ -6,7 +6,7 @@ export interface FileReader { * 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; + fileExists(path: string, followSymlinks?: boolean): Promise; readFileHash(path: string): Promise; listFilesRecursive(dirPath: string): Promise; diff --git a/cli/src/presentation/commands/agents.ts b/cli/src/presentation/commands/agents.ts new file mode 100644 index 000000000..3e381fc71 --- /dev/null +++ b/cli/src/presentation/commands/agents.ts @@ -0,0 +1,68 @@ +import type { Command } from "commander"; +import { + agentIdeNames, + agentProviderNames, + isAgentProvider, + resolveAgentHost, +} from "../../contexts/tools/domain/formats/agent-models.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { ErrorHandler } from "../error-handler.js"; +import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; + +const SCOPE_HELP = "project (default) or user, the IDE's user configuration directory"; + +export function registerAgentsCommand(program: Command): void { + const agents = program + .command("agents") + .description("Opt in to native specialist agents for this project"); + agents + .command("install") + .description("Install agents with the selected provider's model and effort") + .requiredOption("--provider ", agentProviderNames().join(", ")) + .requiredOption("--ide ", agentIdeNames().join(", ")) + .option("--main", "also set the provider's main session model in the IDE's settings") + .option("--scope ", SCOPE_HELP) + .action(async (options: { provider: string; ide: string; main?: boolean; scope?: string }) => { + const { projectRoot, verbose, output } = parseGlobalOptions(program); + try { + const host = resolveAgentHost(options.ide); + if (!isAgentProvider(options.provider)) + throw new Error(`Unsupported agent provider: ${options.provider}`); + const user = parseScopeFlag(options.scope, output) === "user"; + const deps = await createDeps(projectRoot, { verbose }, output); + const count = await deps.manageAgentsUseCase.install({ + projectRoot, + host, + ...(user ? { user: deps.userAgentRoot } : {}), + provider: options.provider, + main: options.main === true, + }); + output.success( + `${count} agent files installed for ${options.ide}${options.main ? ", main model set" : ""}.` + ); + } catch (error) { + new ErrorHandler(output).handle(error); + } + }); + agents + .command("remove") + .description("Remove unmodified AIDD agents and restore the main model they replaced") + .requiredOption("--ide ", agentIdeNames().join(", ")) + .option("--scope ", SCOPE_HELP) + .action(async (options: { ide: string; scope?: string }) => { + const { projectRoot, verbose, output } = parseGlobalOptions(program); + try { + const host = resolveAgentHost(options.ide); + const user = parseScopeFlag(options.scope, output) === "user"; + const deps = await createDeps(projectRoot, { verbose }, output); + const count = await deps.manageAgentsUseCase.remove({ + projectRoot, + host, + ...(user ? { user: deps.userAgentRoot } : {}), + }); + output.success(`${count} agent files removed for ${options.ide}.`); + } catch (error) { + new ErrorHandler(output).handle(error); + } + }); +} diff --git a/cli/src/runtime/assets/agent-sources.ts b/cli/src/runtime/assets/agent-sources.ts new file mode 100644 index 000000000..2dacbd0b6 --- /dev/null +++ b/cli/src/runtime/assets/agent-sources.ts @@ -0,0 +1,11 @@ +import executor from "../../../assets/agents/executor.md"; +import explorer from "../../../assets/agents/explorer.md"; +import reviewer from "../../../assets/agents/reviewer.md"; +import validator from "../../../assets/agents/validator.md"; + +export const AGENT_SOURCES: Readonly> = { + "aidd-dev-explorer": explorer, + "aidd-dev-executor": executor, + "aidd-dev-validator": validator, + "aidd-dev-reviewer": reviewer, +}; diff --git a/cli/src/runtime/filesystem/atomic-write.ts b/cli/src/runtime/filesystem/atomic-write.ts index 3aac000e9..0da3476e5 100644 --- a/cli/src/runtime/filesystem/atomic-write.ts +++ b/cli/src/runtime/filesystem/atomic-write.ts @@ -1,12 +1,18 @@ import { randomBytes } from "node:crypto"; -import { rename, writeFile } from "node:fs/promises"; +import { chmod, rename, stat, 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. */ + * concurrent writers to the same `path` off each other's temporary file. A replaced file keeps + * its permissions, so a private settings file stays private. */ export async function atomicWriteFile(path: string, content: string): Promise { const tmpPath = `${path}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`; + const mode = await stat(path).then( + (existing) => existing.mode & 0o777, + () => undefined + ); await writeFile(tmpPath, content, "utf-8"); + if (mode !== undefined) await chmod(tmpPath, mode); await rename(tmpPath, path); } diff --git a/cli/src/runtime/filesystem/file-adapter.ts b/cli/src/runtime/filesystem/file-adapter.ts index 0e5ebbdd2..84c01d5df 100644 --- a/cli/src/runtime/filesystem/file-adapter.ts +++ b/cli/src/runtime/filesystem/file-adapter.ts @@ -1,5 +1,15 @@ import { constants } from "node:fs"; -import { access, mkdir, readdir, readFile, realpath, rm, rmdir, stat } from "node:fs/promises"; +import { + access, + lstat, + mkdir, + readdir, + readFile, + realpath, + rm, + rmdir, + stat, +} from "node:fs/promises"; import { dirname, join } from "node:path"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import { JsonParseError } from "../../kernel/errors.js"; @@ -103,9 +113,9 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { } } - async fileExists(path: string): Promise { + async fileExists(path: string, followSymlinks = true): Promise { try { - await stat(path); + await (followSymlinks ? stat(path) : lstat(path)); return true; } catch { return false; diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 59ce0367e..ce3a0fcfb 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -10,6 +10,8 @@ import { MarketplaceAddUseCase } from "../../contexts/distribution/application/m 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 type { UserAgentRoot } from "../../contexts/framework/application/agents/agent-location.js"; +import { ManageAgentsUseCase } from "../../contexts/framework/application/agents/manage-agents-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"; @@ -68,6 +70,7 @@ import { UninstallUseCase } from "../../contexts/framework/application/uninstall 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 { AgentInstallationStoreAdapter } from "../../contexts/framework/infrastructure/agent-installation-store-adapter.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"; @@ -95,6 +98,7 @@ import { PluginPickUseCase } from "../../presentation/prompts/plugin-pick-use-ca 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 { AGENT_SOURCES } from "../assets/agent-sources.js"; import { BundledAssetProviderAdapter } from "../assets/asset-loader.js"; import { AuthProviderAdapter } from "../auth/auth-provider-adapter.js"; import { AuthReaderAdapter } from "../auth/auth-reader-adapter.js"; @@ -126,6 +130,9 @@ interface GlobalOptions { } interface Deps { + manageAgentsUseCase: ManageAgentsUseCase; + /** Where `agents --scope user` installs: the IDE user directories and AIDD's own. */ + userAgentRoot: UserAgentRoot; fs: FileReader & FileWriter & FileMerger; manifestRepo: ManifestRepository; /** `--scope user`'s own manifest repository: `userConfigDir()/manifest.json`, never nested @@ -210,6 +217,12 @@ export async function createDeps( const hasher = new HasherAdapter(); const logger = output ?? new CLIOutput(options.verbose); const fs = new FileAdapter(hasher, logger); + const manageAgentsUseCase = new ManageAgentsUseCase( + fs, + new AgentInstallationStoreAdapter(fs), + hasher, + AGENT_SOURCES + ); const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); const manifestRepo = new ManifestRepositoryAdapter(projectRoot); const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir, atomicWriteFile); @@ -569,7 +582,8 @@ export async function createDeps( userSourceReferences, hostPluginRegistries, userManifestRepo, - nativeSources + nativeSources, + manageAgentsUseCase ); const cleanUserScopeUseCase = new CleanUserScopeUseCase( fs, @@ -589,6 +603,12 @@ export async function createDeps( const listInstalledRulesUseCase = new ListInstalledRulesUseCase(fs); const checkUpdateUseCase = new CheckUpdateUseCase(cliUpdater, currentVersionProvider, logger, fs); const deps: Deps = { + manageAgentsUseCase, + userAgentRoot: { + home: homedir(), + env: (name) => process.env[name], + configDir: userConfigDir(), + }, validateRecipesUseCase: new ValidateRecipesUseCase(new RecipeFilesAdapter()), fs, manifestRepo, diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts index 31661c70d..fdf4706a8 100644 --- a/cli/tests/architecture/context-boundary.arch.test.ts +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -11,6 +11,9 @@ const PUBLIC_MODULES: Readonly> = { tools: [ // the tool contract and lookup surface "src/contexts/tools/domain/contracts.ts", + "src/contexts/tools/domain/capabilities/agents-capability.ts", + "src/contexts/tools/domain/formats/agent-models.ts", + "src/contexts/tools/domain/formats/main-model-config.ts", "src/contexts/tools/domain/registry.ts", "src/contexts/tools/domain/build-contract.ts", // co-owned configuration (settings.json, .mcp.json et al.) @@ -82,6 +85,7 @@ const PUBLIC_MODULES: Readonly> = { // 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: [ + "src/contexts/framework/application/agents/manage-agents-use-case.ts", "src/contexts/framework/domain/recipes/recipe-validation.ts", // the rule inventory `framework rules` prints, one row per installed rule "src/contexts/framework/domain/installed-rule.ts", @@ -199,7 +203,6 @@ function reachesIntoInterior( * 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", diff --git a/cli/tests/architecture/folder-size.arch.test.ts b/cli/tests/architecture/folder-size.arch.test.ts index 4600362bd..a65fcc6bf 100644 --- a/cli/tests/architecture/folder-size.arch.test.ts +++ b/cli/tests/architecture/folder-size.arch.test.ts @@ -13,10 +13,9 @@ const MAX_FILES_PER_FOLDER = 10; * files are shuffled. The test asserts the count, so a reason nobody measured fails here. */ const BASELINE: readonly { readonly path: string; readonly count: number }[] = [ - // Eleven 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 eleven, still over the limit and clearer about nothing. - { path: "src/presentation/commands", count: 15 }, + // Command groups, including opt-in agents, and their command-only helpers. + // Keeping each group at its CLI entry point avoids combining unrelated command surfaces. + { path: "src/presentation/commands", count: 16 }, // 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. diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 783d3405e..186608dcc 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -29,7 +29,8 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] { path: "src/contexts/framework/application/restore/restore-use-case.ts", injected: 12 }, // Host plugin registries prove ref scope/enablement; the added native source reader independently // proves the current catalogue before these flows mutate it. Neither witness replaces the other. - { path: "src/contexts/framework/application/clean-use-case.ts", injected: 13 }, + // Independent opt-in agents have their own ownership record and participate in full cleanup. + { path: "src/contexts/framework/application/clean-use-case.ts", injected: 14 }, { path: "src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts", injected: 15, diff --git a/cli/tests/contexts/framework/application/agents/manage-agents-use-case.integration.test.ts b/cli/tests/contexts/framework/application/agents/manage-agents-use-case.integration.test.ts new file mode 100644 index 000000000..6be4842c1 --- /dev/null +++ b/cli/tests/contexts/framework/application/agents/manage-agents-use-case.integration.test.ts @@ -0,0 +1,427 @@ +import { mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ManageAgentsUseCase } from "../../../../../src/contexts/framework/application/agents/manage-agents-use-case.js"; +import { AgentInstallationStoreAdapter } from "../../../../../src/contexts/framework/infrastructure/agent-installation-store-adapter.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { FileAdapter } from "../../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../../src/runtime/filesystem/hasher-adapter.js"; + +const sources = { + "aidd-dev-explorer": "---\nname: explorer\ndescription: Explore\n---\nFind relevant source.", + "aidd-dev-reviewer": "---\nname: reviewer\ndescription: Review\n---\nReview quality.", +}; + +describe("ManageAgentsUseCase", () => { + let root: string; + let manager: ManageAgentsUseCase; + let fs: FileAdapter; + let store: AgentInstallationStoreAdapter; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-agents-")); + const hasher = new HasherAdapter(); + fs = new FileAdapter(hasher); + store = new AgentInstallationStoreAdapter(fs); + manager = new ManageAgentsUseCase(fs, store, hasher, sources); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); + }); + + it("sets the main model on install and reverts only unchanged values on removal", async () => { + const config = join(root, ".codex/config.toml"); + await mkdir(join(root, ".codex"), { recursive: true }); + await writeFile(config, '# mine\napproval_policy = "never"\n'); + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + expect(await readFile(config, "utf8")).toBe('# mine\napproval_policy = "never"\n'); + await manager.install({ projectRoot: root, host: "codex", provider: "openai", main: true }); + expect(await readFile(config, "utf8")).toBe( + 'model = "gpt-6.1-sol"\nmodel_reasoning_effort = "medium"\n# mine\napproval_policy = "never"\n' + ); + await writeFile(config, (await readFile(config, "utf8")).replace('"medium"', '"high"')); + await manager.remove({ projectRoot: root, host: "codex" }); + expect(await readFile(config, "utf8")).toBe( + 'model_reasoning_effort = "high"\n# mine\napproval_policy = "never"\n' + ); + await expect(readFile(join(root, ".aidd/main-model.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("deletes a settings file it created once removeAll reverts it", async () => { + await manager.install({ projectRoot: root, host: "opencode", provider: "mistral", main: true }); + expect(JSON.parse(await readFile(join(root, "opencode.json"), "utf8"))).toStrictEqual({ + model: "mistral/mistral-large-4", + }); + await manager.removeAll(root); + await expect(readFile(join(root, "opencode.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("installs user-level agents and restores the main model on removal", async () => { + const home = join(root, "home"); + const userRoot = { + home, + env: (name: string) => (name === "CODEX_HOME" ? join(home, "codex-home") : undefined), + configDir: join(home, ".config/aidd"), + }; + const settings = join(home, "codex-home/config.toml"); + await mkdir(dirname(settings), { recursive: true }); + await writeFile(settings, 'model = "mine"\n'); + const user = manager; + const target = { projectRoot: root, host: "codex", user: userRoot } as const; + expect(await user.install({ ...target, provider: "openai", main: true })).toBe(2); + expect( + await readFile(join(home, "codex-home/agents/aidd-dev-explorer.toml"), "utf8") + ).toContain("Explore"); + expect(await readFile(settings, "utf8")).toBe( + 'model_reasoning_effort = "medium"\nmodel = "gpt-6.1-sol"\n' + ); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await user.remove(target)).toBe(2); + expect(await readFile(settings, "utf8")).toBe('model = "mine"\n'); + }); + + it("refuses user-level removal from a configuration directory other than the install's", async () => { + const home = join(root, "home"); + const at = (codexHome: string) => ({ + projectRoot: root, + host: "codex" as const, + user: { + home, + env: (name: string) => (name === "CODEX_HOME" ? join(home, codexHome) : undefined), + configDir: join(home, ".config/aidd"), + }, + }); + await manager.install({ ...at("codex-a"), provider: "openai" }); + await expect(manager.remove(at("codex-b"))).rejects.toThrow(/No agents directory/); + expect(await manager.remove(at("codex-a"))).toBe(2); + }); + + it("refuses --main where the IDE has no main model setting", async () => { + await expect( + manager.install({ projectRoot: root, host: "cursor", provider: "anthropic", main: true }) + ).rejects.toThrow(/no main model setting/); + }); + + it("removes an unmodified agent whose role is no longer bundled", async () => { + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + const { "aidd-dev-reviewer": _, ...remaining } = sources; + const next = new ManageAgentsUseCase(fs, store, new HasherAdapter(), remaining); + await next.install({ projectRoot: root, host: "codex", provider: "openai" }); + await expect( + readFile(join(root, ".codex/agents/aidd-dev-reviewer.toml")) + ).rejects.toMatchObject({ code: "ENOENT" }); + expect( + Object.keys(JSON.parse(await readFile(join(root, ".aidd/agents.json"), "utf8")).codex) + ).toEqual(["aidd-dev-explorer"]); + }); + + it("restores files and both registries when the main model registry cannot be saved", async () => { + await manager.install({ projectRoot: root, host: "opencode", provider: "openai" }); + const ledger = await readFile(join(root, ".aidd/agents.json"), "utf8"); + vi.spyOn(store, "saveMainModels").mockRejectedValueOnce(new Error("registry refused")); + await expect( + manager.install({ projectRoot: root, host: "opencode", provider: "anthropic", main: true }) + ).rejects.toThrow("registry refused"); + expect(await readFile(join(root, ".aidd/agents.json"), "utf8")).toBe(ledger); + await expect(readFile(join(root, "opencode.json"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(root, ".opencode/agents/aidd-dev-explorer.md"), "utf8")).toContain( + "openai/" + ); + }); + + it("refuses to bypass an existing opencode.jsonc", async () => { + await writeFile(join(root, "opencode.jsonc"), "// mine\n{}\n"); + await expect( + manager.install({ projectRoot: root, host: "opencode", provider: "mistral", main: true }) + ).rejects.toThrow(/opencode\.jsonc/); + await expect(readFile(join(root, "opencode.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rolls back a new installation when ownership cannot be saved, then allows retry", async () => { + vi.spyOn(store, "save").mockRejectedValueOnce(new Error("registry refused")); + await expect( + manager.install({ projectRoot: root, host: "codex", provider: "openai" }) + ).rejects.toThrow("registry refused"); + for (const name of Object.keys(sources)) + await expect(readFile(join(root, `.codex/agents/${name}.toml`))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await manager.install({ projectRoot: root, host: "codex", provider: "openai" })).toBe(2); + }); + + it("restores the previous provider and ledger after a failed update", async () => { + await manager.install({ projectRoot: root, host: "opencode", provider: "openai" }); + const ledger = await readFile(join(root, ".aidd/agents.json"), "utf8"); + const contents = await Promise.all( + Object.keys(sources).map((name) => + readFile(join(root, `.opencode/agents/${name}.md`), "utf8") + ) + ); + vi.spyOn(store, "save").mockRejectedValueOnce(new Error("registry refused")); + await expect( + manager.install({ projectRoot: root, host: "opencode", provider: "anthropic" }) + ).rejects.toThrow("registry refused"); + expect(await readFile(join(root, ".aidd/agents.json"), "utf8")).toBe(ledger); + expect( + await Promise.all( + Object.keys(sources).map((name) => + readFile(join(root, `.opencode/agents/${name}.md`), "utf8") + ) + ) + ).toEqual(contents); + expect( + await manager.install({ projectRoot: root, host: "opencode", provider: "anthropic" }) + ).toBe(2); + }); + + it("rolls back earlier files when a later native write fails", async () => { + const write = fs.writeFile.bind(fs); + vi.spyOn(fs, "writeFile") + .mockImplementationOnce(write) + .mockRejectedValueOnce(new Error("write refused")); + await expect( + manager.install({ projectRoot: root, host: "codex", provider: "openai" }) + ).rejects.toThrow("write refused"); + await expect( + readFile(join(root, ".codex/agents/aidd-dev-explorer.toml")) + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await manager.install({ projectRoot: root, host: "codex", provider: "openai" })).toBe(2); + }); + + it("keeps the original error when a refused native write left the existing file intact", async () => { + await manager.install({ projectRoot: root, host: "opencode", provider: "openai" }); + const failure = new Error("native directory is read-only"); + vi.spyOn(fs, "writeFile").mockRejectedValue(failure); + await expect( + manager.install({ projectRoot: root, host: "opencode", provider: "anthropic" }) + ).rejects.toBe(failure); + expect(await readFile(join(root, ".opencode/agents/aidd-dev-explorer.md"), "utf8")).toContain( + "openai/gpt-6-luna" + ); + }); + + it("reports an incomplete rollback instead of hiding a restoration failure", async () => { + vi.spyOn(store, "save").mockRejectedValueOnce(new Error("registry refused")); + vi.spyOn(fs, "deleteFile").mockResolvedValue(); + const failed = manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + await expect(failed).rejects.toBeInstanceOf(AggregateError); + await expect(failed).rejects.toThrow("rollback was incomplete"); + }); + + it.each(["remove", "removeAll"] as const)( + "restores agents when %s cannot save ownership", + async (method) => { + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + const ledger = await readFile(join(root, ".aidd/agents.json"), "utf8"); + vi.spyOn(store, "save").mockRejectedValueOnce(new Error("registry refused")); + const remove = () => + method === "remove" + ? manager.remove({ projectRoot: root, host: "codex" }) + : manager.removeAll(root); + await expect(remove()).rejects.toThrow("registry refused"); + expect(await readFile(join(root, ".aidd/agents.json"), "utf8")).toBe(ledger); + expect(await manager.install({ projectRoot: root, host: "codex", provider: "openai" })).toBe( + 0 + ); + expect(await remove()).toBe(2); + } + ); + + it("records both IDEs during simultaneous installations", async () => { + await Promise.all([ + manager.install({ projectRoot: root, host: "codex", provider: "openai" }), + manager.install({ projectRoot: root, host: "opencode", provider: "openai" }), + ]); + expect((await manager.preview(root)).map((tool) => tool.toolId).sort()).toEqual([ + "codex", + "opencode", + ]); + expect(await manager.remove({ projectRoot: root, host: "codex" })).toBe(2); + expect(await manager.preview(root)).toEqual([{ toolId: "opencode", fileCount: 2 }]); + expect(await manager.remove({ projectRoot: root, host: "opencode" })).toBe(2); + }); + + it("keeps a concurrent installation registered when another IDE is removed", async () => { + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + await Promise.all([ + manager.remove({ projectRoot: root, host: "codex" }), + manager.install({ projectRoot: root, host: "opencode", provider: "anthropic" }), + ]); + expect(await manager.preview(root)).toEqual([{ toolId: "opencode", fileCount: 2 }]); + expect(await manager.removeAll(root)).toBe(2); + }); + + it("serializes remove-all with installation even when the registry directory becomes empty", async () => { + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + await Promise.all([ + manager.removeAll(root), + manager.install({ projectRoot: root, host: "opencode", provider: "mistral" }), + ]); + const remaining = await manager.preview(root); + const opencodeExists = await fs.fileExists(join(root, ".opencode/agents/aidd-dev-explorer.md")); + expect(opencodeExists).toBe(remaining.some(({ toolId }) => toolId === "opencode")); + expect(remaining.every(({ toolId }) => toolId === "opencode")).toBe(true); + await expect(readFile(join(root, ".aidd/agents.json.lock"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("installs namespaced native Codex agents and records ownership", async () => { + expect(await manager.install({ projectRoot: root, host: "codex", provider: "openai" })).toBe(2); + const content = await readFile(join(root, ".codex/agents/aidd-dev-explorer.toml"), "utf8"); + expect(content).toContain('model = "gpt-6-luna"'); + expect(content).toContain('model_reasoning_effort = "medium"'); + expect(content).toContain('name = "aidd-dev-explorer"'); + const review = await readFile(join(root, ".codex/agents/aidd-dev-reviewer.toml"), "utf8"); + expect(review).toContain('model = "gpt-6.1-sol"'); + expect(review).toContain('model_reasoning_effort = "medium"'); + expect( + JSON.parse(await readFile(join(root, ".aidd/agents.json"), "utf8")).codex + ).toHaveProperty("aidd-dev-explorer"); + expect(await manager.install({ projectRoot: root, host: "codex", provider: "openai" })).toBe(0); + }); + + it("changes provider only for unmodified owned files", async () => { + await manager.install({ projectRoot: root, host: "opencode", provider: "openai" }); + expect( + await manager.install({ projectRoot: root, host: "opencode", provider: "anthropic" }) + ).toBe(2); + expect(await readFile(join(root, ".opencode/agents/aidd-dev-explorer.md"), "utf8")).toContain( + "anthropic/claude-haiku-5-5#medium" + ); + expect(await readFile(join(root, ".opencode/agents/aidd-dev-reviewer.md"), "utf8")).toContain( + "anthropic/claude-sonnet-5-5#medium" + ); + await writeFile(join(root, ".opencode/agents/aidd-dev-reviewer.md"), "personal edit"); + await expect( + manager.install({ projectRoot: root, host: "opencode", provider: "openai" }) + ).rejects.toThrow("modified"); + expect(await readFile(join(root, ".opencode/agents/aidd-dev-explorer.md"), "utf8")).toContain( + "anthropic/claude-haiku-5-5#medium" + ); + }); + + it("preflights every destination before writing and preserves personal files", async () => { + await mkdir(join(root, ".codex/agents"), { recursive: true }); + await writeFile(join(root, ".codex/agents/aidd-dev-reviewer.toml"), "personal"); + await expect( + manager.install({ projectRoot: root, host: "codex", provider: "openai" }) + ).rejects.toThrow("not owned"); + await expect( + readFile(join(root, ".codex/agents/aidd-dev-explorer.toml")) + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it.skipIf(process.platform === "win32")( + "preserves a dangling personal agent symlink before writing any agent", + async () => { + const path = join(root, ".codex/agents/aidd-dev-reviewer.toml"); + const target = join(root, "personal.toml"); + await mkdir(join(root, ".codex/agents"), { recursive: true }); + await symlink(target, path); + expect(await fs.fileExists(path)).toBe(false); + expect(await fs.fileExists(path, false)).toBe(true); + await expect( + manager.install({ projectRoot: root, host: "codex", provider: "openai" }) + ).rejects.toThrow("not owned"); + expect(await readlink(path)).toBe(target); + await expect( + readFile(join(root, ".codex/agents/aidd-dev-explorer.toml")) + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + } + ); + + it.skipIf(process.platform === "win32").each(["install", "remove", "removeAll"] as const)( + "refuses %s when an owned agent becomes a dangling symlink", + async (operation) => { + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + const path = join(root, ".codex/agents/aidd-dev-reviewer.toml"); + const target = join(root, "personal.toml"); + const ledger = await readFile(join(root, ".aidd/agents.json"), "utf8"); + const explorer = await readFile(join(root, ".codex/agents/aidd-dev-explorer.toml"), "utf8"); + await rm(path); + await symlink(target, path); + const change = + operation === "install" + ? manager.install({ projectRoot: root, host: "codex", provider: "openai" }) + : operation === "remove" + ? manager.remove({ projectRoot: root, host: "codex" }) + : manager.removeAll(root); + await expect(change).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readlink(path)).toBe(target); + expect(await readFile(join(root, ".aidd/agents.json"), "utf8")).toBe(ledger); + expect(await readFile(join(root, ".codex/agents/aidd-dev-explorer.toml"), "utf8")).toBe( + explorer + ); + } + ); + + it("removes owned agents, tolerates missing files, and keeps personal agents", async () => { + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + await rm(join(root, ".codex/agents/aidd-dev-reviewer.toml")); + await writeFile(join(root, ".codex/agents/personal.toml"), "personal"); + expect(await manager.remove({ projectRoot: root, host: "codex" })).toBe(1); + expect(await readFile(join(root, ".codex/agents/personal.toml"), "utf8")).toBe("personal"); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await manager.remove({ projectRoot: root, host: "codex" })).toBe(0); + }); + + it("refuses edited owned files before removing any agent", async () => { + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + await writeFile(join(root, ".codex/agents/aidd-dev-reviewer.toml"), "edited"); + await expect(manager.remove({ projectRoot: root, host: "codex" })).rejects.toThrow("modified"); + expect(await readFile(join(root, ".codex/agents/aidd-dev-explorer.toml"), "utf8")).toContain( + "Explore" + ); + }); + + it("rejects ledger entries outside the bundled agent names", async () => { + await mkdir(join(root, ".aidd")); + await writeFile( + join(root, ".aidd/agents.json"), + JSON.stringify({ codex: { "../../personal": "a".repeat(32) } }) + ); + await expect(manager.remove({ projectRoot: root, host: "codex" })).rejects.toThrow( + "Unknown managed agent" + ); + }); + + it.skipIf(process.platform === "win32")( + "refuses agent and ledger directories pointing outside the project", + async () => { + const outside = await mkdtemp(join(tmpdir(), "aidd-agents-outside-")); + try { + await symlink(outside, join(root, ".codex")); + await expect( + manager.install({ projectRoot: root, host: "codex", provider: "openai" }) + ).rejects.toThrow("outside project"); + await rm(join(root, ".codex")); + await rm(join(root, ".aidd"), { recursive: true, force: true }); + await symlink(outside, join(root, ".aidd")); + await expect( + manager.install({ projectRoot: root, host: "codex", provider: "openai" }) + ).rejects.toThrow("outside project"); + } finally { + await rm(outside, { recursive: true, force: true }); + } + } + ); +}); diff --git a/cli/tests/contexts/framework/application/clean/clean-opt-in-agents.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-opt-in-agents.integration.test.ts new file mode 100644 index 000000000..d1760f6ef --- /dev/null +++ b/cli/tests/contexts/framework/application/clean/clean-opt-in-agents.integration.test.ts @@ -0,0 +1,164 @@ +import { access, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ManageAgentsUseCase } from "../../../../../src/contexts/framework/application/agents/manage-agents-use-case.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 { AgentInstallationStoreAdapter } from "../../../../../src/contexts/framework/infrastructure/agent-installation-store-adapter.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { FileAdapter } from "../../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../../src/runtime/filesystem/hasher-adapter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +describe("clean includes opt-in agents", () => { + let root: string; + let manager: ManageAgentsUseCase; + let fs: FileAdapter; + let hasher: HasherAdapter; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-clean-agents-")); + hasher = new HasherAdapter(); + fs = new FileAdapter(hasher); + manager = new ManageAgentsUseCase(fs, new AgentInstallationStoreAdapter(fs), hasher, { + "aidd-dev-explorer": "---\nname: explorer\ndescription: Explore\n---\nExplore source.", + }); + await manager.install({ projectRoot: root, host: "codex", provider: "openai" }); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); + }); + + function clean(manifest: Manifest | null = null): CleanUseCase { + return new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, root), + new CapturingLogger(), + new GitignoreUseCase(fs), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + manager + ); + } + + it("previews and removes agents in a project without a framework manifest", async () => { + const useCase = clean(); + const preview = await useCase.execute({ projectRoot: root, force: false }); + expect(preview.dryRun).toBe(true); + expect(preview.preview.tools).toEqual([{ toolId: "codex", fileCount: 1 }]); + expect(await readFile(join(root, ".codex/agents/aidd-dev-explorer.toml"), "utf8")).toContain( + "Explore" + ); + expect((await useCase.execute({ projectRoot: root, force: true })).fileCount).toBe(1); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("keeps personal configuration beside the agent ledger", async () => { + await writeFile(join(root, ".aidd/config.json"), "{}"); + await clean().execute({ projectRoot: root, force: true }); + expect(await readFile(join(root, ".aidd/config.json"), "utf8")).toBe("{}"); + }); + + it("keeps personal directory links beside the agent ledger", async () => { + const personal = join(root, "personal"); + await mkdir(personal); + await writeFile(join(personal, "note.txt"), "personal"); + const link = join(root, ".aidd", "personal-link"); + await symlink(personal, link, "junction"); + await clean().execute({ projectRoot: root, force: true }); + expect((await lstat(link)).isSymbolicLink()).toBe(true); + expect(await readFile(join(link, "note.txt"), "utf8")).toBe("personal"); + }); + + it("holds the agent mutex throughout framework state cleanup before a waiting install proceeds", async () => { + const scanning = Promise.withResolvers(); + const finishScan = Promise.withResolvers(); + const list = fs.listDirectory.bind(fs); + vi.spyOn(fs, "listDirectory").mockImplementation(async (path) => { + if (path === join(root, ".aidd")) { + await access(join(root, ".aidd/agents.json.lock")); + scanning.resolve(); + await finishScan.promise; + } + return list(path); + }); + const cleaning = clean(Manifest.create()).execute({ projectRoot: root, force: true }); + // Reject immediately if cleanup failed before reaching the gate. + await Promise.race([ + scanning.promise, + cleaning.then(() => { + throw new Error("cleanup skipped the gate"); + }), + ]); + const installing = manager.install({ + projectRoot: root, + host: "opencode", + provider: "mistral", + }); + finishScan.resolve(); + await Promise.all([cleaning, installing]); + expect(await manager.preview(root)).toEqual([{ toolId: "opencode", fileCount: 1 }]); + expect(await readFile(join(root, ".opencode/agents/aidd-dev-explorer.md"), "utf8")).toContain( + "mistral/mistral-small-latest#high" + ); + await expect(access(join(root, ".codex/agents/aidd-dev-explorer.toml"))).rejects.toThrow(); + await expect(access(join(root, ".aidd/agents.json.lock"))).rejects.toThrow(); + }); + + it("counts agents once alongside framework files and other opted-in hosts", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", [ + new InstallationFile({ + relativePath: ".codex/rule.md", + content: "rule", + hash: hasher.hash("rule"), + }), + ]); + await fs.writeFile(join(root, ".codex/rule.md"), "rule"); + await manager.install({ projectRoot: root, host: "opencode", provider: "openai" }); + const useCase = clean(manifest); + const preview = await useCase.execute({ projectRoot: root, force: false }); + expect(preview.preview.tools).toEqual([ + { toolId: "codex", fileCount: 2 }, + { toolId: "opencode", fileCount: 1 }, + ]); + expect(preview.preview.totalFileCount).toBe(3); + expect((await useCase.execute({ projectRoot: root, force: true })).fileCount).toBe(3); + await expect(readFile(join(root, ".codex/rule.md"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("rejects modified agents before deleting framework files", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", [ + new InstallationFile({ + relativePath: ".codex/rule.md", + content: "rule", + hash: hasher.hash("rule"), + }), + ]); + await fs.writeFile(join(root, ".codex/rule.md"), "rule"); + await writeFile(join(root, ".codex/agents/aidd-dev-explorer.toml"), "edited"); + await expect(clean(manifest).execute({ projectRoot: root, force: true })).rejects.toThrow( + "modified" + ); + expect(await readFile(join(root, ".codex/rule.md"), "utf8")).toBe("rule"); + expect(await readFile(join(root, ".aidd/agents.json"), "utf8")).toContain("codex"); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/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 index f89233b76..98e7bfefa 100644 --- a/cli/tests/contexts/framework/application/framework/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,5 +1,5 @@ // OpenCode uses Mode B with `mode: "flat"` and project scope, so `translateFlat` writes at -// `.opencode/
//`, never under one `.opencode/plugins//` root. +// section-specific native paths, 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/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; @@ -39,7 +39,7 @@ function buildDist(name = "aidd-context"): PluginDistribution { } describe("install opencode plugin via Mode B (integration)", () => { - it("materializes flat plugin files under .opencode/
// at projectRoot", async () => { + it("materializes project commands and namespaced native agents", async () => { const fs = new InMemoryFileAdapter(); const hasher = new DeterministicHasher(); const adapter = new ModeBFlatMaterializationTranslator(fs, hasher, () => STUB_HOME); @@ -59,9 +59,7 @@ describe("install opencode plugin via Mode B (integration)", () => { expect( written.some((p) => p.startsWith(`${PROJECT_ROOT}/.opencode/commands/aidd-context/`)) ).toBe(true); - expect( - written.some((p) => p.startsWith(`${PROJECT_ROOT}/.opencode/agents/aidd-context/`)) - ).toBe(true); + expect(written).toContain(`${PROJECT_ROOT}/.opencode/agents/aidd-context-coach.md`); }); it("does not write any file under user home for project-scope flat tools", async () => { diff --git a/cli/tests/contexts/framework/infrastructure/agent-installation-store-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/agent-installation-store-adapter.integration.test.ts new file mode 100644 index 000000000..329a98349 --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/agent-installation-store-adapter.integration.test.ts @@ -0,0 +1,102 @@ +import { mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentInstallationStoreAdapter } from "../../../../src/contexts/framework/infrastructure/agent-installation-store-adapter.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; + +describe("AgentInstallationStoreAdapter", () => { + let root: string; + let store: AgentInstallationStoreAdapter; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-agent-store-")); + store = new AgentInstallationStoreAdapter(new FileAdapter(new HasherAdapter())); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); + }); + + it("releases exclusive access after a failed operation", async () => { + await expect( + store.withExclusiveAccess(root, async () => { + throw new Error("operation refused"); + }) + ).rejects.toThrow("operation refused"); + expect(await store.withExclusiveAccess(root, async () => 42)).toBe(42); + await expect(readFile(join(root, ".aidd/agents.json.lock"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("waits for a competing writer before reading ownership", async () => { + const other = new AgentInstallationStoreAdapter(new FileAdapter(new HasherAdapter())); + const first = { codex: { "aidd-dev-explorer": new FileHash("a".repeat(32)) } }; + const second = { "aidd-dev-reviewer": new FileHash("b".repeat(32)) }; + await Promise.all([ + store.withExclusiveAccess(root, async () => + store.save(root, { ...(await store.load(root)), ...first }) + ), + other.withExclusiveAccess(root, async () => + other.save(root, { ...(await other.load(root)), opencode: second }) + ), + ]); + expect(await store.load(root)).toEqual({ ...first, opencode: second }); + }); + + it("leaves a held lock intact and reports how to recover after timeout", async () => { + const lock = join(root, ".aidd/agents.json.lock"); + await mkdir(lock, { recursive: true }); + vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(30_001); + const action = vi.fn(); + await expect(store.withExclusiveAccess(root, action)).rejects.toThrow( + "remove this lock only if that operation has stopped" + ); + expect(action).not.toHaveBeenCalled(); + await expect(mkdir(lock)).rejects.toMatchObject({ code: "EEXIST" }); + }); + + it("round trips typed hashes for several hosts and removes an empty ledger", async () => { + expect(await store.load(root)).toEqual({}); + const expected = { + codex: { "aidd-dev-explorer": new FileHash("a".repeat(32)) }, + opencode: { "aidd-dev-reviewer": new FileHash("b".repeat(32)) }, + }; + await store.save(root, expected); + expect(await store.load(root)).toEqual(expected); + await store.save(root, {}); + await expect(readFile(join(root, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it.skipIf(process.platform === "win32")( + "refuses a dangling registry symlink instead of treating it as an empty registry", + async () => { + const path = join(root, ".aidd/agents.json"); + const target = join(root, "personal-registry.json"); + await mkdir(join(root, ".aidd")); + await symlink(target, path); + await expect(store.load(root)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readlink(path)).toBe(target); + } + ); + + it.each([ + "null", + "[]", + '"text"', + "{", + '{"other":{}}', + '{"codex":null}', + '{"codex":[]}', + '{"codex":{"aidd-dev-explorer":42}}', + '{"codex":{"aidd-dev-explorer":"bad"}}', + ])("rejects malformed ownership data %s", async (data) => { + await mkdir(join(root, ".aidd")); + await writeFile(join(root, ".aidd/agents.json"), data); + await expect(store.load(root)).rejects.toThrow(); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts index 0503162b0..4e55a7277 100644 --- a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts @@ -8,11 +8,13 @@ import { describe("stripAgentFrontmatter", () => { describe("keeps allowlisted keys", () => { - it("preserves all six allowed keys when all are present", () => { + it("preserves all allowed keys when all are present", () => { const input = { name: "reviewer", description: "Reviews code", model: "claude-sonnet", + reasoningEffort: "medium", + "include-custom-instructions": true, tools: ["Read", "Write"], agents: ["helper"], "argument-hint": "[file]", @@ -28,6 +30,12 @@ describe("stripAgentFrontmatter", () => { }); }); + it("preserves an explicit repository-instruction opt-out", () => { + expect(stripAgentFrontmatter({ "include-custom-instructions": false })).toEqual({ + "include-custom-instructions": false, + }); + }); + describe("drops non-allowlisted keys", () => { it("drops paths key", () => { const input = { name: "reviewer", paths: ["src/**/*.ts"] }; diff --git a/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts new file mode 100644 index 000000000..3b1997d0c --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { + agentIdeNames, + agentModelOptions, + isAgentHost, + isAgentProvider, + resolveAgentHost, +} from "../../../../../src/contexts/tools/domain/formats/agent-models.js"; +import { claude } from "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { codex } from "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { parseToml } from "../../../../../src/contexts/tools/domain/profiles/codex/toml.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 { parseFrontmatter, serializeFrontmatter } from "../../../../../src/kernel/markdown.js"; + +describe("explicit agent models", () => { + it.each([ + ["claude-code", "claude"], + ["claude", "claude"], + ["codex", "codex"], + ["copilot", "copilot"], + ["opencode", "opencode"], + ["cursor", "cursor"], + ])("resolves IDE %s to host %s", (ide, expected) => { + expect(resolveAgentHost(ide)).toBe(expected); + }); + + it.each(["kilo", "unknown", "constructor", ""])("rejects unsupported IDE %s", (ide) => { + expect(() => resolveAgentHost(ide)).toThrow(/not supported/); + }); + + it("lists the configured public IDE names", () => { + expect(agentIdeNames()).toStrictEqual([ + "claude-code", + "codex", + "copilot", + "opencode", + "cursor", + ]); + }); + + it.each([ + ["claude", "anthropic", { model: "claude-haiku-5-5", effort: "medium" }], + ["codex", "openai", { model: "gpt-6-luna", model_reasoning_effort: "medium" }], + ["copilot", "openai", { model: "gpt-6-luna", reasoningEffort: "medium" }], + ["copilot", "anthropic", { model: "claude-haiku-5.5", reasoningEffort: "medium" }], + ["opencode", "openai", { model: "openai/gpt-6-luna#medium" }], + ["opencode", "anthropic", { model: "anthropic/claude-haiku-5-5#medium" }], + ["opencode", "mistral", { model: "mistral/mistral-small-latest#high" }], + ["cursor", "anthropic", { model: "claude-haiku-5-5[effort=medium]" }], + ] as const)("serializes %s with the selected %s provider", (host, provider, expected) => { + for (const role of ["explorer", "executor", "validator"]) + expect(agentModelOptions(host, provider, role)).toStrictEqual(expected); + }); + + it.each([ + ["codex", "openai", { model: "gpt-6.1-sol", model_reasoning_effort: "medium" }], + ["copilot", "openai", { model: "gpt-6.1-sol", reasoningEffort: "medium" }], + ["opencode", "openai", { model: "openai/gpt-6.1-sol#medium" }], + ] as const)( + "shares model and effort between main and review on %s / %s", + (host, provider, expected) => { + for (const role of ["main", "reviewer"]) + expect(agentModelOptions(host, provider, role)).toStrictEqual(expected); + } + ); + + it.each([ + [ + "claude", + { model: "claude-opus-5-5", effort: "medium" }, + { model: "claude-sonnet-5-5", effort: "medium" }, + ], + [ + "copilot", + { model: "claude-opus-5.5", reasoningEffort: "medium" }, + { model: "claude-sonnet-5.5", reasoningEffort: "medium" }, + ], + [ + "opencode", + { model: "anthropic/claude-opus-5-5#medium" }, + { model: "anthropic/claude-sonnet-5-5#medium" }, + ], + [ + "cursor", + { model: "claude-opus-5-5[effort=medium]" }, + { model: "claude-sonnet-5-5[effort=medium]" }, + ], + ] as const)("runs Anthropic's main on Opus and review on Sonnet on %s", (host, main, review) => { + expect(agentModelOptions(host, "anthropic", "main")).toStrictEqual(main); + expect(agentModelOptions(host, "anthropic", "reviewer")).toStrictEqual(review); + }); + + it("gives Mistral a larger main model than its reviewer on opencode", () => { + expect(agentModelOptions("opencode", "mistral", "main")).toStrictEqual({ + model: "mistral/mistral-large-4#high", + }); + expect(agentModelOptions("opencode", "mistral", "reviewer")).toStrictEqual({ + model: "mistral/mistral-medium-latest#high", + }); + }); + + it.each(["unknown", "constructor", "__proto__", ""])("rejects unconfigured role %s", (role) => { + expect(() => agentModelOptions("codex", "openai", role)).toThrow(/role.*not configured/); + }); + + it.each([ + ["claude", "openai"], + ["codex", "anthropic"], + ["cursor", "openai"], + ["claude", "mistral"], + ["codex", "mistral"], + ["copilot", "mistral"], + ["cursor", "mistral"], + ] as const)("rejects unsupported %s / %s selections", (host, provider) => { + expect(() => agentModelOptions(host, provider, "reviewer")).toThrow(/not supported/); + }); + + it.each(["claude", "codex", "copilot", "opencode", "cursor"])( + "recognizes the native %s host", + (host) => expect(isAgentHost(host)).toBe(true) + ); + it.each(["claude-code", "kilo", "", "toString", "constructor", "__proto__", null, undefined, 1])( + "rejects unknown host %s", + (host) => { + expect(isAgentHost(host)).toBe(false); + } + ); + it.each(["openai", "anthropic", "mistral"])("recognizes provider %s", (provider) => { + expect(isAgentProvider(provider)).toBe(true); + }); + it.each(["unknown", "", "toString", "constructor", "__proto__", null, undefined, 1])( + "rejects unknown provider %s", + (provider) => { + expect(isAgentProvider(provider)).toBe(false); + } + ); +}); + +describe("generic agent conversion", () => { + for (const host of [claude, codex, copilot, cursor, opencode]) { + it(`${host.toolId} leaves unconfigured agents unpinned`, () => { + const converted = host.capabilities.agents.convertFrontmatter( + { name: "explorer", description: "Role" }, + "explorer.md" + ); + for (const field of ["model", "effort", "model_reasoning_effort", "reasoningEffort"]) { + expect(converted).not.toHaveProperty(field); + } + for (const build of Object.values(host.buildContracts ?? {})) { + const artifact = build().artifacts.agents; + if (!artifact.supported || artifact.transform === undefined) + throw new Error("Missing agent transform"); + const content = artifact.transform( + "---\nname: explorer\ndescription: Role\n---\nBody\n", + "aidd-dev", + "explorer.md" + ); + const fields = + host.toolId === "codex" ? parseToml(content) : parseFrontmatter(content).frontmatter; + expect(fields).not.toHaveProperty("model"); + } + }); + } + + it.each([ + [claude, { model: "custom-native", effort: "high" }], + [codex, { model: "custom-native", model_reasoning_effort: "high" }], + [copilot, { model: "custom-native", reasoningEffort: "high" }], + [cursor, { model: "custom-native" }], + [opencode, { model: "custom-native#high" }], + ] as const)("preserves explicit settings for $toolId", (host, options) => { + const capability = host.capabilities.agents; + const frontmatter = { name: "explorer", description: "Role", ...options }; + const converted = capability.convertFrontmatter(frontmatter, "explorer.md"); + expect(converted).toMatchObject(options); + const serialized = capability.serialize(converted, "Body"); + expect( + host.toolId === "codex" ? parseToml(serialized) : parseFrontmatter(serialized).frontmatter + ).toMatchObject(options); + for (const build of Object.values(host.buildContracts ?? {})) { + const artifact = build().artifacts.agents; + if (!artifact.supported || artifact.transform === undefined) + throw new Error("Missing agent transform"); + const content = artifact.transform( + serializeFrontmatter(frontmatter, "Body"), + "aidd-dev", + "explorer.md" + ); + expect( + host.toolId === "codex" ? parseToml(content) : parseFrontmatter(content).frontmatter + ).toMatchObject(options); + } + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/main-model-config.unit.test.ts b/cli/tests/contexts/tools/domain/formats/main-model-config.unit.test.ts new file mode 100644 index 000000000..e1ad9f531 --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/main-model-config.unit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + mainModelTarget, + mainModelValues, + planMainModelInstall, + planMainModelRemoval, +} from "../../../../../src/contexts/tools/domain/formats/main-model-config.js"; + +const codex = { model: "gpt-6.1-sol", model_reasoning_effort: "medium" }; + +describe("main model settings", () => { + it.each([ + ["claude", "anthropic", { model: "claude-opus-5-5", effortLevel: "medium" }], + ["codex", "openai", codex], + // OpenCode's root `model` keeps no variant, so no effort is written. + ["opencode", "mistral", { model: "mistral/mistral-large-4" }], + ] as const)("resolves %s / %s to native settings", (host, provider, expected) => { + expect(mainModelValues(host, provider)).toStrictEqual(expected); + }); + + it.each(["copilot", "cursor"] as const)("leaves %s without a file-based main model", (host) => { + expect(mainModelTarget(host)).toBeNull(); + }); + + it("merges JSON settings, keeping other keys, indentation and a BOM", () => { + const content = '{\n "model": "opus",\n "hooks": { "a": 1 }\n}'; + const { content: next, record } = planMainModelInstall( + content, + "json", + { model: "x" }, + undefined + ); + expect(next).toBe('{\n "model": "x",\n "hooks": {\n "a": 1\n }\n}\n'); + expect(record).toStrictEqual({ + written: { model: "x" }, + previous: { model: "opus" }, + created: false, + }); + expect(planMainModelRemoval(next, "json", record)).toContain('"model": "opus"'); + }); + + it("refuses JSON it cannot parse instead of overwriting it", () => { + expect(() => planMainModelInstall("// mine\n{}", "json", { model: "x" }, undefined)).toThrow(); + }); + + it("edits top-level TOML lines, keeping comments, tables and CRLF endings", () => { + const content = '# mine\r\nmodel = "old" # keep\r\n\r\n[profiles.fast]\r\nmodel = "table"\r\n'; + const { content: next, record } = planMainModelInstall(content, "toml", codex, undefined); + expect(next).toBe( + 'model_reasoning_effort = "medium"\r\n# mine\r\nmodel = "gpt-6.1-sol" # keep\r\n\r\n[profiles.fast]\r\nmodel = "table"\r\n' + ); + expect(planMainModelRemoval(next, "toml", record)).toBe( + '# mine\r\nmodel = "old" # keep\r\n\r\n[profiles.fast]\r\nmodel = "table"\r\n' + ); + }); + + it.each([ + ["a multi-line string", 'instructions = """\nmodel = "x"\n"""\n'], + ["a quoted key", '"model" = "old"\n'], + ["a dotted key", "model.x = 1\n"], + ["a multi-line array", 'list = [\n [1, 2],\n]\nmodel = "old"\n'], + ])("refuses to edit TOML with %s", (_, content) => { + expect(() => planMainModelInstall(content, "toml", codex, undefined)).toThrow(); + }); + + it("refuses to overwrite a value changed since AIDD set it", () => { + const first = planMainModelInstall(null, "json", { model: "x" }, undefined); + const edited = first.content.replace('"x"', '"mine"'); + expect(() => planMainModelInstall(edited, "json", { model: "y" }, first.record)).toThrow( + /changed since AIDD/ + ); + const again = planMainModelInstall(first.content, "json", { model: "y" }, first.record); + expect(again.record.previous).toStrictEqual({ model: null }); + }); + + it("restores only unchanged keys and deletes a file it created once empty", () => { + const created = planMainModelInstall(null, "toml", codex, undefined); + expect(planMainModelRemoval(created.content, "toml", created.record)).toBeNull(); + const edited = created.content.replace('"medium"', '"high"'); + expect(planMainModelRemoval(edited, "toml", created.record)).toBe( + 'model_reasoning_effort = "high"\n' + ); + const kept = planMainModelInstall("{}", "json", { model: "x" }, undefined); + expect(planMainModelRemoval(kept.content, "json", kept.record)).toBe("{}\n"); + }); + + it("leaves unreadable settings untouched on removal", () => { + const record = { written: { model: "x" }, previous: { model: null }, created: false }; + expect(planMainModelRemoval("// broken", "json", record)).toBe("// broken"); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts deleted file mode 100644 index 246749a38..000000000 --- a/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { claude } from "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; - -describe("claude", () => { - it("has .claude/ directory", () => { - expect(claude.directory).toBe(".claude/"); - }); - - it("writes agents as markdown", () => { - expect(claude.capabilities.agents.params.format).toBe("markdown"); - }); - - describe("capabilities.mcp", () => { - it("outputs to .mcp.json", () => { - expect(claude.capabilities.mcp.params.outputPath).toBe(".mcp.json"); - }); - - it("writes its servers as JSON under `mcpServers`", () => { - expect(claude.capabilities.mcp.params).toMatchObject({ - format: "json", - entrySection: "mcpServers", - }); - }); - - 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/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/agents.unit.test.ts new file mode 100644 index 000000000..d07678b1e --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/agents.unit.test.ts @@ -0,0 +1,34 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude agents", () => { + it("writes agents as markdown", () => { + expect(claude.capabilities.agents.params.format).toBe("markdown"); + }); + + describe("capabilities.agents.convertFrontmatter()", () => { + it("preserves an explicitly selected agent model", () => { + const fm = { name: "alexia", description: "Agent", model: "opus" }; + const result = claude.capabilities.agents.convertFrontmatter(fm); + expect(result).toEqual({ name: "alexia", description: "Agent", model: "opus" }); + }); + }); + + 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.agents.params.userDirectory()", () => { + const userDirectory = claude.capabilities.agents.params.userDirectory; + it("follows CLAUDE_CONFIG_DIR, else ~/.claude", () => { + expect( + userDirectory?.("/home", (name) => (name === "CLAUDE_CONFIG_DIR" ? "/cfg" : undefined)) + ).toBe("/cfg"); + expect(userDirectory?.("/home", () => undefined)).toBe(join("/home", ".claude")); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/commands.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/commands.unit.test.ts new file mode 100644 index 000000000..b8cb5705d --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/commands.unit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude commands", () => { + 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.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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/content.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/content.unit.test.ts new file mode 100644 index 000000000..848bedfaa --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/content.unit.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude content", () => { + 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" + ); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/mcp.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/mcp.unit.test.ts new file mode 100644 index 000000000..7b234ca4d --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/mcp.unit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude mcp", () => { + describe("capabilities.mcp", () => { + it("outputs to .mcp.json", () => { + expect(claude.capabilities.mcp.params.outputPath).toBe(".mcp.json"); + }); + + it("writes its servers as JSON under `mcpServers`", () => { + expect(claude.capabilities.mcp.params).toMatchObject({ + format: "json", + entrySection: "mcpServers", + }); + }); + + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts new file mode 100644 index 000000000..aa073ea4d --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts @@ -0,0 +1,82 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude plugins", () => { + 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/profile.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/profile.unit.test.ts new file mode 100644 index 000000000..5e0e28907 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/profile.unit.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude profile", () => { + it("has .claude/ directory", () => { + expect(claude.directory).toBe(".claude/"); + }); + + it("names the one config file Claude reads, and where it goes", () => { + expect(claude.configOutputPaths).toStrictEqual({ "settings.json": ".claude/settings.json" }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/rules.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/rules.unit.test.ts new file mode 100644 index 000000000..f5e6e4d52 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/rules.unit.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude rules", () => { + 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.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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/skills.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/skills.unit.test.ts new file mode 100644 index 000000000..ce703b2b0 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/skills.unit.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude skills", () => { + 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" + ); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts deleted file mode 100644 index 9a60582de..000000000 --- a/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts +++ /dev/null @@ -1,468 +0,0 @@ -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("keeps a skill folder's name whole, even one ending in .md", () => { - const path = codex.capabilities.skills.buildInstallPath("notes.md/SKILL.md"); - expect(path).toBe(".agents/skills/aidd-notes.md/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, none of its hooks run 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("finds its own hook inside any group of a user's SessionStart, and adds no second one", () => { - const existing = JSON.stringify( - { - SessionStart: [ - { hooks: [{ type: "command", command: "user-start.sh" }] }, - { hooks: [{ type: "command", command: "user-two.sh" }, AIDD_ENTRY.hooks[0]] }, - ], - }, - null, - 2 - ); - - expect(mergeCodexHooksJson(existing)).toBe(existing); - }); - - 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"); - }); - - it("starts over from a config it cannot parse rather than failing the install", () => { - expect(mergeCodexConfigToml("not = [valid", '[mcp_servers.ctx]\ncommand = "node"\n')).toBe( - 'project_doc_max_bytes = 262144\n\n[mcp_servers.ctx]\ncommand = "node"\n\n[features]\nhooks = true\n' - ); - }); - - it("adds only its defaults when the payload names no mcp server", () => { - expect(mergeCodexConfigToml('[mcp_servers.mine]\ncommand = "x"\n', "")).toBe( - 'project_doc_max_bytes = 262144\n\n[mcp_servers.mine]\ncommand = "x"\n\n[features]\nhooks = true\n' - ); - }); - - it("raises project_doc_max_bytes to what the payload asks when that is above the floor", () => { - expect(mergeCodexConfigToml("", "project_doc_max_bytes = 500000\n")).toBe( - "project_doc_max_bytes = 500000\n\n[features]\nhooks = true\n" - ); - }); - - it("leaves a user's project_doc_max_bytes already at the floor untouched, whatever the payload asks", () => { - expect( - mergeCodexConfigToml("project_doc_max_bytes = 262144\n", "project_doc_max_bytes = 500000\n") - ).toBe("project_doc_max_bytes = 262144\n\n[features]\nhooks = true\n"); - }); - - it("keeps a user's own hooks = false rather than turning hooks on", () => { - expect(mergeCodexConfigToml("[features]\nhooks = false\n", "")).toBe( - "project_doc_max_bytes = 262144\n\n[features]\nhooks = false\n" - ); - }); - - it("turns hooks on beside a user's other features, keeping them", () => { - expect(mergeCodexConfigToml("[features]\nweb_search = true\n", "")).toBe( - "project_doc_max_bytes = 262144\n\n[features]\nweb_search = true\nhooks = true\n" - ); - }); -}); - -/** - * 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("writes no description key for a skill that has none", () => { - expect(stripCodexSkillFrontmatter({ name: "n" })).toStrictEqual({ name: "n" }); - }); - - 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/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/agents.unit.test.ts new file mode 100644 index 000000000..ceedcc863 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/agents.unit.test.ts @@ -0,0 +1,22 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; + +describe("codex agents", () => { + describe("capabilities.agents.params.userDirectory()", () => { + it("follows CODEX_HOME, else ~/.codex", () => { + const userDirectory = codex.capabilities.agents.params.userDirectory; + expect(userDirectory?.("/home", (name) => (name === "CODEX_HOME" ? "/cx" : undefined))).toBe( + "/cx" + ); + expect(userDirectory?.("/home", () => undefined)).toBe(join("/home", ".codex")); + }); + }); + + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts index 1d1cb9cd5..87b1ba87a 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts @@ -94,33 +94,38 @@ describe("codexAgentMarkdownToToml()", () => { }); }); - describe("model omission (D-5)", () => { - it("omits model key when frontmatter has model: opus", () => { + describe("explicit model preservation", () => { + it.each([ + "opus", + "sonnet", + "haiku", + "inherit", + "default", + "opusplan", + "best", + "fable", + "sonnet[1m]", + "opus[1m]", + ])("omits Claude selector %s so the parent model is inherited", (model) => { const toml = codexAgentMarkdownToToml( - "---\nname: planner\ndescription: A planner.\nmodel: opus\n---\n\nBody.", + `---\nname: planner\nmodel: ${model}\n---\n\nBody.`, "aidd-dev", "planner.md" ); - expect(toml).not.toContain("model"); + expect(parseToml(toml)).not.toHaveProperty("model"); }); - it("omits model key when frontmatter has model: sonnet", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\nmodel: sonnet\n---\n\nBody.", - "aidd-dev", - "planner.md" - ); - expect(toml).not.toContain("model"); - }); - - it("omits model key for hypothetical Codex model id (MVP1 always omits)", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\nmodel: gpt-5-codex\n---\n\nBody.", - "aidd-dev", - "planner.md" - ); - expect(toml).not.toContain("model"); - }); + it.each(["gpt-5-codex", "gpt-6-luna", "local-coder", "claude-opus-5-5"])( + "preserves explicit model identifier %s for the configured provider", + (model) => { + const toml = codexAgentMarkdownToToml( + `---\nname: planner\nmodel: ${model}\nmodel_reasoning_effort: medium\n---\n\nBody.`, + "aidd-dev", + "planner.md" + ); + expect(parseToml(toml)).toMatchObject({ model, model_reasoning_effort: "medium" }); + } + ); }); describe("verbatim body preservation (D-4)", () => { diff --git a/cli/tests/contexts/tools/domain/profiles/codex/commands.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/commands.unit.test.ts new file mode 100644 index 000000000..9cc34ab61 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/commands.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; + +describe("codex commands", () => { + 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" }); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/config.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/config.unit.test.ts new file mode 100644 index 000000000..2bd082e34 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/config.unit.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { mergeCodexConfigToml } from "../../../../../../src/contexts/tools/domain/profiles/codex/build.js"; + +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"); + }); + + it("starts over from a config it cannot parse rather than failing the install", () => { + expect(mergeCodexConfigToml("not = [valid", '[mcp_servers.ctx]\ncommand = "node"\n')).toBe( + 'project_doc_max_bytes = 262144\n\n[mcp_servers.ctx]\ncommand = "node"\n\n[features]\nhooks = true\n' + ); + }); + + it("adds only its defaults when the payload names no mcp server", () => { + expect(mergeCodexConfigToml('[mcp_servers.mine]\ncommand = "x"\n', "")).toBe( + 'project_doc_max_bytes = 262144\n\n[mcp_servers.mine]\ncommand = "x"\n\n[features]\nhooks = true\n' + ); + }); + + it("raises project_doc_max_bytes to what the payload asks when that is above the floor", () => { + expect(mergeCodexConfigToml("", "project_doc_max_bytes = 500000\n")).toBe( + "project_doc_max_bytes = 500000\n\n[features]\nhooks = true\n" + ); + }); + + it("leaves a user's project_doc_max_bytes already at the floor untouched, whatever the payload asks", () => { + expect( + mergeCodexConfigToml("project_doc_max_bytes = 262144\n", "project_doc_max_bytes = 500000\n") + ).toBe("project_doc_max_bytes = 262144\n\n[features]\nhooks = true\n"); + }); + + it("keeps a user's own hooks = false rather than turning hooks on", () => { + expect(mergeCodexConfigToml("[features]\nhooks = false\n", "")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nhooks = false\n" + ); + }); + + it("turns hooks on beside a user's other features, keeping them", () => { + expect(mergeCodexConfigToml("[features]\nweb_search = true\n", "")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nweb_search = true\nhooks = true\n" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/content.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/content.unit.test.ts new file mode 100644 index 000000000..581b90f3c --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/content.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { rewriteCodexContent } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; + +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"); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/hooks.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/hooks.unit.test.ts new file mode 100644 index 000000000..0fa2c7ea3 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/hooks.unit.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + codex, + mergeCodexHooksJson, +} from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; + +describe("codex hooks", () => { + 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("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("finds its own hook inside any group of a user's SessionStart, and adds no second one", () => { + const existing = JSON.stringify( + { + SessionStart: [ + { hooks: [{ type: "command", command: "user-start.sh" }] }, + { hooks: [{ type: "command", command: "user-two.sh" }, AIDD_ENTRY.hooks[0]] }, + ], + }, + null, + 2 + ); + + expect(mergeCodexHooksJson(existing)).toBe(existing); + }); + + 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) + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/mcp.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/mcp.unit.test.ts new file mode 100644 index 000000000..91cd8ebe6 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/mcp.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; + +describe("codex mcp", () => { + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/plugins.unit.test.ts new file mode 100644 index 000000000..20de2462b --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/plugins.unit.test.ts @@ -0,0 +1,62 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; + +describe("codex plugins", () => { + 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, none of its hooks run and nothing says why." + ); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/profile.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/profile.unit.test.ts new file mode 100644 index 000000000..ca2ddd65f --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/profile.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { getToolConfig } from "../../../../../../src/contexts/tools/domain/registry.js"; + +describe("codex profile", () => { + 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"); + }); + + it("names the one config file Codex reads, and where it goes", () => { + expect(codex.configOutputPaths).toStrictEqual({ "config.toml": ".codex/config.toml" }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/rules.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/rules.unit.test.ts new file mode 100644 index 000000000..6a06a4ab1 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/rules.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; + +describe("codex rules", () => { + 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); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/skills.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/skills.unit.test.ts new file mode 100644 index 000000000..36578ea8f --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/skills.unit.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { stripCodexSkillFrontmatter } from "../../../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { serializeFrontmatter } from "../../../../../../src/kernel/markdown.js"; + +describe("codex skills", () => { + 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("keeps a skill folder's name whole, even one ending in .md", () => { + const path = codex.capabilities.skills.buildInstallPath("notes.md/SKILL.md"); + expect(path).toBe(".agents/skills/aidd-notes.md/SKILL.md"); + }); + }); +}); + +/** + * 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("writes no description key for a skill that has none", () => { + expect(stripCodexSkillFrontmatter({ name: "n" })).toStrictEqual({ name: "n" }); + }); + + 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/copilot.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts deleted file mode 100644 index 53a7ce5ca..000000000 --- a/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { CONFIG_MCP } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; -import { copilot } from "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import { nativeActivationOf } from "../../../../../src/contexts/tools/domain/registry.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" }); - }); - - it("names an unnamed agent after its own file, whatever folders it sits in", () => { - expect( - copilot.capabilities.agents.convertFrontmatter( - { description: "Reviews code" }, - "team/reviews/code-reviewer.md" - ) - ).toStrictEqual({ name: "code-reviewer", description: "Reviews code" }); - }); - - it("drops only the trailing .md from the file it names an agent after", () => { - expect( - copilot.capabilities.agents.convertFrontmatter( - { description: "Helps" }, - "notes.md-helper.md" - ) - ).toStrictEqual({ name: "notes.md-helper", description: "Helps" }); - }); - - it("leaves an agent unnamed when neither its frontmatter nor a file name gives one", () => { - expect( - copilot.capabilities.agents.convertFrontmatter({ description: "Helps" }) - ).toStrictEqual({ - name: undefined, - description: "Helps", - }); - }); - - it("drops a name that is not a string rather than writing it", () => { - expect( - copilot.capabilities.agents.convertFrontmatter({ name: 42, description: "Helps" }, "x.md") - ).toStrictEqual({ name: undefined, description: "Helps" }); - }); - }); - - 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.mcp.params.transformContent()", () => { - const transform = (content: string): string | undefined => - copilot.capabilities.mcp.params.transformContent?.(content); - - it("renames mcpServers to the servers key VS Code reads, keeping every other key", () => { - const content = JSON.stringify({ mcpServers: { ctx: { command: "node" } }, inputs: [] }); - expect(transform(content)).toBe( - JSON.stringify({ inputs: [], servers: { ctx: { command: "node" } } }, null, 2) - ); - }); - - it("returns a config already keyed by servers byte for byte", () => { - const content = JSON.stringify({ servers: { ctx: { command: "node" } } }); - expect(transform(content)).toBe(content); - }); - - it("renames nothing when a config carries both keys, rather than overwriting servers", () => { - const content = JSON.stringify({ servers: { a: {} }, mcpServers: { b: {} } }); - expect(transform(content)).toBe(content); - }); - }); - - 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"); - }); - - it("prefixes a command nested two folders deep with both folders' phase numbers", () => { - const path = copilot.capabilities.commands?.buildInstallPath("01_plan/02_steps/review.md"); - expect(path).toBe(".github/prompts/01-02-review.prompt.md"); - }); - - it("keeps a folder name whole when it does not start with a phase number", () => { - const path = copilot.capabilities.commands?.buildInstallPath("v2-tools/x.md"); - expect(path).toBe(".github/prompts/v2-tools-x.prompt.md"); - }); - - it("does not add the prompt extension twice to a file that already carries it", () => { - const path = copilot.capabilities.commands?.buildInstallPath("review.prompt.md"); - expect(path).toBe(".github/prompts/review.prompt.md"); - }); - - it("adds the prompt extension to a file that carries no extension at all", () => { - const path = copilot.capabilities.commands?.buildInstallPath("commit"); - 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("strips a numeric prefix of several digits from a rule's file name", () => { - const path = copilot.capabilities.rules?.buildInstallPath("01-standards/10-mermaid.md"); - expect(path).toBe(".github/instructions/01-mermaid.instructions.md"); - }); - - it("keeps a number inside a rule's file name, stripping only a leading one", () => { - const path = copilot.capabilities.rules?.buildInstallPath("01-standards/naming-2-rules.md"); - expect(path).toBe(".github/instructions/01-naming-2-rules.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(); - }); - - it("names a nested agent after its own file, not the folders above it", () => { - const path = copilot.capabilities.agents.buildInstallPath("team/sub/code-reviewer.md"); - expect(path).toBe(".github/agents/code-reviewer.agent.md"); - }); - - it("keeps a file that is not markdown under its own name", () => { - const path = copilot.capabilities.agents.buildInstallPath("helper.txt"); - expect(path).toBe(".github/agents/helper.txt"); - }); - }); - - 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("joins several path patterns into one comma-separated applyTo", () => { - expect( - copilot.capabilities.rules?.convertFrontmatter({ paths: ["src/**/*.ts", "tests/**/*.ts"] }) - ).toStrictEqual({ applyTo: "src/**/*.ts,tests/**/*.ts" }); - }); - - it("writes no description key when alwaysApply is false and there is no description", () => { - expect(copilot.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( - {} - ); - }); - - it("drops the description of a rule that always applies", () => { - expect( - copilot.capabilities.rules?.convertFrontmatter({ description: "Always on." }) - ).toStrictEqual({}); - }); - - 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" - ); - }); - - it("resolves every other section's directory reference to its installed directory", () => { - expect(rewrite("@{{TOOLS}}/commands/ @{{TOOLS}}/rules/ @{{TOOLS}}/skills/")).toBe( - "[.github/prompts/](../../.github/prompts/) " + - "[.github/instructions/](../../.github/instructions/) " + - "[.github/skills/](../../.github/skills/)" - ); - }); - - it("keeps a nested directory reference's folder rather than flattening it", () => { - expect( - rewrite( - "@{{TOOLS}}/agents/team/ @{{TOOLS}}/commands/01-plan/ @{{TOOLS}}/rules/01-standards/" - ) - ).toBe( - "[.github/agents/team/](../../.github/agents/team/) " + - "[.github/prompts/01-plan/](../../.github/prompts/01-plan/) " + - "[.github/instructions/01-standards/](../../.github/instructions/01-standards/)" - ); - }); - - it("keeps a reference to a section's .gitkeep under its own section path", () => { - expect( - rewrite( - "@{{TOOLS}}/agents/.gitkeep @{{TOOLS}}/commands/.gitkeep @{{TOOLS}}/rules/.gitkeep @{{TOOLS}}/skills/.gitkeep" - ) - ).toBe( - "[.github/agents/.gitkeep](../../.github/agents/.gitkeep) " + - "[.github/commands/.gitkeep](../../.github/commands/.gitkeep) " + - "[.github/rules/.gitkeep](../../.github/rules/.gitkeep) " + - "[.github/skills/.gitkeep](../../.github/skills/.gitkeep)" - ); - }); - - it("rewrites every reference in the content, not only the first", () => { - expect( - rewrite("@{{TOOLS}}/agents/a.md @{{TOOLS}}/agents/b.md @{{DOCS}}/a.md @{{DOCS}}/b.md") - ).toBe( - "[.github/agents/a.agent.md](../../.github/agents/a.agent.md) " + - "[.github/agents/b.agent.md](../../.github/agents/b.agent.md) " + - "[aidd_docs/a.md](../../aidd_docs/a.md) " + - "[aidd_docs/b.md](../../aidd_docs/b.md)" - ); - }); - - it("leaves alone a placeholder that only resembles {{TOOLS}}", () => { - expect(rewrite("See @{{TOOLX}}/agents/x.md")).toBe("See @{{TOOLX}}/agents/x.md"); - }); - }); - - 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") - ); - }); - }); -}); - -describe("copilot declarations the rest of the CLI reads", () => { - it("probes a plugin manifest in the three places Copilot reads one, in its order", () => { - expect(copilot.distributionProbes?.manifest).toStrictEqual([ - ".plugin/plugin.json", - ".github/plugin/plugin.json", - "plugin.json", - ]); - }); - - it("writes agents as markdown", () => { - expect(copilot.capabilities.agents.params.format).toBe("markdown"); - }); - - it("writes its MCP servers as JSON under `servers`", () => { - expect(copilot.capabilities.mcp.params).toMatchObject({ - format: "json", - entrySection: "servers", - }); - expect(copilot.capabilities.mcp.consumes).toStrictEqual([CONFIG_MCP]); - }); - - it("translates plugins for its own marketplace", () => { - expect(copilot.capabilities.plugins.translationMode).toBe("marketplace"); - }); - - it("drives its own CLI with the verbs it answers to", () => { - expect(nativeActivationOf("copilot")).toMatchObject({ - binary: "copilot", - upgradeVerb: "update", - enableVerb: "install", - disableVerb: "uninstall", - sourceCheckVerb: "update", - forceRemoveArgs: ["--force"], - }); - }); -}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/agents.unit.test.ts new file mode 100644 index 000000000..56b9f378b --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/agents.unit.test.ts @@ -0,0 +1,111 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot agents", () => { + describe("capabilities.agents.convertFrontmatter()", () => { + it("includes repository instructions in custom subagents by default", () => { + expect(copilot.capabilities.agents.convertFrontmatter({ name: "reviewer" })).toHaveProperty( + "include-custom-instructions", + true + ); + }); + + it("preserves an explicit repository-instruction opt-out", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ + name: "reviewer", + "include-custom-instructions": false, + }) + ).toHaveProperty("include-custom-instructions", false); + }); + + it("preserves an explicitly selected agent model", () => { + const fm = { name: "alexia", description: "Agent", model: "opus" }; + const result = copilot.capabilities.agents.convertFrontmatter(fm); + expect(result).toEqual({ + name: "alexia", + description: "Agent", + model: "opus", + "include-custom-instructions": true, + }); + }); + + it("names an unnamed agent after its own file, whatever folders it sits in", () => { + expect( + copilot.capabilities.agents.convertFrontmatter( + { description: "Reviews code" }, + "team/reviews/code-reviewer.md" + ) + ).toStrictEqual({ + name: "code-reviewer", + description: "Reviews code", + "include-custom-instructions": true, + }); + }); + + it("drops only the trailing .md from the file it names an agent after", () => { + expect( + copilot.capabilities.agents.convertFrontmatter( + { description: "Helps" }, + "notes.md-helper.md" + ) + ).toStrictEqual({ + name: "notes.md-helper", + description: "Helps", + "include-custom-instructions": true, + }); + }); + + it("leaves an agent unnamed when neither its frontmatter nor a file name gives one", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ description: "Helps" }) + ).toStrictEqual({ + name: undefined, + description: "Helps", + "include-custom-instructions": true, + }); + }); + + it("drops a name that is not a string rather than writing it", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ name: 42, description: "Helps" }, "x.md") + ).toStrictEqual({ + name: undefined, + description: "Helps", + "include-custom-instructions": true, + }); + }); + }); + + describe("capabilities.agents.params.userDirectory()", () => { + it("follows COPILOT_HOME, else ~/.copilot", () => { + const userDirectory = copilot.capabilities.agents.params.userDirectory; + expect( + userDirectory?.("/home", (name) => (name === "COPILOT_HOME" ? "/cp" : undefined)) + ).toBe("/cp"); + expect(userDirectory?.("/home", () => undefined)).toBe(join("/home", ".copilot")); + }); + }); + + 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(); + }); + + it("names a nested agent after its own file, not the folders above it", () => { + const path = copilot.capabilities.agents.buildInstallPath("team/sub/code-reviewer.md"); + expect(path).toBe(".github/agents/code-reviewer.agent.md"); + }); + + it("keeps a file that is not markdown under its own name", () => { + const path = copilot.capabilities.agents.buildInstallPath("helper.txt"); + expect(path).toBe(".github/agents/helper.txt"); + }); + }); +}); 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 index 356012722..e2ea4eda1 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts @@ -7,6 +7,7 @@ import { buildCopilotFlatContract, buildCopilotMarketplaceContract, } from "../../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { parseFrontmatter } from "../../../../../../src/kernel/markdown.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; // Built, not written literally: biome reads a string holding "${...}" as a lost template. @@ -303,4 +304,19 @@ describe("buildCopilotFlatContract()", () => { transform?.("---\ndescription: Helps\n---\nBody.\n", "aidd-dev", "notes.md-helper.md") ).toBe("---\ndescription: 'Helps'\nname: 'aidd-dev-notes.md-helper'\n---\nBody.\n"); }); + + it.each([true, false])( + "preserves repository instruction selection %s in flat agents", + (include) => { + const transform = supported(buildCopilotFlatContract().artifacts.agents).transform; + const result = transform?.( + `---\nname: reviewer\ninclude-custom-instructions: ${include}\n---\nReview.\n`, + "aidd-dev", + "reviewer.md" + ); + expect(parseFrontmatter(result ?? "").frontmatter["include-custom-instructions"]).toBe( + include + ); + } + ); }); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/commands.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/commands.unit.test.ts new file mode 100644 index 000000000..d91dc0f23 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/commands.unit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot commands", () => { + 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"); + }); + + it("prefixes a command nested two folders deep with both folders' phase numbers", () => { + const path = copilot.capabilities.commands?.buildInstallPath("01_plan/02_steps/review.md"); + expect(path).toBe(".github/prompts/01-02-review.prompt.md"); + }); + + it("keeps a folder name whole when it does not start with a phase number", () => { + const path = copilot.capabilities.commands?.buildInstallPath("v2-tools/x.md"); + expect(path).toBe(".github/prompts/v2-tools-x.prompt.md"); + }); + + it("does not add the prompt extension twice to a file that already carries it", () => { + const path = copilot.capabilities.commands?.buildInstallPath("review.prompt.md"); + expect(path).toBe(".github/prompts/review.prompt.md"); + }); + + it("adds the prompt extension to a file that carries no extension at all", () => { + const path = copilot.capabilities.commands?.buildInstallPath("commit"); + expect(path).toBe(".github/prompts/commit.prompt.md"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/content.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/content.unit.test.ts new file mode 100644 index 000000000..a842e16bf --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/content.unit.test.ts @@ -0,0 +1,153 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +/** 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" + ); + }); + + it("resolves every other section's directory reference to its installed directory", () => { + expect(rewrite("@{{TOOLS}}/commands/ @{{TOOLS}}/rules/ @{{TOOLS}}/skills/")).toBe( + "[.github/prompts/](../../.github/prompts/) " + + "[.github/instructions/](../../.github/instructions/) " + + "[.github/skills/](../../.github/skills/)" + ); + }); + + it("keeps a nested directory reference's folder rather than flattening it", () => { + expect( + rewrite( + "@{{TOOLS}}/agents/team/ @{{TOOLS}}/commands/01-plan/ @{{TOOLS}}/rules/01-standards/" + ) + ).toBe( + "[.github/agents/team/](../../.github/agents/team/) " + + "[.github/prompts/01-plan/](../../.github/prompts/01-plan/) " + + "[.github/instructions/01-standards/](../../.github/instructions/01-standards/)" + ); + }); + + it("keeps a reference to a section's .gitkeep under its own section path", () => { + expect( + rewrite( + "@{{TOOLS}}/agents/.gitkeep @{{TOOLS}}/commands/.gitkeep @{{TOOLS}}/rules/.gitkeep @{{TOOLS}}/skills/.gitkeep" + ) + ).toBe( + "[.github/agents/.gitkeep](../../.github/agents/.gitkeep) " + + "[.github/commands/.gitkeep](../../.github/commands/.gitkeep) " + + "[.github/rules/.gitkeep](../../.github/rules/.gitkeep) " + + "[.github/skills/.gitkeep](../../.github/skills/.gitkeep)" + ); + }); + + it("rewrites every reference in the content, not only the first", () => { + expect( + rewrite("@{{TOOLS}}/agents/a.md @{{TOOLS}}/agents/b.md @{{DOCS}}/a.md @{{DOCS}}/b.md") + ).toBe( + "[.github/agents/a.agent.md](../../.github/agents/a.agent.md) " + + "[.github/agents/b.agent.md](../../.github/agents/b.agent.md) " + + "[aidd_docs/a.md](../../aidd_docs/a.md) " + + "[aidd_docs/b.md](../../aidd_docs/b.md)" + ); + }); + + it("leaves alone a placeholder that only resembles {{TOOLS}}", () => { + expect(rewrite("See @{{TOOLX}}/agents/x.md")).toBe("See @{{TOOLX}}/agents/x.md"); + }); + }); + + 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/mcp.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/mcp.unit.test.ts new file mode 100644 index 000000000..20275eab6 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/mcp.unit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot mcp", () => { + 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.mcp.params.transformContent()", () => { + const transform = (content: string): string | undefined => + copilot.capabilities.mcp.params.transformContent?.(content); + + it("renames mcpServers to the servers key VS Code reads, keeping every other key", () => { + const content = JSON.stringify({ mcpServers: { ctx: { command: "node" } }, inputs: [] }); + expect(transform(content)).toBe( + JSON.stringify({ inputs: [], servers: { ctx: { command: "node" } } }, null, 2) + ); + }); + + it("returns a config already keyed by servers byte for byte", () => { + const content = JSON.stringify({ servers: { ctx: { command: "node" } } }); + expect(transform(content)).toBe(content); + }); + + it("renames nothing when a config carries both keys, rather than overwriting servers", () => { + const content = JSON.stringify({ servers: { a: {} }, mcpServers: { b: {} } }); + expect(transform(content)).toBe(content); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/plugins.unit.test.ts new file mode 100644 index 000000000..a1e48f0e4 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/plugins.unit.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot plugins", () => { + 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(); + }); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts new file mode 100644 index 000000000..9d042e232 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { CONFIG_MCP } from "../../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { nativeActivationOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +describe("copilot declarations the rest of the CLI reads", () => { + it("probes a plugin manifest in the three places Copilot reads one, in its order", () => { + expect(copilot.distributionProbes?.manifest).toStrictEqual([ + ".plugin/plugin.json", + ".github/plugin/plugin.json", + "plugin.json", + ]); + }); + + it("writes agents as markdown", () => { + expect(copilot.capabilities.agents.params.format).toBe("markdown"); + }); + + it("writes its MCP servers as JSON under `servers`", () => { + expect(copilot.capabilities.mcp.params).toMatchObject({ + format: "json", + entrySection: "servers", + }); + expect(copilot.capabilities.mcp.consumes).toStrictEqual([CONFIG_MCP]); + }); + + it("translates plugins for its own marketplace", () => { + expect(copilot.capabilities.plugins.translationMode).toBe("marketplace"); + }); + + it("drives its own CLI with the verbs it answers to", () => { + expect(nativeActivationOf("copilot")).toMatchObject({ + binary: "copilot", + upgradeVerb: "update", + updateVerb: "update", + enableVerb: "install", + disableVerb: "uninstall", + sourceCheckVerb: "update", + forceRemoveArgs: ["--force"], + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/rules.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/rules.unit.test.ts new file mode 100644 index 000000000..1ea98e1f2 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/rules.unit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot rules", () => { + 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.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("strips a numeric prefix of several digits from a rule's file name", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/10-mermaid.md"); + expect(path).toBe(".github/instructions/01-mermaid.instructions.md"); + }); + + it("keeps a number inside a rule's file name, stripping only a leading one", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/naming-2-rules.md"); + expect(path).toBe(".github/instructions/01-naming-2-rules.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.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("joins several path patterns into one comma-separated applyTo", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ paths: ["src/**/*.ts", "tests/**/*.ts"] }) + ).toStrictEqual({ applyTo: "src/**/*.ts,tests/**/*.ts" }); + }); + + it("writes no description key when alwaysApply is false and there is no description", () => { + expect(copilot.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( + {} + ); + }); + + it("drops the description of a rule that always applies", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ description: "Always on." }) + ).toStrictEqual({}); + }); + + 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" }); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/settings.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/settings.unit.test.ts new file mode 100644 index 000000000..4349d6294 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/settings.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot settings", () => { + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/skills.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/skills.unit.test.ts new file mode 100644 index 000000000..cde838aba --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/skills.unit.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot skills", () => { + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts deleted file mode 100644 index aa92cb0cb..000000000 --- a/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { CONFIG_MCP } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; -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")); - }); - }); -}); - -describe("cursor declarations the rest of the CLI reads", () => { - it("has .cursor/ directory", () => { - expect(cursor.directory).toBe(".cursor/"); - }); - - it("probes a marketplace catalog at .cursor-plugin/marketplace.json", () => { - expect(cursor.distributionProbes?.marketplace).toStrictEqual([ - ".cursor-plugin/marketplace.json", - ]); - }); - - it("writes agents as markdown", () => { - expect(cursor.capabilities.agents.params.format).toBe("markdown"); - }); - - it("writes its MCP servers as JSON under `mcpServers`, in .cursor/mcp.json", () => { - expect(cursor.capabilities.mcp.params).toMatchObject({ - outputPath: ".cursor/mcp.json", - format: "json", - entrySection: "mcpServers", - }); - expect(cursor.capabilities.mcp.consumes).toStrictEqual([CONFIG_MCP]); - }); -}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts new file mode 100644 index 000000000..382968600 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor agents", () => { + describe("capabilities.agents.convertFrontmatter()", () => { + it("keeps model inheritance for AIDD agents installed as native plugins", () => { + const result = cursor.capabilities.agents.convertFrontmatter( + { name: "explorer", description: "Role" }, + "explorer.md" + ); + expect(result).toEqual({ name: "explorer", description: "Role" }); + }); + + it("preserves an explicitly selected agent model", () => { + const fm = { name: "alexia", description: "Agent", model: "opus" }; + const result = cursor.capabilities.agents.convertFrontmatter(fm); + expect(result).toEqual({ name: "alexia", description: "Agent", model: "opus" }); + }); + }); + + describe("capabilities.agents.serialize()", () => { + it("double-quotes values, since Cursor keeps single quotes in the name", () => { + expect( + cursor.capabilities.agents.serialize( + { name: "explorer", model: "m[effort=medium]" }, + "Body" + ) + ).toBe('---\nname: "explorer"\nmodel: "m[effort=medium]"\n---\nBody'); + }); + + it("declares no user directory: the Cursor CLI does not load user agents", () => { + expect(cursor.capabilities.agents.params.userDirectory).toBeUndefined(); + }); + }); + + 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"); + }); + }); +}); 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 index deff0395d..8c4341226 100644 --- a/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts @@ -7,6 +7,7 @@ import { buildCursorContract, buildCursorFlatContract, } from "../../../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { parseFrontmatter } from "../../../../../../src/kernel/markdown.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; // Built, not written literally: biome reads a string holding "${...}" as a lost template. @@ -35,6 +36,21 @@ function sourceOf(artifact: ArtifactContract): ArtifactSource | null { } describe("buildCursorContract()", () => { + it("gives same-named plugin roles distinct native identities", () => { + const transform = supported(buildCursorContract().artifacts.agents).transform; + const first = transform?.(AGENT_SOURCE, "first-plugin", "planner.md") ?? ""; + const second = transform?.(AGENT_SOURCE, "second-plugin", "planner.md") ?? ""; + expect(parseFrontmatter(first).frontmatter.name).toBe("first-plugin-planner"); + expect(parseFrontmatter(second).frontmatter.name).toBe("second-plugin-planner"); + }); + + it("drops only the file's final .md from a plugin agent's name", () => { + const transform = supported(buildCursorContract().artifacts.agents).transform; + const output = transform?.(AGENT_SOURCE, "aidd-dev", "read.md-first.md") ?? ""; + + expect(parseFrontmatter(output).frontmatter.name).toBe("aidd-dev-read.md-first"); + }); + it("declares Cursor's own plugin manifest path, schema and root token", () => { const contract = buildCursorContract(); @@ -114,7 +130,7 @@ describe("buildCursorContract()", () => { expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( [ "---", - "name: 'planner'", + "name: 'aidd-dev-planner'", "description: 'Plans the work'", "model: 'opus'", "---", diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/commands.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/commands.unit.test.ts new file mode 100644 index 000000000..a52ecadf2 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/commands.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor commands", () => { + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/content.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/content.unit.test.ts new file mode 100644 index 000000000..eec3f54d5 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/content.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor content", () => { + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/plugins.unit.test.ts new file mode 100644 index 000000000..9e20693b8 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/plugins.unit.test.ts @@ -0,0 +1,44 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor plugins", () => { + 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/profile.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/profile.unit.test.ts new file mode 100644 index 000000000..7da856f52 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/profile.unit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { CONFIG_MCP } from "../../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor declarations the rest of the CLI reads", () => { + it("has .cursor/ directory", () => { + expect(cursor.directory).toBe(".cursor/"); + }); + + it("probes a marketplace catalog at .cursor-plugin/marketplace.json", () => { + expect(cursor.distributionProbes?.marketplace).toStrictEqual([ + ".cursor-plugin/marketplace.json", + ]); + }); + + it("writes agents as markdown", () => { + expect(cursor.capabilities.agents.params.format).toBe("markdown"); + }); + + it("writes its MCP servers as JSON under `mcpServers`, in .cursor/mcp.json", () => { + expect(cursor.capabilities.mcp.params).toMatchObject({ + outputPath: ".cursor/mcp.json", + format: "json", + entrySection: "mcpServers", + }); + expect(cursor.capabilities.mcp.consumes).toStrictEqual([CONFIG_MCP]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/rules.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/rules.unit.test.ts new file mode 100644 index 000000000..d2e978224 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/rules.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor rules", () => { + 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.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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/skills.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/skills.unit.test.ts new file mode 100644 index 000000000..3b7bb1edc --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/skills.unit.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor skills", () => { + 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"); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts deleted file mode 100644 index c9c40137c..000000000 --- a/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { describe, expect, it } from "vitest"; -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 () => [], - listFilesRecursive: async () => [], - }; -} - -describe("opencode", () => { - describe("capabilities.agents.buildInstallPath()", () => { - it("builds path under .opencode/agents/", () => { - const path = opencode.capabilities.agents.buildInstallPath("code-reviewer.md"); - expect(path).toBe(".opencode/agents/code-reviewer.md"); - }); - - it("strips .opencode.md tool suffix", () => { - const path = opencode.capabilities.agents.buildInstallPath("alexia.opencode.md"); - expect(path).toBe(".opencode/agents/alexia.md"); - }); - }); - - describe("capabilities.agents.convertFrontmatter()", () => { - it("adds mode subagent to OpenCode agent frontmatter", () => { - const fm = { name: "alexia", description: "Act like the user" }; - const result = opencode.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ description: "Act like the user", mode: "subagent" }); - }); - }); - - describe("capabilities.commands.buildInstallPath()", () => { - it("builds AIDD-namespaced path under .opencode/commands/aidd//", () => { - const path = opencode.capabilities.commands?.buildInstallPath("04_code/implement.md"); - expect(path).toBe(".opencode/commands/aidd/04/implement.md"); - }); - - it("falls back to aidd/ for top-level files without a phase directory", () => { - const path = opencode.capabilities.commands?.buildInstallPath("implement.md"); - expect(path).toBe(".opencode/commands/aidd/implement.md"); - }); - }); - - describe("capabilities.commands.convertFrontmatter()", () => { - it("emits name prefixed with aidd:: and description", () => { - const fm = { name: "implement", description: "Implement a plan", model: "gpt-4o" }; - const result = opencode.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); - expect(result).toEqual({ name: "aidd:04:implement", description: "Implement a plan" }); - }); - - it("emits bare name when relativeFileName has no leading-digit phase", () => { - const fm = { name: "implement", description: "Implement a plan" }; - const result = opencode.capabilities.commands?.convertFrontmatter(fm, "implement.md"); - 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"); - expect(path).toBe(".opencode/rules/01-standards/naming.md"); - }); - }); - - describe("capabilities.rules.convertFrontmatter()", () => { - it("returns empty frontmatter regardless of input (paths, always-apply)", () => { - const result = opencode.capabilities.rules?.convertFrontmatter({ - paths: ["src/**/*.ts"], - }); - expect(result).toEqual({}); - }); - - it("returns empty frontmatter for always-apply rules", () => { - const result = opencode.capabilities.rules?.convertFrontmatter({ - description: "always", - }); - expect(result).toEqual({}); - }); - - it("keeps description when alwaysApply is false and no paths are specified", () => { - const result = opencode.capabilities.rules?.convertFrontmatter({ - description: "Apply when editing command files.", - alwaysApply: false, - }); - expect(result).toEqual({ description: "Apply when editing command files." }); - }); - - it("writes no description key when alwaysApply is false and there is no description", () => { - expect(opencode.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( - {} - ); - }); - }); - - describe("capabilities.skills.buildInstallPath()", () => { - it("builds path under .opencode/skills/", () => { - const path = opencode.capabilities.skills.buildInstallPath("my-skill/SKILL.md"); - expect(path).toBe(".opencode/skills/my-skill/SKILL.md"); - }); - }); - - describe("capabilities.skills.convertFrontmatter()", () => { - it("preserves all frontmatter fields", () => { - const fm = { name: "my-skill", description: "A skill" }; - expect(opencode.capabilities.skills.convertFrontmatter(fm)).toEqual(fm); - }); - }); - - describe("capabilities.mcp", () => { - it("maps both opencode and mcp config names to opencode.json", () => { - expect(opencode.capabilities.mcp.params.outputPath).toBe("opencode.json"); - expect(opencode.capabilities.mcp.consumes).toContain("opencode"); - expect(opencode.capabilities.mcp.consumes).toContain("mcp"); - }); - - it("does not consume unknown config names", () => { - expect(opencode.capabilities.mcp.consumes).not.toContain("unknown"); - }); - - it("uses framework-prime merge strategy", () => { - expect(opencode.capabilities.mcp.params.mergeStrategy).toBe("framework-prime"); - }); - - it("writes its config as JSON, under the mcp key", () => { - const { format, entrySection } = opencode.capabilities.mcp.params; - expect({ format, entrySection }).toStrictEqual({ format: "json", entrySection: "mcp" }); - }); - }); - - describe("capabilities.mcp.transform() (MCP transform)", () => { - it("is defined on the OpenCode mcp capability", () => { - expect(opencode.capabilities.mcp.params.transformContent).toBeDefined(); - }); - - const transform = (configName: string, content: string): string => { - if (configName !== "mcp") return content; - const fn = opencode.capabilities.mcp.params.transformContent; - if (!fn) throw new Error("transformContent not defined"); - return fn(content); - }; - - it("transforms mcpServers to OpenCode mcp format with env", () => { - const input = JSON.stringify({ - mcpServers: { - "my-server": { - command: "npx", - args: ["-y", "@some/mcp-server"], - env: { API_KEY: "secret" }, - }, - }, - }); - expect(JSON.parse(transform("mcp", input))).toEqual({ - mcp: { - "my-server": { - type: "local", - command: ["npx", "-y", "@some/mcp-server"], - enabled: true, - environment: { API_KEY: "secret" }, - }, - }, - }); - }); - - it("omits environment when env field is absent", () => { - const input = JSON.stringify({ - mcpServers: { server: { command: "node", args: ["server.js"] } }, - }); - expect(JSON.parse(transform("mcp", input)).mcp.server).not.toHaveProperty("environment"); - }); - - it("omits environment when env is explicitly empty", () => { - const input = JSON.stringify({ - mcpServers: { server: { command: "node", env: {} } }, - }); - expect(JSON.parse(transform("mcp", input)).mcp.server).not.toHaveProperty("environment"); - }); - - it("produces empty mcp object when mcpServers is empty", () => { - const input = JSON.stringify({ mcpServers: {} }); - expect(JSON.parse(transform("mcp", input))).toEqual({ mcp: {} }); - }); - - it("throws with context on malformed JSON", () => { - expect(() => transform("mcp", "not-json")).toThrow("Cannot parse MCP config:"); - }); - - it("throws when root is not an object (array)", () => { - expect(() => transform("mcp", "[]")).toThrow("MCP config must be a JSON object"); - }); - - it("transforms url-based server to remote format", () => { - const input = JSON.stringify({ - mcpServers: { figma: { url: "https://mcp.figma.com/mcp", type: "http" } }, - }); - expect(JSON.parse(transform("mcp", input))).toEqual({ - mcp: { figma: { type: "remote", url: "https://mcp.figma.com/mcp", enabled: true } }, - }); - }); - - it("handles mixed local and remote servers in the same config", () => { - const input = JSON.stringify({ - mcpServers: { - local: { command: "npx", args: ["-y", "pkg"] }, - remote: { url: "https://example.com/mcp" }, - }, - }); - const result = JSON.parse(transform("mcp", input)); - expect(result.mcp.local.type).toBe("local"); - expect(result.mcp.remote.type).toBe("remote"); - }); - - it("throws when a server has neither command nor url", () => { - const input = JSON.stringify({ mcpServers: { bad: { token: "abc" } } }); - expect(() => transform("mcp", input)).toThrow( - 'MCP server "bad" must have either a "command" or "url" field' - ); - }); - - it("returns non-mcp content unchanged", () => { - const content = '{"instructions":[".opencode/rules/**/*.md"]}'; - expect(transform("opencode", content)).toBe(content); - }); - - it("sets enabled: false on local server with disabled: true", () => { - const input = JSON.stringify({ - mcpServers: { "my-server": { command: "node", args: ["server.js"], disabled: true } }, - }); - expect(JSON.parse(transform("mcp", input)).mcp["my-server"].enabled).toBe(false); - }); - - it("sets enabled: false on remote server with disabled: true", () => { - const input = JSON.stringify({ - mcpServers: { figma: { url: "https://mcp.figma.com/mcp", disabled: true } }, - }); - expect(JSON.parse(transform("mcp", input)).mcp.figma.enabled).toBe(false); - }); - - it("preserves mixed enabled/disabled servers correctly", () => { - const input = JSON.stringify({ - mcpServers: { - enabled: { command: "node", args: ["on.js"] }, - disabled: { command: "node", args: ["off.js"], disabled: true }, - }, - }); - const result = JSON.parse(transform("mcp", input)); - expect(result.mcp.enabled.enabled).toBe(true); - expect(result.mcp.disabled.enabled).toBe(false); - }); - - it("sets enabled: true when disabled is explicitly false", () => { - const input = JSON.stringify({ - mcpServers: { "my-server": { command: "node", disabled: false } }, - }); - expect(JSON.parse(transform("mcp", input)).mcp["my-server"].enabled).toBe(true); - }); - }); - - describe("capabilities.mcp.resolveOutput()", () => { - const PROJECT_ROOT = "/project"; - - async function resolve(fs: FileReader): Promise { - return opencode.capabilities.mcp.resolveOutput(PROJECT_ROOT, fs); - } - - it("returns opencode.json when neither config file exists", async () => { - expect(await resolve(makeFs([]))).toBe("opencode.json"); - }); - - it("returns opencode.json when only opencode.json exists", async () => { - expect(await resolve(makeFs(["opencode.json"]))).toBe("opencode.json"); - }); - - it("returns opencode.jsonc when only opencode.jsonc exists", async () => { - expect(await resolve(makeFs(["opencode.jsonc"]))).toBe("opencode.jsonc"); - }); - - it("throws OpencodeDualConfigError when both opencode.json and opencode.jsonc exist", async () => { - await expect(resolve(makeFs(["opencode.json", "opencode.jsonc"]))).rejects.toThrow( - OpencodeDualConfigError - ); - }); - }); - - describe("capabilities.plugins", () => { - it("has a plugins capability", () => { - expect("plugins" in opencode.capabilities).toBe(true); - }); - - it("is flat mode", () => { - expect(opencode.capabilities.plugins.mode).toBe("flat"); - }); - - it("uses aidd- as flat namespace prefix", () => { - expect(opencode.capabilities.plugins.flatNamespacePrefix).toBe("aidd-"); - }); - - it("pluginsDir is null", () => { - expect(opencode.capabilities.plugins.pluginsDir).toBeNull(); - }); - - it("pluginOutputDir returns null", () => { - expect(opencode.capabilities.plugins.pluginOutputDir("my-plugin")).toBeNull(); - }); - - it("namespaces a plugin's hook scripts under .opencode/hooks/", () => { - expect(opencode.capabilities.plugins.flatHooksDir).toBe(".opencode/hooks/"); - }); - - 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", - }); - }); - }); - - 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("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("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); - }); - }); -}); - -describe("opencode.rewriteContent()", () => { - it("routes a numbered command folder under commands/aidd//, with or without the @ prefix", () => { - expect( - opencode.rewriteContent( - "Run .opencode/commands/04_code/implement.md, then @.opencode/commands/02-plan/plan.md.\n" - ) - ).toBe( - "Run .opencode/commands/aidd/04/implement.md, then @.opencode/commands/aidd/02/plan.md.\n" - ); - }); -}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/agents.unit.test.ts new file mode 100644 index 000000000..83b8c9f77 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/agents.unit.test.ts @@ -0,0 +1,35 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +describe("opencode agents", () => { + describe("capabilities.agents.params.userDirectory()", () => { + const userDirectory = opencode.capabilities.agents.params.userDirectory; + it("follows XDG_CONFIG_HOME, else ~/.config, under opencode", () => { + expect( + userDirectory?.("/home", (name) => (name === "XDG_CONFIG_HOME" ? "/xdg" : undefined)) + ).toBe(join("/xdg", "opencode")); + expect(userDirectory?.("/home", () => undefined)).toBe(join("/home", ".config", "opencode")); + }); + }); + + describe("capabilities.agents.buildInstallPath()", () => { + it("builds path under .opencode/agents/", () => { + const path = opencode.capabilities.agents.buildInstallPath("code-reviewer.md"); + expect(path).toBe(".opencode/agents/code-reviewer.md"); + }); + + it("strips .opencode.md tool suffix", () => { + const path = opencode.capabilities.agents.buildInstallPath("alexia.opencode.md"); + expect(path).toBe(".opencode/agents/alexia.md"); + }); + }); + + describe("capabilities.agents.convertFrontmatter()", () => { + it("adds mode subagent to OpenCode agent frontmatter", () => { + const fm = { name: "alexia", description: "Act like the user" }; + const result = opencode.capabilities.agents.convertFrontmatter(fm); + expect(result).toEqual({ description: "Act like the user", mode: "subagent" }); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/commands.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/commands.unit.test.ts new file mode 100644 index 000000000..c365ffafe --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/commands.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +describe("opencode commands", () => { + describe("capabilities.commands.buildInstallPath()", () => { + it("builds AIDD-namespaced path under .opencode/commands/aidd//", () => { + const path = opencode.capabilities.commands?.buildInstallPath("04_code/implement.md"); + expect(path).toBe(".opencode/commands/aidd/04/implement.md"); + }); + + it("falls back to aidd/ for top-level files without a phase directory", () => { + const path = opencode.capabilities.commands?.buildInstallPath("implement.md"); + expect(path).toBe(".opencode/commands/aidd/implement.md"); + }); + }); + + describe("capabilities.commands.convertFrontmatter()", () => { + it("emits name prefixed with aidd:: and description", () => { + const fm = { name: "implement", description: "Implement a plan", model: "gpt-4o" }; + const result = opencode.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); + expect(result).toEqual({ name: "aidd:04:implement", description: "Implement a plan" }); + }); + + it("emits bare name when relativeFileName has no leading-digit phase", () => { + const fm = { name: "implement", description: "Implement a plan" }; + const result = opencode.capabilities.commands?.convertFrontmatter(fm, "implement.md"); + expect(result).toEqual({ name: "implement", description: "Implement a plan" }); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/content.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/content.unit.test.ts new file mode 100644 index 000000000..179465050 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/content.unit.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +describe("opencode.rewriteContent()", () => { + it("routes a numbered command folder under commands/aidd//, with or without the @ prefix", () => { + expect( + opencode.rewriteContent( + "Run .opencode/commands/04_code/implement.md, then @.opencode/commands/02-plan/plan.md.\n" + ) + ).toBe( + "Run .opencode/commands/aidd/04/implement.md, then @.opencode/commands/aidd/02/plan.md.\n" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/hooks.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/hooks.unit.test.ts new file mode 100644 index 000000000..892732b8c --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/hooks.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +describe("opencode hooks", () => { + 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("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("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/mcp.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/mcp.unit.test.ts new file mode 100644 index 000000000..d322a1e1a --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/mcp.unit.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +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 () => [], + listFilesRecursive: async () => [], + }; +} + +describe("opencode mcp", () => { + describe("capabilities.mcp", () => { + it("maps both opencode and mcp config names to opencode.json", () => { + expect(opencode.capabilities.mcp.params.outputPath).toBe("opencode.json"); + expect(opencode.capabilities.mcp.consumes).toContain("opencode"); + expect(opencode.capabilities.mcp.consumes).toContain("mcp"); + }); + + it("does not consume unknown config names", () => { + expect(opencode.capabilities.mcp.consumes).not.toContain("unknown"); + }); + + it("uses framework-prime merge strategy", () => { + expect(opencode.capabilities.mcp.params.mergeStrategy).toBe("framework-prime"); + }); + + it("writes its config as JSON, under the mcp key", () => { + const { format, entrySection } = opencode.capabilities.mcp.params; + expect({ format, entrySection }).toStrictEqual({ format: "json", entrySection: "mcp" }); + }); + }); + + describe("capabilities.mcp.transform() (MCP transform)", () => { + it("is defined on the OpenCode mcp capability", () => { + expect(opencode.capabilities.mcp.params.transformContent).toBeDefined(); + }); + + const transform = (configName: string, content: string): string => { + if (configName !== "mcp") return content; + const fn = opencode.capabilities.mcp.params.transformContent; + if (!fn) throw new Error("transformContent not defined"); + return fn(content); + }; + + it("transforms mcpServers to OpenCode mcp format with env", () => { + const input = JSON.stringify({ + mcpServers: { + "my-server": { + command: "npx", + args: ["-y", "@some/mcp-server"], + env: { API_KEY: "secret" }, + }, + }, + }); + expect(JSON.parse(transform("mcp", input))).toEqual({ + mcp: { + "my-server": { + type: "local", + command: ["npx", "-y", "@some/mcp-server"], + enabled: true, + environment: { API_KEY: "secret" }, + }, + }, + }); + }); + + it("omits environment when env field is absent", () => { + const input = JSON.stringify({ + mcpServers: { server: { command: "node", args: ["server.js"] } }, + }); + expect(JSON.parse(transform("mcp", input)).mcp.server).not.toHaveProperty("environment"); + }); + + it("omits environment when env is explicitly empty", () => { + const input = JSON.stringify({ + mcpServers: { server: { command: "node", env: {} } }, + }); + expect(JSON.parse(transform("mcp", input)).mcp.server).not.toHaveProperty("environment"); + }); + + it("produces empty mcp object when mcpServers is empty", () => { + const input = JSON.stringify({ mcpServers: {} }); + expect(JSON.parse(transform("mcp", input))).toEqual({ mcp: {} }); + }); + + it("throws with context on malformed JSON", () => { + expect(() => transform("mcp", "not-json")).toThrow("Cannot parse MCP config:"); + }); + + it("throws when root is not an object (array)", () => { + expect(() => transform("mcp", "[]")).toThrow("MCP config must be a JSON object"); + }); + + it("transforms url-based server to remote format", () => { + const input = JSON.stringify({ + mcpServers: { figma: { url: "https://mcp.figma.com/mcp", type: "http" } }, + }); + expect(JSON.parse(transform("mcp", input))).toEqual({ + mcp: { figma: { type: "remote", url: "https://mcp.figma.com/mcp", enabled: true } }, + }); + }); + + it("handles mixed local and remote servers in the same config", () => { + const input = JSON.stringify({ + mcpServers: { + local: { command: "npx", args: ["-y", "pkg"] }, + remote: { url: "https://example.com/mcp" }, + }, + }); + const result = JSON.parse(transform("mcp", input)); + expect(result.mcp.local.type).toBe("local"); + expect(result.mcp.remote.type).toBe("remote"); + }); + + it("throws when a server has neither command nor url", () => { + const input = JSON.stringify({ mcpServers: { bad: { token: "abc" } } }); + expect(() => transform("mcp", input)).toThrow( + 'MCP server "bad" must have either a "command" or "url" field' + ); + }); + + it("returns non-mcp content unchanged", () => { + const content = '{"instructions":[".opencode/rules/**/*.md"]}'; + expect(transform("opencode", content)).toBe(content); + }); + + it("sets enabled: false on local server with disabled: true", () => { + const input = JSON.stringify({ + mcpServers: { "my-server": { command: "node", args: ["server.js"], disabled: true } }, + }); + expect(JSON.parse(transform("mcp", input)).mcp["my-server"].enabled).toBe(false); + }); + + it("sets enabled: false on remote server with disabled: true", () => { + const input = JSON.stringify({ + mcpServers: { figma: { url: "https://mcp.figma.com/mcp", disabled: true } }, + }); + expect(JSON.parse(transform("mcp", input)).mcp.figma.enabled).toBe(false); + }); + + it("preserves mixed enabled/disabled servers correctly", () => { + const input = JSON.stringify({ + mcpServers: { + enabled: { command: "node", args: ["on.js"] }, + disabled: { command: "node", args: ["off.js"], disabled: true }, + }, + }); + const result = JSON.parse(transform("mcp", input)); + expect(result.mcp.enabled.enabled).toBe(true); + expect(result.mcp.disabled.enabled).toBe(false); + }); + + it("sets enabled: true when disabled is explicitly false", () => { + const input = JSON.stringify({ + mcpServers: { "my-server": { command: "node", disabled: false } }, + }); + expect(JSON.parse(transform("mcp", input)).mcp["my-server"].enabled).toBe(true); + }); + }); + + describe("capabilities.mcp.resolveOutput()", () => { + const PROJECT_ROOT = "/project"; + + async function resolve(fs: FileReader): Promise { + return opencode.capabilities.mcp.resolveOutput(PROJECT_ROOT, fs); + } + + it("returns opencode.json when neither config file exists", async () => { + expect(await resolve(makeFs([]))).toBe("opencode.json"); + }); + + it("returns opencode.json when only opencode.json exists", async () => { + expect(await resolve(makeFs(["opencode.json"]))).toBe("opencode.json"); + }); + + it("returns opencode.jsonc when only opencode.jsonc exists", async () => { + expect(await resolve(makeFs(["opencode.jsonc"]))).toBe("opencode.jsonc"); + }); + + it("throws OpencodeDualConfigError when both opencode.json and opencode.jsonc exist", async () => { + await expect(resolve(makeFs(["opencode.json", "opencode.jsonc"]))).rejects.toThrow( + OpencodeDualConfigError + ); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/plugins.unit.test.ts new file mode 100644 index 000000000..b60a61e53 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/plugins.unit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +describe("opencode plugins", () => { + describe("capabilities.plugins", () => { + it("has a plugins capability", () => { + expect("plugins" in opencode.capabilities).toBe(true); + }); + + it("is flat mode", () => { + expect(opencode.capabilities.plugins.mode).toBe("flat"); + }); + + it("uses aidd- as flat namespace prefix", () => { + expect(opencode.capabilities.plugins.flatNamespacePrefix).toBe("aidd-"); + }); + + it("pluginsDir is null", () => { + expect(opencode.capabilities.plugins.pluginsDir).toBeNull(); + }); + + it("pluginOutputDir returns null", () => { + expect(opencode.capabilities.plugins.pluginOutputDir("my-plugin")).toBeNull(); + }); + + it("namespaces a plugin's hook scripts under .opencode/hooks/", () => { + expect(opencode.capabilities.plugins.flatHooksDir).toBe(".opencode/hooks/"); + }); + + 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", + }); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/rules.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/rules.unit.test.ts new file mode 100644 index 000000000..5a2cb8147 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/rules.unit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +describe("opencode rules", () => { + describe("capabilities.rules.buildInstallPath()", () => { + it("builds path under .opencode/rules/", () => { + const path = opencode.capabilities.rules?.buildInstallPath("01-standards/naming.md"); + expect(path).toBe(".opencode/rules/01-standards/naming.md"); + }); + }); + + describe("capabilities.rules.convertFrontmatter()", () => { + it("returns empty frontmatter regardless of input (paths, always-apply)", () => { + const result = opencode.capabilities.rules?.convertFrontmatter({ + paths: ["src/**/*.ts"], + }); + expect(result).toEqual({}); + }); + + it("returns empty frontmatter for always-apply rules", () => { + const result = opencode.capabilities.rules?.convertFrontmatter({ + description: "always", + }); + expect(result).toEqual({}); + }); + + it("keeps description when alwaysApply is false and no paths are specified", () => { + const result = opencode.capabilities.rules?.convertFrontmatter({ + description: "Apply when editing command files.", + alwaysApply: false, + }); + expect(result).toEqual({ description: "Apply when editing command files." }); + }); + + it("writes no description key when alwaysApply is false and there is no description", () => { + expect(opencode.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( + {} + ); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/skills.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/skills.unit.test.ts new file mode 100644 index 000000000..0109ddc62 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/skills.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +describe("opencode skills", () => { + describe("capabilities.skills.buildInstallPath()", () => { + it("builds path under .opencode/skills/", () => { + const path = opencode.capabilities.skills.buildInstallPath("my-skill/SKILL.md"); + expect(path).toBe(".opencode/skills/my-skill/SKILL.md"); + }); + }); + + describe("capabilities.skills.convertFrontmatter()", () => { + it("preserves all frontmatter fields", () => { + const fm = { name: "my-skill", description: "A skill" }; + expect(opencode.capabilities.skills.convertFrontmatter(fm)).toEqual(fm); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/vscode.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/vscode/settings.unit.test.ts similarity index 91% rename from cli/tests/contexts/tools/domain/profiles/vscode.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/vscode/settings.unit.test.ts index f964366ba..346c0fd29 100644 --- a/cli/tests/contexts/tools/domain/profiles/vscode.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/vscode/settings.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { vscodeToolConfig } from "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { vscodeToolConfig } from "../../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; -describe("vscodeToolConfig", () => { +describe("vscodeToolConfig settings", () => { describe("settings capabilities", () => { const settings = Array.isArray(vscodeToolConfig.settings) ? vscodeToolConfig.settings 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 index 652cd7a41..77a3ce565 100644 --- 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 @@ -28,7 +28,7 @@ async function write(relative: string, content: string): Promise { } function readerFor(tool: AiToolId) { - const reader = hostPluginRegistryReaders(home).get(tool); + const reader = hostPluginRegistryReaders(home, {}).get(tool); if (reader === undefined) throw new Error(`no reader declared for ${tool}`); return reader; } diff --git a/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts index 6d4e00522..e9a7d95e0 100644 --- a/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts @@ -212,15 +212,18 @@ describe("CodexOutputStrategy", () => { } }); - it("TOML files do not contain model key (D-5)", async () => { + it("TOML files leave Claude selectors unpinned for native model inheritance", async () => { const fs = await makeSeededFsFromReal(); const uc = makeUseCase(fs); await uc.execute({ sourceDir: REAL_FIXTURE_DIR, outDir: OUT_DIR, target: "codex" }); - const tomlFiles = fs.listUnder(OUT_DIR).filter((p) => p.endsWith(".toml")); - for (const tomlPath of tomlFiles) { - const content = fs.getFile(tomlPath) ?? ""; + for (const [plugin, agent, model] of [ + ["aidd-dev", "reviewer", undefined], + ["aidd-dev", "implementer", undefined], + ["aidd-async-dev", "async-orchestrator", undefined], + ]) { + const content = fs.getFile(`${OUT_DIR}/plugins/${plugin}/codex-agents/${agent}.toml`) ?? ""; const parsed = parseToml(content) as Record; - expect(parsed.model).toBeUndefined(); + expect(parsed.model).toBe(model); } }); }); @@ -448,15 +451,15 @@ describe("CodexOutputStrategy", () => { }); }); - describe("model key omission (D-5)", () => { - it("fixture agent with model: opus produces TOML with no model key", async () => { + describe("explicit model preservation", () => { + it("fixture agent with model: opus inherits the parent model", async () => { const fs = await makeSeededFsFromCodex(); const uc = makeUseCase(fs); await uc.execute({ sourceDir: CODEX_FIXTURE_DIR, outDir: OUT_DIR, target: "codex" }); const tomlContent = fs.getFile(`${OUT_DIR}/plugins/aidd-codex-fixture/codex-agents/test-agent.toml`) ?? ""; const parsed = parseToml(tomlContent) as Record; - expect(parsed.model).toBeUndefined(); + expect(parsed).not.toHaveProperty("model"); }); }); }); 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 index d0913b772..d368c8ff0 100644 --- a/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts +++ b/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts @@ -2,6 +2,7 @@ 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 { parseToml } from "../../../../src/contexts/tools/domain/profiles/codex/toml.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"; @@ -14,7 +15,7 @@ import { PluginDistribution, } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; import { FileHash } from "../../../../src/kernel/file.js"; -import { parseFrontmatter } from "../../../../src/kernel/markdown.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../../src/kernel/markdown.js"; const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; const translator = new PluginContentTranslator(stubHasher); @@ -84,6 +85,57 @@ function pathsFor(tool: ToolConfig, dist = makeDist()): string[] { } describe("PluginContentTranslator.translate()", () => { + it.each([ + [ + "claude", + claude, + ".claude/plugins/aidd-dev/agents/reviewer.md", + { model: "claude-haiku-5-5", effort: "medium" }, + ], + [ + "codex", + codex, + ".codex/plugins/aidd-dev/agents/reviewer.toml", + { model: "gpt-6-luna", model_reasoning_effort: "medium" }, + ], + [ + "copilot", + copilot, + ".github/plugins/aidd-dev/agents/reviewer.agent.md", + { model: "gpt-6-luna", reasoningEffort: "medium" }, + ], + [ + "opencode", + opencode, + ".opencode/agents/aidd-dev-reviewer.md", + { model: "openai/gpt-6-luna#medium" }, + ], + ] as const)( + "preserves %s explicit agent settings through ordinary installation", + (label, host, path, expected) => { + const base = makeDist({ manifest: { name: "aidd-dev", version: "1.0.0" } }); + const agent = makeFile( + "agents/reviewer.md", + serializeFrontmatter( + { name: "reviewer", description: "Role", ...expected }, + "Reviewer agent from sample-plugin." + ) + ); + const dist = makeDist({ + manifest: base.manifest, + files: base.files.map((file) => (file.relativePath === agent.relativePath ? agent : file)), + components: { ...base.components, agents: [agent] }, + }); + const installed = translator.translate(dist, host).find((file) => file.relativePath === path); + expect(installed).toBeDefined(); + const content = installed?.content ?? ""; + expect( + label === "codex" ? parseToml(content) : parseFrontmatter(content).frontmatter + ).toMatchObject(expected); + expect(content).toContain("Reviewer agent from sample-plugin."); + } + ); + describe("claude target", () => { it("emits all components claude supports under .claude/plugins/sample-plugin/", () => { const paths = pathsFor(claude); @@ -238,8 +290,8 @@ describe("PluginContentTranslator.translate()", () => { 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 agents at their native flat path", () => { + expect(pathsFor(opencode)).toContain(".opencode/agents/sample-plugin-reviewer.md"); }); it("emits skills under .opencode/skills/sample-plugin/", () => { diff --git a/cli/tests/e2e/agents-lifecycle.e2e.test.ts b/cli/tests/e2e/agents-lifecycle.e2e.test.ts new file mode 100644 index 000000000..30b58c366 --- /dev/null +++ b/cli/tests/e2e/agents-lifecycle.e2e.test.ts @@ -0,0 +1,101 @@ +import { access, chmod, mkdir, readFile, readlink, symlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, runCli } from "./helpers.js"; + +describe("agent ownership across CLI processes", () => { + it + .skipIf(process.platform === "win32") + .each([".codex/agents/aidd-dev-reviewer.toml", ".aidd/agents.json"])( + "refuses a dangling personal symlink at %s before writing agents", + async (relativePath) => { + const box = await createTestEnv("agents-dangling-symlink"); + const path = join(box.projectDir, relativePath); + const target = join(box.projectDir, "personal-missing-file"); + try { + await mkdir(dirname(path), { recursive: true }); + await symlink(target, path); + const failed = await runCli( + ["agents", "install", "--provider", "openai", "--ide", "codex"], + box.projectDir, + box.fakeHome + ); + expect(failed.exitCode).toBe(1); + expect(await readlink(path)).toBe(target); + await expect( + access(join(box.projectDir, ".codex/agents/aidd-dev-explorer.toml")) + ).rejects.toMatchObject({ code: "ENOENT" }); + if (relativePath !== ".aidd/agents.json") + await expect(access(join(box.projectDir, ".aidd/agents.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + await box.cleanup(); + } + } + ); + + it("retains both concurrent installations and removes only the selected IDE", async () => { + const box = await createTestEnv("agents-concurrent"); + const run = (args: string[]) => runCli(args, box.projectDir, box.fakeHome); + try { + const installed = await Promise.all([ + run(["agents", "install", "--provider", "openai", "--ide", "codex"]), + run(["agents", "install", "--provider", "mistral", "--ide", "opencode"]), + ]); + for (const result of installed) expect(result.exitCode, result.stderr).toBe(0); + const ledger = () => + readFile(join(box.projectDir, ".aidd/agents.json"), "utf8").then(JSON.parse); + expect(Object.keys(await ledger()).sort()).toEqual(["codex", "opencode"]); + const removed = await run(["agents", "remove", "--ide", "codex"]); + expect(removed.exitCode, removed.stderr).toBe(0); + expect(Object.keys(await ledger())).toEqual(["opencode"]); + for (const role of ["explorer", "executor", "validator", "reviewer"]) { + await access(join(box.projectDir, `.opencode/agents/aidd-dev-${role}.md`)); + await expect( + access(join(box.projectDir, `.codex/agents/aidd-dev-${role}.toml`)) + ).rejects.toThrow(); + } + const cleaned = await run(["clean", "--force"]); + expect(cleaned.exitCode, cleaned.stderr).toBe(0); + for (const role of ["explorer", "executor", "validator", "reviewer"]) + await expect( + access(join(box.projectDir, `.opencode/agents/aidd-dev-${role}.md`)) + ).rejects.toThrow(); + await expect(ledger()).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await box.cleanup(); + } + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "leaves no agents when the registry directory refuses writes, then allows retry", + async () => { + const box = await createTestEnv("agents-registry-refused"); + const directory = join(box.projectDir, ".aidd"); + const run = () => + runCli( + ["agents", "install", "--provider", "openai", "--ide", "codex"], + box.projectDir, + box.fakeHome + ); + try { + await mkdir(directory); + await chmod(directory, 0o555); + const failed = await run(); + expect(failed.exitCode).toBe(1); + for (const role of ["explorer", "executor", "validator", "reviewer"]) + await expect( + access(join(box.projectDir, `.codex/agents/aidd-dev-${role}.toml`)) + ).rejects.toThrow(); + await chmod(directory, 0o755); + const retried = await run(); + expect(retried.exitCode, retried.stderr).toBe(0); + await access(join(box.projectDir, ".aidd/agents.json")); + } finally { + await chmod(directory, 0o755); + await box.cleanup(); + } + } + ); +}); diff --git a/cli/tests/e2e/agents-runtime.e2e.test.ts b/cli/tests/e2e/agents-runtime.e2e.test.ts new file mode 100644 index 000000000..6ff82ccf1 --- /dev/null +++ b/cli/tests/e2e/agents-runtime.e2e.test.ts @@ -0,0 +1,145 @@ +import { execFile, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readdir, readFile, stat } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { cliPath } from "./helpers.js"; + +/** + * Opt-in (`AGENTS_RUNTIME_SMOKE=1`): installs the four agents into a throwaway project, then + * asks each installed host which agents it loaded. It runs the person's real, signed-in hosts, + * so it reads their own HOME. Claude Code, Copilot and OpenCode answer without inference; + * Cursor and Codex need one short model turn. + */ +const describeRuntime = process.env.AGENTS_RUNTIME_SMOKE === "1" ? describe : describe.skip; +const ROLES = ["aidd-dev-executor", "aidd-dev-explorer", "aidd-dev-reviewer", "aidd-dev-validator"]; +const run = promisify(execFile); +const TIMEOUT = 240_000; + +function installed(binary: string): boolean { + return (process.env.PATH ?? "") + .split(delimiter) + .some((directory) => directory !== "" && existsSync(join(directory, binary))); +} + +/** Combined output, whatever the exit code: hosts report unknown agents on stderr. Stdin is + * closed, since `codex exec` otherwise waits for more prompt on it. */ +function output(binary: string, args: string[], cwd: string): Promise { + return new Promise((resolve) => { + // OpenCode resolves its project from PWD, not the process directory. + const env = { ...process.env, PWD: cwd }; + const child = spawn(binary, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let text = ""; + child.stdout.on("data", (chunk) => { + text += chunk; + }); + child.stderr.on("data", (chunk) => { + text += chunk; + }); + const timer = setTimeout(() => child.kill(), TIMEOUT - 10_000); + child.on("close", () => { + clearTimeout(timer); + resolve(text); + }); + }); +} + +async function project(ide: string, provider: string): Promise { + const root = await mkdtemp(join(tmpdir(), `aidd-agents-runtime-${ide}-`)); + await run("git", ["init", "-q"], { cwd: root }); + await run( + process.execPath, + [cliPath(), "agents", "install", "--ide", ide, "--provider", provider], + { + cwd: root, + } + ); + return root; +} + +const expectAllRoles = (text: string) => { + for (const role of ROLES) expect(text).toContain(role); +}; + +describeRuntime("E2E: hosts load the installed agents", () => { + it.skipIf(!installed("claude"))( + "Claude Code lists them for an unknown --agent", + async () => { + const root = await project("claude-code", "anthropic"); + expectAllRoles(await output("claude", ["-p", "ok", "--agent", "aidd-dev-unknown"], root)); + }, + TIMEOUT + ); + + it.skipIf(!installed("copilot"))( + "Copilot lists them for an unknown --agent", + async () => { + const root = await project("copilot", "anthropic"); + expectAllRoles( + await output("copilot", ["-p", "ok", "--agent", "aidd-dev-unknown", "-s"], root) + ); + }, + TIMEOUT + ); + + it.skipIf(!installed("opencode"))( + "OpenCode lists them through its agent API", + async () => { + const root = await project("opencode", "anthropic"); + const listed = await output("opencode", ["api", "agent.list"], root); + expectAllRoles(listed); + }, + TIMEOUT + ); + + it.skipIf(!installed("cursor-agent"))( + "Cursor names them, unquoted, in its subagent catalog", + async (context) => { + const root = await project("cursor", "anthropic"); + const prompt = + "Call the Task tool with subagent type exactly aidd-dev-unknown and prompt hi. If it errors, reply with the full error text verbatim."; + const events = await output( + "cursor-agent", + ["-p", "--trust", "--output-format", "stream-json", prompt], + root + ); + if (events.includes("usage limit")) context.skip("Cursor account usage limit reached"); + for (const role of ROLES) expect(events).toMatch(new RegExp(`\\\\"${role}\\\\"`)); + }, + TIMEOUT + ); + + it.skipIf(!installed("codex"))( + "Codex spawns one with the installed prompt and model", + async () => { + const root = await project("codex", "openai"); + const started = Date.now(); + await output( + "codex", + [ + "exec", + "--skip-git-repo-check", + "-c", + `projects."${root}".trust_level="trusted"`, + "Spawn exactly one agent of type aidd-dev-explorer with the task: reply ok. Then reply done.", + ], + root + ); + const sessions = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions"); + const rollouts: string[] = []; + for (const entry of await readdir(sessions, { recursive: true })) + if (entry.endsWith(".jsonl")) rollouts.push(join(sessions, entry)); + const recent = []; + for (const path of rollouts) if ((await stat(path)).mtimeMs >= started) recent.push(path); + const child = (await Promise.all(recent.map((path) => readFile(path, "utf8")))).find((text) => + text.includes('"agent_role":"aidd-dev-explorer"') + ); + expect(child, "no Codex session ran as aidd-dev-explorer").toBeDefined(); + expect(child).toContain("Keep sources unchanged"); + expect(child).toContain('"model":"gpt-6-luna"'); + }, + TIMEOUT + ); +}); diff --git a/cli/tests/e2e/agents.e2e.test.ts b/cli/tests/e2e/agents.e2e.test.ts new file mode 100644 index 000000000..924952bbf --- /dev/null +++ b/cli/tests/e2e/agents.e2e.test.ts @@ -0,0 +1,263 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseToml } from "../../src/contexts/tools/domain/profiles/codex/toml.js"; +import { parseFrontmatter } from "../../src/kernel/markdown.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { createTestEnv, runCli } from "./helpers.js"; + +const roles = ["explorer", "executor", "validator", "reviewer"]; +const cases = [ + [ + "claude-code", + "anthropic", + ".claude", + ".md", + { model: "claude-haiku-5-5", effort: "medium" }, + { model: "claude-sonnet-5-5", effort: "medium" }, + ], + [ + "codex", + "openai", + ".codex", + ".toml", + { model: "gpt-6-luna", model_reasoning_effort: "medium" }, + { model: "gpt-6.1-sol", model_reasoning_effort: "medium" }, + ], + [ + "copilot", + "openai", + ".github", + ".agent.md", + { model: "gpt-6-luna", reasoningEffort: "medium" }, + { model: "gpt-6.1-sol", reasoningEffort: "medium" }, + ], + [ + "copilot", + "anthropic", + ".github", + ".agent.md", + { model: "claude-haiku-5.5", reasoningEffort: "medium" }, + { model: "claude-sonnet-5.5", reasoningEffort: "medium" }, + ], + [ + "opencode", + "openai", + ".opencode", + ".md", + { model: "openai/gpt-6-luna#medium" }, + { model: "openai/gpt-6.1-sol#medium" }, + ], + [ + "opencode", + "anthropic", + ".opencode", + ".md", + { model: "anthropic/claude-haiku-5-5#medium" }, + { model: "anthropic/claude-sonnet-5-5#medium" }, + ], + [ + "opencode", + "mistral", + ".opencode", + ".md", + { model: "mistral/mistral-small-latest#high" }, + { model: "mistral/mistral-medium-latest#high" }, + ], + [ + "cursor", + "anthropic", + ".cursor", + ".md", + { model: "claude-haiku-5-5[effort=medium]" }, + { model: "claude-sonnet-5-5[effort=medium]" }, + ], +] as const; + +describe.concurrent("explicit native agent installation", () => { + it.each(["marketplace", "flat"])("Codex %s translation omits Claude aliases", async (layout) => { + const box = await createTestEnv(`agents-codex-${layout}`); + try { + const source = join(REPOSITORY_ROOT, "cli", "tests", "fixtures", "framework-codex"); + const output = join(box.tempDir, "translated"); + await mkdir(output); + const result = await runCli( + ["translate", source, "--to", "codex", "--as", layout, "--out", output], + box.projectDir, + box.fakeHome + ); + expect(result.exitCode, result.stderr).toBe(0); + const path = + layout === "flat" + ? join(output, ".codex", "agents", "aidd-codex-fixture-test-agent.toml") + : join(output, "plugins", "aidd-codex-fixture", "codex-agents", "test-agent.toml"); + const agent = parseToml(await readFile(path, "utf8")); + expect(agent).not.toHaveProperty("model"); + expect(agent.developer_instructions).toContain("# Test Agent"); + } finally { + await box.cleanup(); + } + }); + + for (const [ide, provider, directory, suffix, expected, reviewModel] of cases) { + it(`${ide} installs the four agents with ${provider} headers, then removes them`, async () => { + const box = await createTestEnv(`agents-${ide}-${provider}`); + try { + const result = await runCli( + ["agents", "install", "--provider", provider, "--ide", ide], + box.projectDir, + box.fakeHome + ); + expect(result.exitCode, result.stderr).toBe(0); + for (const role of roles) { + const content = await readFile( + join(box.projectDir, directory, "agents", `aidd-dev-${role}${suffix}`), + "utf8" + ); + const fields = + ide === "codex" ? parseToml(content) : parseFrontmatter(content).frontmatter; + expect(fields).toMatchObject(role === "reviewer" ? reviewModel : expected); + if (ide === "copilot") expect(fields["include-custom-instructions"]).toBe(true); + if (ide === "opencode") expect(fields.mode).toBe("subagent"); + else expect(fields.name).toBe(`aidd-dev-${role}`); + expect(content).toContain("# Behavior"); + } + const repeated = await runCli( + ["agents", "install", "--provider", provider, "--ide", ide], + box.projectDir, + box.fakeHome + ); + expect(repeated.exitCode, repeated.stderr).toBe(0); + const removed = await runCli( + ["agents", "remove", "--ide", ide], + box.projectDir, + box.fakeHome + ); + expect(removed.exitCode, removed.stderr).toBe(0); + for (const role of roles) + await expect( + access(join(box.projectDir, directory, "agents", `aidd-dev-${role}${suffix}`)) + ).rejects.toThrow(); + } finally { + await box.cleanup(); + } + }); + } + + it.each([ + ["--ide", "codex"], + ["--provider", "openai"], + ["--provider", "anthropic", "--ide", "codex"], + ["--provider", "openai", "--ide", "claude-code"], + ["--provider", "mistral", "--ide", "codex"], + ["--provider", "unknown", "--ide", "codex"], + ["--provider", "openai", "--ide", "unknown"], + ])("rejects missing or unsupported selection: %s %s", async (...args) => { + const box = await createTestEnv("agents-invalid"); + try { + const result = await runCli(["agents", "install", ...args], box.projectDir, box.fakeHome); + expect(result.exitCode).toBe(1); + await expect(access(join(box.projectDir, ".aidd", "agents.json"))).rejects.toThrow(); + } finally { + await box.cleanup(); + } + }); + + it("refuses a personal-file collision before installing any agent", async () => { + const box = await createTestEnv("agents-collision"); + try { + const directory = join(box.projectDir, ".codex", "agents"); + await mkdir(directory, { recursive: true }); + const personal = join(directory, "aidd-dev-reviewer.toml"); + await writeFile(personal, "personal agent\n"); + const result = await runCli( + ["agents", "install", "--provider", "openai", "--ide", "codex"], + box.projectDir, + box.fakeHome + ); + expect(result.exitCode).toBe(1); + expect(await readFile(personal, "utf8")).toBe("personal agent\n"); + await expect(access(join(directory, "aidd-dev-explorer.toml"))).rejects.toThrow(); + } finally { + await box.cleanup(); + } + }); + + it("switches provider explicitly and preserves unrelated personal agents", async () => { + const box = await createTestEnv("agents-provider-switch"); + try { + const directory = join(box.projectDir, ".opencode", "agents"); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "personal.md"), "personal agent\n"); + for (const provider of ["openai", "anthropic", "mistral"]) { + const result = await runCli( + ["agents", "install", "--provider", provider, "--ide", "opencode"], + box.projectDir, + box.fakeHome + ); + expect(result.exitCode, result.stderr).toBe(0); + } + for (const role of roles) { + const content = await readFile(join(directory, `aidd-dev-${role}.md`), "utf8"); + expect(parseFrontmatter(content).frontmatter.model).toBe( + role === "reviewer" + ? "mistral/mistral-medium-latest#high" + : "mistral/mistral-small-latest#high" + ); + } + const removed = await runCli( + ["agents", "remove", "--ide", "opencode"], + box.projectDir, + box.fakeHome + ); + expect(removed.exitCode, removed.stderr).toBe(0); + expect(await readFile(join(directory, "personal.md"), "utf8")).toBe("personal agent\n"); + } finally { + await box.cleanup(); + } + }); + + it("clean previews then removes opted-in agents without requiring framework setup", async () => { + const box = await createTestEnv("agents-clean"); + try { + const installed = await runCli( + ["agents", "install", "--provider", "openai", "--ide", "codex"], + box.projectDir, + box.fakeHome + ); + expect(installed.exitCode, installed.stderr).toBe(0); + const directory = join(box.projectDir, ".codex", "agents"); + const preview = await runCli(["clean"], box.projectDir, box.fakeHome); + expect(preview.exitCode, preview.stderr).toBe(0); + await access(join(directory, "aidd-dev-explorer.toml")); + const cleaned = await runCli(["clean", "--force"], box.projectDir, box.fakeHome); + expect(cleaned.exitCode, cleaned.stderr).toBe(0); + for (const role of roles) + await expect(access(join(directory, `aidd-dev-${role}.toml`))).rejects.toThrow(); + await expect(access(join(box.projectDir, ".aidd", "agents.json"))).rejects.toThrow(); + } finally { + await box.cleanup(); + } + }); + + it("framework distribution contains no opt-in agents", async () => { + const box = await createTestEnv("agents-default"); + try { + const output = join(box.tempDir, "built"); + const result = await runCli( + ["translate", REPOSITORY_ROOT, "--to", "claude", "--out", output], + box.projectDir, + box.fakeHome + ); + expect(result.exitCode, result.stderr).toBe(0); + const plugin = join(output, "plugins", "aidd-dev"); + const manifest = JSON.parse( + await readFile(join(plugin, ".claude-plugin", "plugin.json"), "utf8") + ); + expect(manifest).not.toHaveProperty("agents"); + await expect(access(join(plugin, "agents"))).rejects.toThrow(); + } finally { + await box.cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index a0dfd3231..94681274a 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -257,6 +257,9 @@ export function sandboxedEnv( // 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"), + CLAUDE_CONFIG_DIR: join(fakeHome, ".claude"), + CLAUDE_CODE_PLUGIN_CACHE_DIR: join(fakeHome, ".claude", "plugins"), + COPILOT_HOME: join(fakeHome, ".copilot"), // 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", diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts index 22b4ecea6..002f2a5bc 100644 --- a/cli/tests/e2e/kilo-runtime.e2e.test.ts +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -280,8 +280,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { shell: KILO_SHELL, maxBuffer: 8 * 1024 * 1024, }); - expect(agents.stdout).toContain("aidd-dev-checker"); - expect(agents.stdout).toContain("aidd-dev-executor"); + expect(agents.stdout).not.toContain("aidd-dev-"); const mcpProject = join(tempDir, "generated-mcp"); await mkdir(mcpProject, { recursive: true }); @@ -300,6 +299,13 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { fakeHome ); expect(mcpBuild.exitCode, mcpBuild.stderr).toBe(0); + const fixtureAgents = await execFileAsync(KILO_BIN, ["agent", "list"], { + cwd: mcpProject, + env, + shell: KILO_SHELL, + maxBuffer: 8 * 1024 * 1024, + }); + expect(fixtureAgents.stdout).toContain("aidd-test-code-reviewer"); const mcp = await execFileAsync(KILO_BIN, ["mcp", "list"], { cwd: mcpProject, env, diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index e4c771321..98ad398d6 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -693,9 +693,9 @@ "plugins/aidd-dev/skills/01-plan/actions/02-components-behavior.md": "cacb673334d8947c3c252feaaf0ef50c269c06c5cf8f51b45663cbd42402e094", "plugins/aidd-dev/skills/01-plan/actions/03-image-extract-details.md": "6f6cddd0893888c71b0aed5ecfe48217afe0eef10feec6415db433bfcc69f8e3", "plugins/aidd-dev/skills/00-sdlc/SKILL.md": "19bd4a8cf8c6531a6db232c31855e39475c71e2b58c91b439832d813ff594b78", - "plugins/aidd-dev/agents/implementer.md": "61b4531baa060e9b77c41cf1938c6c6433482dfdfe5eb3a774453d45cfe8b092", - "plugins/aidd-dev/agents/planner.md": "de6efeda286fe33a5245a9ed3cf9e60b78946575a5bd66b359b2428adac51ba5", - "plugins/aidd-dev/agents/reviewer.md": "bc5ffdc650d701c73b5da1e5eba904419e33346aac5c68bffdcd53b1e0476432", + "plugins/aidd-dev/agents/implementer.md": "3447d0684155742c21a8692cf95057dd4f90019298e9e5cb6c4e613993393365", + "plugins/aidd-dev/agents/planner.md": "074147c1268518db93623ed1c88681e78bfabdf57bcc51e23a19c306aa8f7394", + "plugins/aidd-dev/agents/reviewer.md": "7d501f19569f48a2bfa04e134b26323645ab08987f88ae8a59ed6e65992a9d3d", "plugins/aidd-dev/.cursor-plugin/plugin.json": "6e892c043c48fdc258cf7d13f1feba463483a91543686da3b30b260296235334", "plugins/aidd-context/skills/06-discovery/SKILL.md": "28f82e034ed5707ad6e59b012680a94ff5935bc7f8a0b5407caeea20ccb2667a", "plugins/aidd-context/skills/06-discovery/actions/01-find-skill.md": "600caf7822017bfe75d40773e1673ada784227b98f5b88d4ba98a57822fa0dcf", @@ -795,7 +795,7 @@ "plugins/aidd-async-dev/skills/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", "plugins/aidd-async-dev/skills/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", "plugins/aidd-async-dev/hooks/hooks.json": "78922a784ee78e9e50587e93628cd3b9d4dfbe49087adc4514e6781cea38cbb9", - "plugins/aidd-async-dev/agents/async-orchestrator.md": "8b29ba73f27414e75c89c6453c0557212790d00d6becfadba363466999f2c9de", + "plugins/aidd-async-dev/agents/async-orchestrator.md": "5ca31d8117dcc4800265ab04093432090485fdda1de8a867b29c3d3e55d30e3d", "plugins/aidd-async-dev/.cursor-plugin/plugin.json": "8d9b5b5edc8ac5d51cdb24bc5605b30ed3149616bfbbcc69db45f35d477315d5", ".cursor-plugin/marketplace.json": "d197f330a5787647d58c7dc20253c8ef8b59c4dce115fdd7f12de45ccec7b18e" }, diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index 449726e32..5c15be947 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -2,7 +2,22 @@ { "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 Measure what Claude Code and Codex sessions\n consume, locally\n help [command] display help for command" + "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 agents Opt in to native specialist agents for this\n project\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 Measure what Claude Code and Codex sessions\n consume, locally\n help [command] display help for command" + }, + { + "invocation": "aidd agents", + "exitCode": 0, + "help": "Usage: aidd agents [options] [command]\n\nOpt in to native specialist agents for this project\n\nOptions:\n -h, --help display help for command\n\nCommands:\n install [options] Install agents with the selected provider's model and\n effort\n remove [options] Remove unmodified AIDD agents and restore the main model\n they replaced\n help [command] display help for command" + }, + { + "invocation": "aidd agents install", + "exitCode": 0, + "help": "Usage: aidd agents install [options]\n\nInstall agents with the selected provider's model and effort\n\nOptions:\n --provider openai, anthropic, mistral\n --ide claude-code, codex, copilot, opencode, cursor\n --main also set the provider's main session model in the IDE's\n settings\n --scope project (default) or user, the IDE's user configuration\n directory\n -h, --help display help for command" + }, + { + "invocation": "aidd agents remove", + "exitCode": 0, + "help": "Usage: aidd agents remove [options]\n\nRemove unmodified AIDD agents and restore the main model they replaced\n\nOptions:\n --ide claude-code, codex, copilot, opencode, cursor\n --scope project (default) or user, the IDE's user configuration\n directory\n -h, --help display help for command" }, { "invocation": "aidd auth", diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index 93c16f4cf..ef5b78054 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -734,7 +734,7 @@ "files": { "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/agents/code-reviewer.md": "3e043789b6143a92278595e43ce96cda", "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" }, diff --git a/cli/tests/helpers/unloadable-file-as-failed-test.unit.test.ts b/cli/tests/helpers/unloadable-file-as-failed-test.unit.test.ts index c14fd77c7..b2cb35654 100644 --- a/cli/tests/helpers/unloadable-file-as-failed-test.unit.test.ts +++ b/cli/tests/helpers/unloadable-file-as-failed-test.unit.test.ts @@ -11,12 +11,12 @@ const LOAD_ERROR = { function testFile(state: "pass" | "fail", tasks: RunnerTestFile["tasks"] = []): RunnerTestFile { const file: RunnerTestFile = { id: "cursor", - name: "cursor.unit.test.ts", - fullName: "cursor.unit.test.ts", + name: "cursor/agents.unit.test.ts", + fullName: "cursor/agents.unit.test.ts", mode: "run", meta: {}, type: "suite", - filepath: "/repo/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts", + filepath: "/repo/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts", projectName: "unit", concurrencyId: 0, workerId: 0, diff --git a/cli/tests/kernel/markdown-yaml-like.unit.test.ts b/cli/tests/kernel/markdown-yaml-like.unit.test.ts index 27812aed6..ae6f17442 100644 --- a/cli/tests/kernel/markdown-yaml-like.unit.test.ts +++ b/cli/tests/kernel/markdown-yaml-like.unit.test.ts @@ -109,6 +109,12 @@ describe("parseFrontmatter, line by line", () => { }); describe("serializeFrontmatter, line by line", () => { + it("writes double-quoted strings on request, escaping what YAML needs", () => { + expect(serializeFrontmatter({ a: `it's "x"\\` }, "body", "double")).toBe( + '---\na: "it\'s \\"x\\"\\\\"\n---\nbody' + ); + }); + it("quotes a value that only starts with a bracket", () => { expect(serializeFrontmatter({ a: "[open" }, "body")).toBe("---\na: '[open'\n---\nbody"); }); diff --git a/cli/tests/presentation/commands/agents-wiring.integration.test.ts b/cli/tests/presentation/commands/agents-wiring.integration.test.ts new file mode 100644 index 000000000..0be3d5bee --- /dev/null +++ b/cli/tests/presentation/commands/agents-wiring.integration.test.ts @@ -0,0 +1,165 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { agentIdeNames } from "../../../src/contexts/tools/domain/formats/agent-models.js"; + +const install = vi.fn(); +const remove = vi.fn(); +const USER_ROOT = { home: "/home", env: () => undefined, configDir: "/home/.config/aidd" }; + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + manageAgentsUseCase: { install, remove }, + userAgentRoot: USER_ROOT, + })), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerAgentsCommand } = await import("../../../src/presentation/commands/agents.js"); + +let written: string[]; +let errors: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + install.mockResolvedValue(4); + remove.mockResolvedValue(2); + 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; + }); +}); + +afterEach(() => vi.restoreAllMocks()); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride().option("--verbose"); + registerAgentsCommand(program); + await program.parseAsync(["node", "aidd", ...args]); + return written.join(""); +} + +describe("explicit agent command routing", () => { + it.each(["install", "remove"])("makes %s selections discoverable in help", (action) => { + const program = new Command(); + registerAgentsCommand(program); + const command = program.commands[0]?.commands.find((entry) => entry.name() === action); + const words = new Set(command?.helpInformation().match(/[\w-]+/g)); + const choices = [ + ...agentIdeNames(), + ...(action === "install" ? ["openai", "anthropic", "mistral"] : []), + ]; + for (const choice of choices) expect(words.has(choice), choice).toBe(true); + }); + + it.each([ + ["codex", "openai", "codex"], + ["claude-code", "anthropic", "claude"], + ["opencode", "mistral", "opencode"], + ])("passes %s and the chosen provider to installation", async (ide, provider, host) => { + expect(await run("agents", "install", "--ide", ide, "--provider", provider)).toBe( + `4 agent files installed for ${ide}.\n` + ); + expect(install).toHaveBeenCalledExactlyOnceWith({ + projectRoot: process.cwd(), + host, + provider, + main: false, + }); + expect(remove).not.toHaveBeenCalled(); + }); + + it("opts in to the main session model only with --main", async () => { + await run("agents", "install", "--ide", "codex", "--provider", "openai", "--main"); + expect(install).toHaveBeenCalledExactlyOnceWith({ + projectRoot: process.cwd(), + host: "codex", + provider: "openai", + main: true, + }); + }); + + it("targets the IDE's user configuration with --scope user", async () => { + await run("agents", "install", "--ide", "codex", "--provider", "openai", "--scope", "user"); + expect(install).toHaveBeenCalledExactlyOnceWith({ + projectRoot: process.cwd(), + host: "codex", + user: USER_ROOT, + provider: "openai", + main: false, + }); + }); + + it("removes only the selected IDE's agents without selecting a provider", async () => { + expect(await run("agents", "remove", "--ide", "codex")).toBe( + "2 agent files removed for codex.\n" + ); + expect(remove).toHaveBeenCalledExactlyOnceWith({ + projectRoot: process.cwd(), + host: "codex", + }); + expect(install).not.toHaveBeenCalled(); + }); + + it.each(["install", "remove"])("passes verbosity to %s dependencies", async (action) => { + await run( + "--verbose", + "agents", + action, + "--ide", + "codex", + ...(action === "install" ? ["--provider", "openai"] : []) + ); + expect(createDeps).toHaveBeenCalledExactlyOnceWith( + process.cwd(), + { verbose: true }, + expect.anything() + ); + }); + + it.each([["install", "--provider", "openai"], ["install", "--ide", "codex"], ["remove"]])( + "refuses missing selection before %s runs", + async (...args) => { + await expect(run("agents", ...args)).rejects.toThrow("required option"); + expect(createDeps).not.toHaveBeenCalled(); + } + ); + + it.each([ + ["install", "--ide", "codex", "--provider", "other"], + ["install", "--ide", "other", "--provider", "openai"], + ["remove", "--ide", "other"], + ])("refuses unsupported selection before %s runs", async (...args) => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("agents", ...args)).rejects.toThrow("exited"); + expect(exit).toHaveBeenCalledWith(1); + expect(errors.join("")).toMatch(/not supported|Unsupported agent provider/); + expect(createDeps).not.toHaveBeenCalled(); + }); + + it.each(["install", "remove"])("reports %s failures and exits unsuccessfully", async (action) => { + (action === "install" ? install : remove).mockRejectedValue(new Error("agent was modified")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect( + run( + "agents", + action, + "--ide", + "codex", + ...(action === "install" ? ["--provider", "openai"] : []) + ) + ).rejects.toThrow("exited"); + expect(exit).toHaveBeenCalledWith(1); + expect(errors.join("")).toBe("Error: agent was modified\n"); + }); +}); diff --git a/cli/tests/runtime/filesystem/atomic-write.integration.test.ts b/cli/tests/runtime/filesystem/atomic-write.integration.test.ts index 0a053b985..69e3d5c0e 100644 --- a/cli/tests/runtime/filesystem/atomic-write.integration.test.ts +++ b/cli/tests/runtime/filesystem/atomic-write.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { chmod, mkdtemp, readdir, 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"; @@ -25,6 +25,16 @@ describe("atomicWriteFile", () => { expect(await readdir(tempDir)).toStrictEqual(["out.txt"]); expect(await readFile(path, "utf-8")).toBe("héllo"); }); + + it.skipIf(process.platform === "win32")("keeps a replaced file's permissions", async () => { + const path = join(tempDir, "private.json"); + await writeFile(path, "{}"); + await chmod(path, 0o600); + + await atomicWriteFile(path, '{"model":"x"}'); + + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); }); describe("HasherAdapter", () => { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8976344d..6eea12c29 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -91,7 +91,7 @@ flowchart TB User -->|direct invocation| Recipe["Recipe SKILL.md"] Protocol -->|discover and invoke provider| Recipe Protocol -->|authorized isolation| Agent["Agent"] - Agent -->|declared recipes only| Recipe + Agent -->|scoped work| Recipe Recipe --> Action["Self-contained action"] Action --> Result["Result or artifact"] ``` @@ -101,9 +101,10 @@ flowchart TB | Role | Contract | | --- | --- | | Skill | Host loads `SKILL.md` on invocation; work runs in the caller's context. | -| Orchestrator | Owns routing; authorizes isolation or bounded fan-out. | -| Agent | Isolates work and returns a result. Invokes only declared canonical recipe skills; never invokes orchestrators or reads skill files. | -| Recipe | Never invents spawning; an isolated recipe never delegates flow work. | +| Caller | Owns scope, delegation, integration, and acceptance. | +| Orchestrator | Sequences capabilities on the caller's behalf. | +| Agent | Isolates a scoped task and returns its result to the caller. Custom agents are optional, self-contained behaviors in one native file per host. | +| Recipe | Primary callers may delegate bounded work; isolated recipes return to their caller. | | Reconnaissance helper | Read-only; neither mutates nor spawns. | | Write path | At most two delegation layers; no cycles. | | SDLC | Owns planning; [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) govern execution, leaf executors, independent judgment and bounded repair. | @@ -130,7 +131,7 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Context | Rule | | --- | --- | -| Dispatch tables (`## Actions`), orchestration references and agent permissions (`# Skills you may invoke`) | Canonical `/plugin:folder` or `@plugin:agent` addresses. Elsewhere, name the responsibility. | +| Dispatch tables (`## Actions`) and orchestration references | Canonical `/plugin:folder` skill addresses. Delegate agent work by responsibility, without custom agent names. | | Cross-plugin providers | Recipes discover by description rather than hardcoding siblings; orchestrators verify installation before dispatch. | | Backlog flow | Owned by `aidd-orchestrator:02-backlog`. | | Orthogonality exceptions | `isExemptFromOrthogonality` permits responsibility maps in `plugins/aidd-orchestrator/**` and onboarding menus in `plugins/aidd-context/skills/00-onboard/**`. The onboarding exemption ends with runtime provider discovery. | diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md new file mode 100644 index 000000000..198f8d679 --- /dev/null +++ b/docs/SUBAGENTS.md @@ -0,0 +1,126 @@ +# 🤝 Optional subagents (beta) + +Four specialist agents your AI tool can hand work to, each in a fresh context and on a model sized for the job. Plugins work without them; you install them explicitly. + +## 📑 Contents + +- [How it works](#-how-it-works) +- [Which agent for which task](#-which-agent-for-which-task) +- [Install](#-install) +- [Models](#-models) +- [Update or remove](#-update-or-remove) +- [Limits](#-limits) + +## 🔀 How it works + +Your main session keeps the conversation and decides what to delegate. Each agent gets a scoped brief, works in its own context, and returns its result to the main session, which owns the final answer. + +```mermaid +sequenceDiagram + actor You + participant Main as Main session (main model) + participant Explorer as Explorer (light) + participant Executor as Executor (light) + participant Validator as Validator (light) + participant Reviewer as Reviewer (standard) + You->>Main: Request + Main->>Explorer: Where is it? How does it work? + Explorer-->>Main: Answer with sources + Main->>Executor: Build or fix this, with criteria + Executor-->>Main: Changes and checks + Main->>Validator: Does it meet the criteria? + Validator-->>Main: Validated yes or no + Main->>Reviewer: Is it clean and coherent? + Reviewer-->>Main: Findings + Main-->>You: Result +``` + +Each step is optional: the main session calls only the agents the task needs, and repeats execution when validation or review finds a gap. + +Delegation is by responsibility: workflows such as `/aidd-orchestrator:01-sdlc` ask for "a fresh validation agent", and the host picks one of these if installed, or a general-purpose agent otherwise. + +## 🧭 Which agent for which task + +| Agent | Use it to | It returns | Changes files | +| --- | --- | --- | --- | +| [Explorer](../cli/assets/agents/explorer.md) | Find files and symbols, trace a flow, answer a codebase question before a change | The answer, with sources and unknowns | No | +| [Executor](../cli/assets/agents/executor.md) | Write or fix code and docs within a defined scope, then verify them | `COMPLETE`, `INCOMPLETE` or `BLOCKED`, with changes and checks run | Yes | +| [Validator](../cli/assets/agents/validator.md) | Confirm delivered work meets the request and its acceptance criteria | `Validated: yes` or `no`, with what failed | No | +| [Reviewer](../cli/assets/agents/reviewer.md) | Find defects, contradictions, duplication and needless complexity in finished work | `no actionable findings`, `changes needed` or `insufficient evidence`, with findings | No | + +Typical order for a change: explore, execute, validate, then review. + +## 📦 Install + +The command is not released yet; build it from a framework checkout: + +```sh +pnpm --dir cli install --frozen-lockfile +pnpm --dir cli build +``` + +Then pick your IDE and provider: + +```sh +# In one project (from its root) +node /path/to/framework/cli/dist/cli.js agents install --ide claude-code --provider anthropic + +# For every project of this user +node /path/to/framework/cli/dist/cli.js agents install --ide claude-code --provider anthropic --scope user + +# Also set the main session model (optional) +node /path/to/framework/cli/dist/cli.js agents install --ide claude-code --provider anthropic --main +``` + +Open a new session afterwards so the IDE loads them. + +| IDE (`--ide`) | Providers | Project | `--scope user` | `--main` | +| --- | --- | --- | --- | --- | +| `claude-code` | anthropic | `.claude/agents/` | `~/.claude/agents/` | yes | +| `codex` | openai | `.codex/agents/` | `~/.codex/agents/` | yes | +| `opencode` | openai, anthropic, mistral | `.opencode/agents/` | `~/.config/opencode/agents/` | yes | +| `copilot` | openai, anthropic | `.github/agents/` | `~/.copilot/agents/` | no | +| `cursor` | anthropic | `.cursor/agents/` | no | no | + +User paths follow `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `COPILOT_HOME`. AIDD records what it wrote in `.aidd/agents.json` (user scope: AIDD's user config directory) and never overwrites a file you edited. + +## 🧠 Models + +Each role uses a profile; each provider sets the profile's model and effort in [agent-models.json](../cli/src/contexts/tools/domain/formats/agent-models.json). + +| Profile | Roles | Anthropic | OpenAI | Mistral (OpenCode) | +| --- | --- | --- | --- | --- | +| `main` | main session, with `--main` | Opus 5.5 | GPT-6.1 Sol | Large 4 | +| `standard` | reviewer | Sonnet 5.5 | GPT-6.1 Sol | Medium (`-latest`) | +| `light` | explorer, executor, validator | Haiku 5.5 | GPT-6 Luna | Small (`-latest`) | + +Implementation and validation run on `light` to save cost; change the role mapping in that file to trade cost for quality. + +`--main` writes only these keys and keeps the rest of the file: + +| IDE | File | Keys | +| --- | --- | --- | +| Claude Code | `.claude/settings.json` | `model`, `effortLevel` | +| Codex | `.codex/config.toml` | `model`, `model_reasoning_effort`; a selected `profile` still wins | +| OpenCode | `opencode.json` | `model` (OpenCode keeps no effort at that level); refused when `opencode.jsonc` holds the settings | + +It records the previous values in `.aidd/main-model.json` and restores them on removal; it refuses a value you changed since, and TOML it cannot edit safely. + +## 🔄 Update or remove + +- **Update:** rerun the same install command; unmodified agents are refreshed and retired roles removed. +- **Remove:** `agents remove --ide ` (add `--scope user` for user agents). It also restores the main model it replaced. +- **Clean:** `aidd clean` removes project agents with the rest of AIDD; user agents stay until `agents remove --scope user`. + +**Framework contributors:** copy [reload-agents.example.json](../scripts/reload-agents.example.json) beside it as the gitignored `reload-agents.json`. `make reload` then runs `agents install --scope user` for each listed IDE, adding `--main` where `"main": true`. The file only adds: undo with `agents remove --scope user --ide `. + +## 🚧 Limits + +- **Runtime model:** the files set model and effort; your plan or account decides what actually runs. A Copilot account limited to `auto` ignores the configured model with a warning. +- **Cursor user scope:** Cursor CLI 2026.10.19 loaded no agent from `~/.cursor/agents/`, so only project install is supported. Cursor agents are written with double quotes because Cursor keeps single quotes in names. +- **Codex:** project files load only for trusted projects; agents inherit the parent session's service tier, so Fast is enabled with `/fast` in the parent ([speed](https://learn.chatgpt.com/docs/agent-configuration/speed)). +- **Interrupted command:** check the files before deleting `.aidd/agents.json.lock`; a handled failure already restores files and registries. + +Loading checked on 2026-10-21 with `pnpm --dir cli test:e2e:agents`, which asks each installed host which agents it loaded: Claude Code, Codex, OpenCode, Copilot CLI 1.0.98 and Cursor CLI 2026.10.19 (project scope); Copilot also at user scope. + +Sources, checked 2026-10-16 to 2026-10-21: [Claude Code sub-agents](https://code.claude.com/docs/en/sub-agents) and [settings](https://code.claude.com/docs/en/settings), [Codex subagents](https://developers.openai.com/codex/subagents) and [config](https://developers.openai.com/codex/config-reference), [OpenCode agents](https://opencode.ai/docs/agents) and [models](https://opencode.ai/v2/docs/models#variants), [Copilot configuration directory](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference) and [model IDs](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference#supported-models), [Cursor subagents](https://cursor.com/docs/subagents), [Claude models](https://platform.claude.com/docs/en/models/overview), [OpenAI models](https://developers.openai.com/api/docs/models), [Mistral models](https://docs.mistral.ai/models), [Mistral Large 4](https://docs.mistral.ai/models/mistral-large-4-0), [Mistral reasoning](https://docs.mistral.ai/studio/conversations/reasoning). Mistral Large 4 and Medium 3.5 are public previews; `mistral-large-latest` still pointed to Large 3, without reasoning, so the main model is pinned. diff --git a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md index d126acad9..3cbd21754 100644 --- a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md +++ b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md @@ -31,9 +31,21 @@ - **Verify APIs, signatures, flags, and behavior against source or docs.** - **Ask one sharp question when ambiguity materially changes the scope or outcome.** - **Batch independent operations when it saves time or context.** -- **Fan out genuinely independent subtasks when coordination costs less than serial work.** - **Name by intention, scope by responsibility** ; not mechanism. +## Agents + +Fan out genuinely independent subtasks to available host agents by responsibility when coordination costs less than serial work; custom agents are optional. The caller owns scope, delegation, and final acceptance. + +| Responsibility | Agent when installed | +| --- | --- | +| Answer a scoped codebase question with sources | `aidd-dev-explorer` | +| Implement assigned work and verify it | `aidd-dev-executor` | +| Confirm the outcome meets its acceptance criteria | `aidd-dev-validator` | +| Review quality, coherence, and needless complexity | `aidd-dev-reviewer` | + +Without them, give the same brief to a general-purpose agent. + ## Memory Management Project docs, memory, specs, and plans live in `aidd_docs/`. diff --git a/plugins/aidd-context/skills/06-agent-generate/SKILL.md b/plugins/aidd-context/skills/06-agent-generate/SKILL.md index 0ed301d81..c10f3d3f8 100644 --- a/plugins/aidd-context/skills/06-agent-generate/SKILL.md +++ b/plugins/aidd-context/skills/06-agent-generate/SKILL.md @@ -6,7 +6,7 @@ argument-hint: role # Agent Generate -Write one canonical agent from intent and render it per confirmed host tool, or once as a plugin source. +Write one self-contained behavior per agent and render it as one file per confirmed host tool, or once as a plugin source. ## Actions diff --git a/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md b/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md index 45010c424..8cb856fb3 100644 --- a/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md +++ b/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md @@ -14,7 +14,7 @@ In-context: the role and its system prompt, the chosen name, an optional user-sp 1. **Gate.** Run the asset-access precheck ([tool-paths.md](../references/tool-paths.md)). 2. **Clarify.** Ask about the purpose, tools, inputs, and instructions. If anything stays vague, ask again before moving on. -3. **Draft.** Write the canonical role ([agent-authoring.md](../references/agent-authoring.md)): a frontmatter intent + a system-prompt body. Include only the optional and orchestration sections it needs. +3. **Draft.** Write the canonical role ([agent-authoring.md](../references/agent-authoring.md)): a frontmatter intent + a self-contained behavior, with inputs and output only when useful. Keep workflows in skills. 4. **Name.** Propose three short, catchy names that fit the purpose, and have the user pick one. 5. **Score.** Rate the agent 1-10 on clarity and completeness. If it scores under 8, revise and score again. 6. **Mode.** Ask where the agent goes: diff --git a/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md b/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md index 92c656e02..58cbe5b3b 100644 --- a/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md +++ b/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md @@ -8,7 +8,7 @@ From 01: the role, the chosen name, an optional user-specified model, and the wr ## Output -One agent file per confirmed tool, and the list of files written. +One self-contained agent file per confirmed tool, and the list of files written. ## Process @@ -16,7 +16,7 @@ One agent file per confirmed tool, and the list of files written. 2. **Frontmatter.** Apply the per-tool frontmatter from [tool-paths.md](../references/tool-paths.md). Drop a field where the tool does not support it. Plugin source: keep the canonical frontmatter. 3. **Convert.** Apply any per-tool structural conversion listed in [tool-paths.md](../references/tool-paths.md). Plugin source skips conversion. 4. **Render.** Per the write mode ([tool-paths.md](../references/tool-paths.md)): - - **Host**: for each confirmed tool, write to its path and extension. + - **Host**: for each confirmed tool, write one file at its path and extension; keep the full behavior in that file. - **Plugin source**: write one canonical agent. No per-tool fan-out. 5. **Validate.** Run the write-target validation ([tool-paths.md](../references/tool-paths.md)). diff --git a/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md b/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md index 9169f12c2..bfdd6cc6c 100644 --- a/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md +++ b/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md @@ -13,9 +13,9 @@ A short pass or fail line per agent file. ## Process 1. **Exists.** Confirm each file is on disk at its expected path. -2. **Shape.** Confirm each file matches its target format ([tool-paths.md](../references/tool-paths.md)), a confirmed tool's, or the canonical form in plugin source, and carries the name, description, and body. -3. **Concise.** Confirm the body stays focused on one role. +2. **Shape.** Confirm each file matches its target format ([tool-paths.md](../references/tool-paths.md)) and carries its required metadata and behavior. +3. **Concise.** Confirm the body stays focused on one behavior, contained in the agent file without a required skill or companion file. ## Test -- Every agent file exists, carries name and description, and holds the role body. +- Every agent file exists, identifies its role in the target format, and holds the behavior. diff --git a/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md b/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md index 6af2328ce..296cf316f 100644 --- a/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md +++ b/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md @@ -9,13 +9,9 @@ description: # required, third person # Behavior - + diff --git a/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md b/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md index 28fae750c..08977eb33 100644 --- a/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md +++ b/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md @@ -4,15 +4,15 @@ The contract every generated agent must satisfy. An agent is a focused role with ## Rules -- **R1.** One agent, one role. A single, clear responsibility. +- **R1.** One agent, one behavior, self-contained in one file per target. Workflows belong in skills; omit skill permission lists and required companion files. - **R2.** Frontmatter: `name` and `description` are required. `model` is optional. The description states what the agent does and when to use it. -- **R3.** The body is the system prompt. Required: `# Role` and `# Behavior` (the steps). Optional sections: see `## Body shape`. Concise and imperative. +- **R3.** The body is the system prompt. Required: `# Role` and `# Behavior` (bounded capabilities and constraints). Optional sections: see `## Body shape`. Concise and imperative. - **R4.** English only, regardless of conversation language. - **R5.** One idea per sentence. Split a sentence that runs past one line. Exceptions: table rows. ## Naming -- Short and catchy, a single word where possible: `executor`, `checker`, `auditor`. +- Short and catchy, a single word where possible: `executor`, `reviewer`, `auditor`. - Fits the role at a glance. Propose three options for the user to pick. ## Body shape @@ -21,8 +21,3 @@ Short imperative sentences. Optional sections, added only when they earn their p - `# Inputs`: what it receives. - `# Outputs`: what it returns. -- `# Guardrails`: what it must never do. -- `# Skills you may invoke`: name a same-plugin skill by its `plugin:folder` address, for deterministic resolution. Name a cross-plugin skill by its capability, discovered at runtime, never hardcoded. -- `# Definition of Ready` / `# Definition of Done`: start and finish gates. -- `# Decisions in scope` / `# Decisions out of scope`: what it may decide, what it must defer. -- `# Handoffs`: who it returns to. diff --git a/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md b/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md index 90a1badde..3f285991f 100644 --- a/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md @@ -14,17 +14,25 @@ The per-tool agent path and the gate every run executes before writing. Agent sl Agents are supported on all five tools. +User-level agents, for every project of the user: Claude Code `~/.claude/agents/` (`CLAUDE_CONFIG_DIR`), Codex `~/.codex/agents/` (`CODEX_HOME`), OpenCode `~/.config/opencode/agents/` (`XDG_CONFIG_HOME`), Copilot `~/.copilot/agents/.agent.md` (`COPILOT_HOME`). Cursor documents `~/.cursor/agents/`, but its CLI 2026.10.19 loaded none from there; prefer the project path. Checked 2026-10-21: [Claude](https://code.claude.com/docs/en/sub-agents), [Codex](https://developers.openai.com/codex/subagents), [OpenCode](https://opencode.ai/docs/agents), [Copilot](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference), [Cursor](https://cursor.com/docs/subagents). + +Cursor keeps single quotes as part of a frontmatter value: quote Cursor values with `"double quotes"` or not at all. + ## Frontmatter per tool -The canonical agent carries `name` and `description`. Emit those a row accepts, drop the rest. Optional fields, including `model`, only if the user asked. Never invent a value. +The canonical agent carries `name` and `description`. Emit those a row accepts, drop the rest. Add model or effort settings only when requested and supported. | Tool | Accepts | | -------------- | ----------------------------------------------------------- | -| Claude Code | `name`, `description`, optional `model`, `color`, `tools` | +| Claude Code | `name`, `description`, optional `model`, `effort`, `color`, `tools` | | Cursor | `name`, `description`, optional `model`, `readonly`, `is_background` | -| OpenCode | `name`, `description`, optional `model`, `temperature`, `permission` | -| GitHub Copilot | `name`, `description`, optional `model`, `tools` | -| Codex CLI | `name`, `description` (drops `model`) | +| OpenCode V2 | `description`, `mode: subagent`, optional `model` as `provider/model#variant`, `permissions` | +| GitHub Copilot | `name`, `description`, optional `model`, `reasoningEffort`, `tools`, `include-custom-instructions` | +| Codex CLI | `name`, `description`, optional `model`, `model_reasoning_effort` | + +For Copilot agents that rely on repository instructions, set `include-custom-instructions: true` unless the user explicitly opts out. + +Native fields checked 2026-10-16: [Claude](https://code.claude.com/docs/en/sub-agents#frontmatter-reference), [Cursor](https://cursor.com/docs/subagents#file-format), [OpenCode V2](https://opencode.ai/v2/docs/agents#model), [Copilot](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference#custom-agent-frontmatter-fields), [Codex](https://developers.openai.com/codex/subagents#custom-agent-file-schema). ## Codex TOML conversion @@ -32,7 +40,7 @@ Codex agents are TOML, not markdown. Convert: - Each frontmatter field becomes a top-level TOML key. Quote every string value with `"double quotes"` (a TOML basic string) and escape any embedded `"` or backslash, so a quote or apostrophe in the description stays valid TOML. - The body becomes `developer_instructions`, wrapped in `'''` literal delimiters (no escaping of the markdown). -- Drop `model`. +- Keep an explicitly requested `model` and `model_reasoning_effort`. ## Detect (which tools are installed) diff --git a/plugins/aidd-dev/.claude-plugin/plugin.json b/plugins/aidd-dev/.claude-plugin/plugin.json index 0c45deac0..077094b8d 100644 --- a/plugins/aidd-dev/.claude-plugin/plugin.json +++ b/plugins/aidd-dev/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "aidd-dev", "version": "2.7.0", - "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure. Hosts engineering agents.", + "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure.", "author": { "name": "AI-Driven Dev", "url": "https://github.com/ai-driven-dev" @@ -19,10 +19,6 @@ "./skills/09-for-sure", "./skills/10-batch" ], - "agents": [ - "./agents/executor.md", - "./agents/checker.md" - ], "keywords": [ "sdlc", "implementation", diff --git a/plugins/aidd-dev/CATALOG.md b/plugins/aidd-dev/CATALOG.md index 331adbca2..5e9a2992c 100644 --- a/plugins/aidd-dev/CATALOG.md +++ b/plugins/aidd-dev/CATALOG.md @@ -7,7 +7,6 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai ## Table of Contents - [`.claude-plugin`](#claude-plugin) -- [`agents`](#agents) - [`skills`](#skills) - [`skills/01-plan`](#skills01-plan) - [`skills/02-implement`](#skills02-implement) @@ -28,13 +27,6 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai |------| | [plugin.json](.claude-plugin/plugin.json) | -### `agents` - -| File | Description | -|------|---| -| [checker.md](agents/checker.md) | `Judges finished work against its validator and the real need, leaving nothing unchecked. Use when code or a deliverable needs independent verification before it ships. Never edits the work, never implements the fix.` | -| [executor.md](agents/executor.md) | `Turns a dispatched task into working, validated code that fits the project. Use when an approved scope must become code. Never plans, never judges its own work.` | - ### `skills` #### `skills/01-plan` @@ -59,7 +51,7 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai | `actions` | [02-execute.md](skills/02-implement/actions/02-execute.md) | - | | `actions` | [03-finalize.md](skills/02-implement/actions/03-finalize.md) | - | | `references` | [blocked.md](skills/02-implement/references/blocked.md) | `Conditions that make a plan blocked (needs a human).` | -| `-` | [SKILL.md](skills/02-implement/SKILL.md) | `Write an existing plan's code, phase by phase, until every acceptance criterion holds. Use when a plan exists and needs implementing. Do NOT use to write a plan, review a diff.` | +| `-` | [SKILL.md](skills/02-implement/SKILL.md) | `Implement an existing plan or assigned phase until its acceptance criteria hold. Use when a plan or bounded phase needs implementing. Not for writing a plan or reviewing a diff.` | #### `skills/03-assert` @@ -95,6 +87,7 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai | `actions` | [04-review-relevancy.md](skills/05-review/actions/04-review-relevancy.md) | - | | `actions` | [05-finalize.md](skills/05-review/actions/05-finalize.md) | - | | `assets` | [review-template.md](skills/05-review/assets/review-template.md) | - | +| `references` | [quality-checklist.md](skills/05-review/references/quality-checklist.md) | - | | `references` | [report-contract.md](skills/05-review/references/report-contract.md) | - | | `references` | [review-rubric.md](skills/05-review/references/review-rubric.md) | - | | `-` | [SKILL.md](skills/05-review/SKILL.md) | `Reviews a change or a diff and says whether it is ready to ship, naming every problem with its fix. Use when the user wants a change checked before it ships. Not for fixing what the review finds, and not for auditing a whole codebase.` | diff --git a/plugins/aidd-dev/README.md b/plugins/aidd-dev/README.md index 30e1d7250..cc7dfc763 100644 --- a/plugins/aidd-dev/README.md +++ b/plugins/aidd-dev/README.md @@ -8,7 +8,7 @@ Code transformation plugin for the AI-Driven Development framework. First time? Install with `/plugin install aidd-dev@aidd-framework`, then run `aidd-dev:01-plan`. -Covers code transformation: planning, implementation, assertions, audits, code review, testing, refactoring, debugging, for-sure, and parallel batch execution. Also hosts AI agents. +Covers code transformation: planning, implementation, assertions, audits, code review, testing, refactoring, debugging, for-sure, and parallel batch execution. ## Skills @@ -24,10 +24,3 @@ Covers code transformation: planning, implementation, assertions, audits, code r | [2.8] | [debug](skills/08-debug/SKILL.md) | Reproduce and fix bugs systematically using test-driven workflow, root cause analysis, and hypothesis validation. | | [2.9] | [for-sure](skills/09-for-sure/SKILL.md) | Iterative agent loop that tracks attempts and retries until a success condition is met. | | [2.10] | [batch](skills/10-batch/SKILL.md) | Split a request into independent tasks, execute them in parallel, and summarize each agent's task, status, and result. | - -## Agents - -| Agent | Description | -|---|---| -| executor | Dispatched doer in its own context. Turns a scoped task into working, validated code that fits the project, deciding how, never what. Never authors a plan, never judges its own work. | -| checker | Independent critic in fresh context. Judges finished work against its validator and the real need, leaving nothing unchecked. Returns findings, scores, and a verdict. Never edits the work, never implements the fix. | diff --git a/plugins/aidd-dev/agents/checker.md b/plugins/aidd-dev/agents/checker.md deleted file mode 100644 index 0122b6033..000000000 --- a/plugins/aidd-dev/agents/checker.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: checker -description: Judges finished work against its validator and the real need, leaving nothing unchecked. Use when code or a deliverable needs independent verification before it ships. Never edits the work, never implements the fix. ---- - -# Role - -You are the checker. Your job is to judge finished work against its validator and the real need, in a fresh context with no memory of how it was built, and to leave nothing unchecked. - -# Behavior - -- Build your validator stack first: the acceptance criteria and the need the work is meant to serve. Extend the checklist below with the project's own review checklist when it provides one. -- Judge each criterion: inspect, run validation commands when they exist, and mark it fulfilled, partial, or unfulfilled with evidence. -- Run the checklist on every code or diff, leaving no item unchecked. -- Then check the layer the reviews miss: does the delivered logic serve the actual need, end to end, even when code review and functional review both pass? Name any gap between intent and result. -- Demand command output or file evidence, never bare claims. Lean strict: a false alarm costs less than a missed defect. -- When a review skill fits the work, run it and let it write its report; that report is your deliverable and your judgment is what fills it. Never hand-write a parallel prose review beside it. -- Return your verdict, findings, and score on top. Hold yourself accountable for whatever you pass. - -# Checklist - -This is the behavioral baseline. Apply it to every code or diff, and extend it with the project's own checklist when one exists. - -- [ ] No information duplication. DRY across code and docs; link to the canonical home instead of copying. -- [ ] No incoherence or contradiction. Naming, behavior, and docs-versus-code stay consistent. -- [ ] No over-engineering. The simplest solution that meets the need; no speculative generality, no unused abstraction. -- [ ] No dead code or debug leftovers. No commented-out blocks, stray logs, or silent TODOs. - -# Scoring - -- If the validator defines weights and thresholds, apply them exactly, and let any hard violation force the score to zero. -- Otherwise score the proportion of fulfilled criteria, adjusted for the severity of the findings, with your reasoning. -- The pass threshold is the caller's gate, not yours. You report the score; you do not declare pass or fail. - -# Guardrails - -- Never edit the work or its validator. Never implement the fix. Never delegate to another agent. -- Never pass on vibes: tie every verdict to a stated criterion, a checklist item, or a named need-gap. -- Flag an ambiguous criterion instead of guessing. Do not go easy because the work looks impressive. - -# Skills you may invoke - -- `/aidd-dev:05-review` -- `/aidd-dev:04-audit` -- `/aidd-refine:02-challenge` diff --git a/plugins/aidd-dev/agents/executor.md b/plugins/aidd-dev/agents/executor.md deleted file mode 100644 index 10a4d6dfc..000000000 --- a/plugins/aidd-dev/agents/executor.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: executor -description: Turns a dispatched task into working, validated code that fits the project. Use when an approved scope must become code. Never plans, never judges its own work. ---- - -# Role - -You are the executor. Your job is to turn a dispatched task into working, validated code that fits the project. You decide how, never what. - -# Behavior - -- Honour the project's own conventions where it defines them, and match the code around you. Where it stays silent, follow the prevailing idiom. -- Internalize the acceptance criteria before writing anything. If the scope is ambiguous, surface it instead of guessing. -- Work in a tight loop: build a substep, validate it, repair on red, and only then move on. Validation passing is the gate, never your own say-so. -- When a validation failure has no clear cause, run `/aidd-dev:08-debug` before repairing and validating again. -- Commit per coherent unit, code and its status together, one unit one commit. -- When you finish or stall, return to whoever invoked you with what is done, what is left, and why. - -# Guardrails - -- Never author or edit a plan, spec, or acceptance criterion. That authority is the caller's. On any drift, stop and hand it back to be replanned. -- Stay strictly in the scope you are handed. Declare anything you bypass: no silent TODO, skipped test, or placeholder mock. -- Never judge your own work, and never start a review. The caller handles that. -- Never delegate to another agent. -- When the work is physically impossible for the AI, stop and say so plainly: a real payment, a human login, an unreadable secret, anything behind hardware or 2FA. Do not fake progress. - -# Skills you may invoke - -- `/aidd-dev:02-implement` -- `/aidd-dev:03-assert` -- `/aidd-dev:06-test` -- `/aidd-dev:08-debug` -- `/aidd-vcs:01-commit` diff --git a/plugins/aidd-dev/skills/01-plan/actions/02-explore.md b/plugins/aidd-dev/skills/01-plan/actions/02-explore.md index 33a6916d5..f431a5f45 100644 --- a/plugins/aidd-dev/skills/01-plan/actions/02-explore.md +++ b/plugins/aidd-dev/skills/01-plan/actions/02-explore.md @@ -12,7 +12,8 @@ The architecture projection (files to modify, create, delete, each with a one-li ## Process -1. **Read.** Explore the code the source touches. Build the projection and list the infrastructure assumptions. +1. **Read.** Explore what the source touches and no earlier exploration already answers. Build the projection and list the infrastructure assumptions. + - When useful, delegate independent domain questions to available agents with a scope and expected findings; reconcile their sourced answers and keep unresolved uncertainty visible. 2. **Check.** Verify against the official docs or the in-repo code. Keep each source and what it settled. Flag blockers and risks. 3. **Select.** Keep the project rules that apply to the projection. diff --git a/plugins/aidd-dev/skills/01-plan/actions/04-plan.md b/plugins/aidd-dev/skills/01-plan/actions/04-plan.md index e6f0677ce..f5b09280f 100644 --- a/plugins/aidd-dev/skills/01-plan/actions/04-plan.md +++ b/plugins/aidd-dev/skills/01-plan/actions/04-plan.md @@ -13,10 +13,11 @@ A feature folder, always at `aidd_docs/tasks//_/_ + +| # | Phase | File | Depends on | +| --- | ------------ | ---------------------------- | ---------- | +| 1 | {phase-name} | [`phase-1.md`](./phase-1.md) | {none} | +| 2 | {phase-name} | [`phase-2.md`](./phase-2.md) | {phase numbers or none} | ## Resources diff --git a/plugins/aidd-dev/skills/01-plan/references/plan-status.md b/plugins/aidd-dev/skills/01-plan/references/plan-status.md index 51e98bf1e..b1f017d6d 100644 --- a/plugins/aidd-dev/skills/01-plan/references/plan-status.md +++ b/plugins/aidd-dev/skills/01-plan/references/plan-status.md @@ -20,4 +20,4 @@ The plan's `status` frontmatter field tracks its lifecycle for kanban views. The - Linear: `pending → in-progress → implemented → reviewed`. `blocked` is reachable from any active state. - Review reject (`iterate` / `changes-requested`) does NOT set `reviewed`. The plan stays `implemented` while the loop fixes the diff, not the plan. - `implemented` ≠ reviewed. Only the review layer sets `reviewed`. -- Workers never write `status`; only plan creation and the orchestration layers do. +- Assigned phase workers update only their own phase status; plan creation and the whole-plan owner update plan status. diff --git a/plugins/aidd-dev/skills/02-implement/SKILL.md b/plugins/aidd-dev/skills/02-implement/SKILL.md index 2bfdd6e87..fbe8d3ba0 100644 --- a/plugins/aidd-dev/skills/02-implement/SKILL.md +++ b/plugins/aidd-dev/skills/02-implement/SKILL.md @@ -1,14 +1,17 @@ --- name: 02-implement -description: Write an existing plan's code, phase by phase, until every acceptance criterion holds. Use when a plan exists and needs implementing. Do NOT use to write a plan, review a diff. -argument-hint: plan +description: Implement an existing plan or assigned phase until its acceptance criteria hold. Use when a plan or bounded phase needs implementing. Not for writing a plan or reviewing a diff. +argument-hint: plan | phase --- # Skill: implement ```mermaid flowchart LR - prepare --> execute --> finalize --> implemented + whole([whole plan]) --> prepare --> execute + assigned([assigned phase]) --> execute + execute -->|whole plan complete| finalize --> implemented + execute -->|assigned phase complete| returned([return changes and evidence]) prepare -->|missing plan| stop execute -->|fix, next task or phase| execute execute --> blocked @@ -20,7 +23,7 @@ flowchart LR ## Actions -Run actions in order. +Run actions in order for a whole plan. An assigned-phase executor reads the plan context and `execute` only. Read each action file in `actions/` before executing it. | Action | Does | @@ -31,6 +34,8 @@ Read each action file in `actions/` before executing it. ## Transversal rules +- The caller owns delegation, integration, shared plan status, and final acceptance; phase executors own only their assigned files and phase status. + - Parallel phases need separate write ownership and validation resources; workers running Git get their own checkout and index, otherwise the caller owns Git. Run serially when isolation is uncertain. - Track the plan through `pending → in-progress → implemented` (or `blocked`). - Track phases through `pending → in-progress → done`. - Treat `in-progress` as a runtime marker. @@ -42,7 +47,7 @@ Read each action file in `actions/` before executing it. - Group tasks only when they cannot be validated separately. - Never split a task by step or file. - Exclude unrelated changes. -- Include `done` in the phase's last task commit. +- Commit `done` once the phase is validated. - Make a final commit for `implemented`. - Use project formatters or hooks. - Never format code manually. diff --git a/plugins/aidd-dev/skills/02-implement/actions/02-execute.md b/plugins/aidd-dev/skills/02-implement/actions/02-execute.md index 1587062d3..0c5147c68 100644 --- a/plugins/aidd-dev/skills/02-implement/actions/02-execute.md +++ b/plugins/aidd-dev/skills/02-implement/actions/02-execute.md @@ -2,7 +2,7 @@ ## Input -The prepared plan. +The prepared plan, or an assigned phase with plan context, dependencies, file ownership, checkout, validation resources, and commit policy. ## Output @@ -12,7 +12,8 @@ Or a `blocked` / `replan needed` report. ## Process -1. **Open.** Walk phases in order. +1. **Open.** Select assigned phases whose dependencies are done; use serial plan order when dependencies are undeclared. + - A declared prerequisite with no matching phase, contradictory dependencies, or unfinished work with no ready phase requires `replan needed`. - Set the current phase `status: in-progress`. - In a feature folder, read `phase-.md` beside `plan.md`. 2. **Code.** Build the next task or inseparable group against its acceptance criteria. @@ -22,7 +23,8 @@ Or a `blocked` / `replan needed` report. - Validate the full phase workflow. - Set the phase `status: done` on success. 4. **Complete.** Commit according to the commit policy. - - Repeat steps 2–4 for the remaining tasks. + - Repeat steps 2–4 for the remaining assigned tasks. + - Phase executors return changes and evidence to the caller. ## Rules @@ -30,14 +32,13 @@ Or a `blocked` / `replan needed` report. - Use the appropriate interface (browser, CLI, API…). - Check actual against expected behavior at every step. - Fix any mismatch. - - Restart from the beginning after a repair. + - Rerun the affected workflow after a repair. - Gate task commits and `done` on successful validation. - - Require a full successful run. - Require observable evidence for every step. - Report blocked validation. - Never count blocked validation as success. - Follow [blocked.md](../references/blocked.md) when implementation is blocked. - - Record the blocked plan according to the commit policy. + - Assigned executors report the blocker; the whole-plan owner records the blocked plan according to the commit policy. - Leave unfinished code uncommitted. - Stop if satisfying the acceptance criteria requires changing scope or requirements. - Report `replan needed: `. diff --git a/plugins/aidd-dev/skills/02-implement/actions/03-finalize.md b/plugins/aidd-dev/skills/02-implement/actions/03-finalize.md index d615ba9c3..bbbb6eb11 100644 --- a/plugins/aidd-dev/skills/02-implement/actions/03-finalize.md +++ b/plugins/aidd-dev/skills/02-implement/actions/03-finalize.md @@ -10,8 +10,9 @@ The validated plan marked `implemented`. ## Process -1. **Verify.** Run the plan's validation commands and tests. +1. **Verify.** Validate the source outcome and phase criteria together after integration, then run the plan's validation commands and tests. - Start the required runtime if needed. + - Run any required end-to-end journey last, before marking the plan implemented. 2. **Mark.** Set the plan `status: implemented`. - Commit according to the commit policy. @@ -20,7 +21,7 @@ The validated plan marked `implemented`. - Gate `implemented` on successful validation. - Require every phase `done`. - Require all validation commands and tests to pass. + - Require any final journey to pass on the verified implementation. - Repair validation failures before `implemented`. - - Rerun the affected workflow after a repair. - - Rerun the validation commands and tests. + - Rerun affected workflows, validation commands and tests, then any required final journey. - Follow Execute's validation, blocker and drift rules. diff --git a/plugins/aidd-dev/skills/02-implement/references/blocked.md b/plugins/aidd-dev/skills/02-implement/references/blocked.md index 505a33899..073b91902 100644 --- a/plugins/aidd-dev/skills/02-implement/references/blocked.md +++ b/plugins/aidd-dev/skills/02-implement/references/blocked.md @@ -5,6 +5,8 @@ description: Conditions that make a plan blocked (needs a human). # When a plan is blocked -`blocked` means implementation cannot proceed and only a human can unblock it. Stop, set `status: blocked`, and escalate. +`blocked` means implementation cannot proceed and only a human can unblock it. Stop and escalate to the whole-plan owner, who sets the plan `status: blocked`. Physically impossible for the AI (no retry helps): real credit-card payment; human login (Google OAuth, Apple Face/Touch ID); a secret the AI cannot read; anything behind hardware or 2FA. + +Outside the assigned change: a failure the change did not cause. diff --git a/plugins/aidd-dev/skills/05-review/SKILL.md b/plugins/aidd-dev/skills/05-review/SKILL.md index 8ee340517..746ab25e0 100644 --- a/plugins/aidd-dev/skills/05-review/SKILL.md +++ b/plugins/aidd-dev/skills/05-review/SKILL.md @@ -9,8 +9,8 @@ argument-hint: diff | plan ```mermaid flowchart LR all([all axes]) --> prepare - one([one axis named]) --> prepare - prepare --> review-code & review-functional & review-relevancy --> finalize + one([selected axes]) --> prepare + prepare -->|selected only| review-code & review-functional & review-relevancy --> finalize finalize --> approve([approve]) finalize --> changes([changes-requested]) finalize --> blocked([blocked]) diff --git a/plugins/aidd-dev/skills/05-review/actions/01-prepare.md b/plugins/aidd-dev/skills/05-review/actions/01-prepare.md index d5454a8b7..31c503329 100644 --- a/plugins/aidd-dev/skills/05-review/actions/01-prepare.md +++ b/plugins/aidd-dev/skills/05-review/actions/01-prepare.md @@ -13,7 +13,7 @@ A round appended to the report, and the diff the axes read. ## Process 1. **Resolve.** Resolve the diff and the report per [report-contract.md](../references/report-contract.md). -2. **Open.** Append a round from the template `report-contract.md` names, filling what is known before the axes run. +2. **Open.** Append a round from the template `report-contract.md` names, recording the selected axes and filling what is known before they run. 3. **Hand over.** Name the diff and the round the axes write into. ## Test @@ -22,4 +22,4 @@ A round appended to the report, and the diff the axes read. | --- | --- | | A fresh report | round 1 appended, its date as the start date | | A report holding two rounds | a third appended, the header counting 3, the first two unchanged | -| A run naming one axis | the round names that axis alone | +| A run naming axes | the round names and runs only those axes | diff --git a/plugins/aidd-dev/skills/05-review/actions/04-review-relevancy.md b/plugins/aidd-dev/skills/05-review/actions/04-review-relevancy.md index 9fb090b8b..fc0ecd872 100644 --- a/plugins/aidd-dev/skills/05-review/actions/04-review-relevancy.md +++ b/plugins/aidd-dev/skills/05-review/actions/04-review-relevancy.md @@ -13,10 +13,10 @@ The round's misfit findings, each with its lens as kind. ## Process 1. **Gather.** Capture the need, and read the project's declared rules, never assuming one. -2. **Flag.** Record each misfit under its lens, tied to a rule, a site or the need; a bare opinion is no finding. +2. **Flag.** Apply [quality-checklist.md](../references/quality-checklist.md); record each misfit under its lens, tied to a rule, a site or the need. - `fit`: drift from the real intent, end to end, not only the literal criteria. - `conform`: a violation of a declared rule, naming it. - - `rot`: duplication, over-engineering or incoherence, citing the site. + - `rot`: a quality-checklist issue, citing the site. ## Test diff --git a/plugins/aidd-dev/skills/05-review/references/quality-checklist.md b/plugins/aidd-dev/skills/05-review/references/quality-checklist.md new file mode 100644 index 000000000..657b142bd --- /dev/null +++ b/plugins/aidd-dev/skills/05-review/references/quality-checklist.md @@ -0,0 +1,8 @@ +# Quality checklist + +Apply relevant items alongside project review rules. + +- [ ] **Duplication:** each fact or responsibility has one canonical home. +- [ ] **Consistency:** components, names, documentation, and project conventions agree. +- [ ] **Simplicity:** complexity serves the need; abstractions have a current use. +- [ ] **Residue:** obsolete code, debug leftovers, silent TODOs, and placeholders are absent. diff --git a/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md b/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md index e5f274b24..fbb3f1212 100644 --- a/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md +++ b/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md @@ -49,7 +49,7 @@ status: pending ## Applicable rules - + | Tool | Name | Path | Why it applies | | ------ | ------ | ------------------------------------- | -------------- | diff --git a/plugins/aidd-dev/skills/10-batch/actions/01-batch.md b/plugins/aidd-dev/skills/10-batch/actions/01-batch.md index 825f87026..500ad7a91 100644 --- a/plugins/aidd-dev/skills/10-batch/actions/01-batch.md +++ b/plugins/aidd-dev/skills/10-batch/actions/01-batch.md @@ -17,20 +17,15 @@ One row per dispatched agent, identified by its name or ID. Status is `Done` (ta 1. **Read.** Take `prompt` from the arguments; if empty, ask the user. 2. **Split.** Divide the prompt into distinct, independent tasks. Inline, no agent. -3. **Launch.** Spawn one `executor` agent per task, all in parallel (one message, multiple Task calls). Track each agent's name or ID and assigned task. Each agent prompt mandates, in order: - ```markdown - 1. Refine the task first: run a non-interactive refine capability if one is available (discovered at runtime, never a hardcoded plugin name); otherwise restate the task clearly and resolve obvious ambiguity inline. Never block on the user. - 2. Implement the refined task. - 3. Return a one-line summary with status, outcome, changed files, and validation evidence; if blocked or failed, include the reason and remaining work. - ``` +3. **Launch.** Spawn one available implementation agent per task, all in parallel with fresh contexts. Supply each task's criteria, write ownership, checks, and commit policy. Isolate mutable validation resources and Git indexes; otherwise the caller owns shared operations. Track each agent's name or ID; require its outcome, changed files, validation evidence, and remaining work. 4. **Collect.** Wait for every dispatched agent to finish or report a blocker. Preserve blocked and failed outcomes; mark an agent that exits with an error as `Failed` even if it returns no summary. -5. **Report.** Print exactly one final table using the output columns, nothing else. Base each status and result on the collected evidence. +5. **Integrate.** Combine the changes and check affected interactions before accepting completed tasks; report integration failures on the affected rows. +6. **Report.** Print exactly one final table using the output columns, nothing else. Base each status and result on the collected evidence. ## Test - Every dispatched agent is one row in the table, including blocked or failed agents. - Each row identifies the agent and its assigned task, status, and result. - Agents were spawned in a single parallel batch. -- Each agent ran a refine step before implementing. - The final table follows collection of all agents' outcomes; `Done` requires completion and validation evidence. - No output besides the table. diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md b/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md index 9e5dad479..da2276f4f 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md @@ -8,9 +8,9 @@ argument-hint: request | interactive request ## Behavior -Decide the mode as `mode.md` says, then read only it and the current zone's reference. Verify that every named provider is installed before calling it. A zone that cannot proceed stops the run and says what it would take to resume. +Decide the mode as `mode.md` says, then read only it and the current zone's reference. A zone that cannot proceed stops the run and says what it would take to resume. A zone commits what it wrote through the commit skill before handing off. -Spawn specialized agents for isolated work. Parallelize independent work when it is faster. Give each agent one focused task that a smaller model can execute. Repeat the responsible zone when delegated work returns an actionable gap. +Use available host agents by responsibility, or a general-purpose agent with a scoped brief; custom agents are optional. Parallelize independent work when it is faster. Repeat the responsible zone when delegated work returns an actionable gap. ```mermaid --- diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/references/01-frame.md b/plugins/aidd-orchestrator/skills/01-sdlc/references/01-frame.md index 67761c333..b47b78703 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/references/01-frame.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/references/01-frame.md @@ -2,7 +2,7 @@ ## Behavior -Start from `$source` only. Resolve a ticket when the source references one. Use the source directly when it is planning-ready. Otherwise clarify only the intent that can change what is built, then formalize the missing contract requirements. Commit whatever this zone wrote before handing the resulting contract to Deliver. +Start from `$source` only. Resolve a ticket when the source references one. Use the source directly when it is planning-ready. Otherwise clarify only the intent that can change what is built, then write the spec, which is the contract. A source is planning-ready when it names the artifact to change, states the observable outcome, and leaves open no decision that changes what is built. diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md b/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md index 7e094d169..bd8c26ff2 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md @@ -2,9 +2,11 @@ ## Behavior -Turn the contract into a proportional plan, then give one executor the plan. The executor implements it and calls the validation skills, which own their checks and repair loops. Include architecture conformance whenever the project documents architecture. Run an end-to-end journey after every other validation when the plan calls for one, and none otherwise. +Turn the contract into a proportional plan, then give one executor the whole plan or delegate ready independent phases. For delegated phases, the caller owns preparation, dependencies, write isolation, integration, and finalization; the phase file is the executor's brief, and the same executor session resumes for its next phase. Integrate prerequisites before starting dependent phases. -Commit and push the validated work through the commit skill. Send only the clean committed candidate to Check. +The executor follows the implementation skill for its assigned scope and calls the validation skills, which own their checks and repair loops. Include architecture conformance whenever the project documents architecture. Phase executors return for integration. Finalize the integrated plan once, including any required end-to-end journey after other checks and before marking it implemented. + +Send only the clean committed candidate and its evidence to Check. ```mermaid --- @@ -20,10 +22,11 @@ flowchart TD subgraph ExecuteStage["Execute and validate the plan"] direction TB - Executor(["@aidd-dev:executor"]) + Executor(["Implementation agent"]) Implement["/aidd-dev:02-implement"] Assert["/aidd-dev:03-assert"] - Test["/aidd-dev:06-test"] + Integrate["Integrate phases"] + Finalize["Finalize integrated plan; /aidd-dev:06-test journey last when required"] Commit["/aidd-vcs:01-commit"] end @@ -35,13 +38,15 @@ flowchart TD Contract --> Plan Plan --> PlanArtifact - PlanArtifact --> Executor - Executor --> Implement + PlanArtifact -- "Whole plan, or ready independent phases." --> Executor + Executor -- "Assigned plan or phase." --> Implement Implement --> Assert - Assert -- "When no end-to-end journey is required, commit the result." --> Commit - Assert -- "When an end-to-end journey is required, run it last." --> Test - Test -- "After the journey succeeds, commit the result." --> Commit - Test -- "Return a failed journey for repair." --> Executor + Assert -- "Whole plan finalized." --> Commit + Assert -- "Assigned phase returned." --> Integrate + Integrate -- "More ready phases." --> Executor + Integrate --> Finalize + Finalize -- "Validation or journey failed; repair affected phase." --> Executor + Finalize --> Commit Commit --> CommittedCandidate CommittedCandidate --> Check @@ -50,7 +55,7 @@ flowchart TD classDef artifact fill:#DCFCE7,stroke:#16A34A,color:#14532D,stroke-width:2px classDef zone fill:#F1F5F9,stroke:#64748B,color:#0F172A,stroke-width:2px - class Plan,Implement,Assert,Test,Commit skill + class Plan,Implement,Assert,Finalize,Commit skill class Executor agent class Contract,PlanArtifact,CommittedCandidate artifact class Check zone diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md b/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md index 9603ab221..0f8617aa1 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md @@ -2,12 +2,13 @@ ## Behavior -- Use one fresh checker, independent from implementation, to review the candidate against the contract, plan, and validation evidence. -- After the review clears, the same checker challenges whether the real outcome is trustworthy and serves the user. +- **Validate the committed candidate** against the contract, criteria, and mandatory checks using a fresh agent. +- **Review quality and coherence** after validation passes: a fresh agent runs `/aidd-dev:05-review` on the code and relevancy axes with the acceptance evidence. +- **Challenge the outcome** with another fresh agent after review clears, not the one that sequenced delivery: verify that it is trustworthy and serves the user. ## Findings -A checker's finding is routed by what it is about. A refusal the user gives at a pause is not one of these: `mode.md` routes that by artifact. +A review or validation finding is routed by what it is about. A refusal the user gives at a pause is not one of these: `mode.md` routes that by artifact. - Product or contract findings are the next Frame source. - Independent implementation findings go through Batch; dependent repairs stay together in Deliver. @@ -17,7 +18,7 @@ A checker's finding is routed by what it is about. A refusal the user gives at a ## Handoff -- Commit whatever this zone wrote, then open the draft pull request when the checker returns no actionable finding. +- Open the draft pull request when no actionable finding remains. ```mermaid --- @@ -30,7 +31,8 @@ flowchart TD Plan["$plan"] CommittedCandidate["$committed_candidate"] ValidationReports["$validation_reports"] - Checker(["@aidd-dev:checker"]) + Validator(["Validation agent"]) + Reviewer(["Review agent"]) Review["/aidd-dev:05-review"] end @@ -53,11 +55,13 @@ flowchart TD PullRequestUrl["$pull_request_url"] end - Contract --> Checker - Plan --> Checker - CommittedCandidate --> Checker - ValidationReports --> Checker - Checker --> Review + Contract --> Validator + Plan --> Validator + CommittedCandidate --> Validator + ValidationReports --> Validator + Validator -- "All criteria pass with evidence." --> Reviewer + Validator -- "Fail or blocked." --> Findings + Reviewer -- "Runs code and relevancy axes." --> Review Review -- "Return actionable review findings." --> Findings Review -- "When the review clears, challenge the outcome." --> Challenge Challenge -- "Return actionable challenge findings." --> Findings @@ -74,7 +78,7 @@ flowchart TD classDef zone fill:#F1F5F9,stroke:#64748B,color:#0F172A,stroke-width:2px class Review,Challenge,Batch,PullRequest skill - class Checker agent + class Reviewer,Validator agent class Contract,Plan,CommittedCandidate,ValidationReports,Findings,PullRequestUrl artifact class Frame,Deliver zone ``` diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/references/mode.md b/plugins/aidd-orchestrator/skills/01-sdlc/references/mode.md index 716ac6fe7..08d840f44 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/references/mode.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/references/mode.md @@ -4,7 +4,7 @@ - The mode is `auto` unless the caller asks for `interactive`, named as the request's first whole word and no part of the source. - A request carrying nothing else is not one to run: say so and stop. -- Ask only before spending money, taking an irreversible action, or making a decision that requires user authority. +- Ask only before spending money, taking an irreversible action such as deleting a branch, or making a decision that requires user authority. - An exception stops the action it guards, not the rest of the request. Say what was withheld and what would release it. ## Auto diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 01a42e0ea..5e30caf6d 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -1,17 +1,169 @@ const assert = require("node:assert/strict"); const { spawnSync } = require("node:child_process"); -const { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } = require("node:fs"); +const { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } = require("node:fs"); const { tmpdir } = require("node:os"); -const { delimiter, join, resolve } = require("node:path"); +const { delimiter, dirname, join, resolve } = require("node:path"); const test = require("node:test"); const repo = resolve(__dirname, "../.."); +const { render, sync } = require("../sync-global-instructions.cjs"); + +const instructionSource = "# AGENTS.md\n\n## Action\n- Keep scope.\n- Follow `docs/ARCHITECTURE.md`.\n\n## Agents\nUse available agents when useful. The caller owns scope and acceptance.\n\n## Memory Management\nProject docs live in aidd_docs/.\n\n@aidd_docs/memory/private.md\n\nLoad more memory.\n"; + +test("global instructions copy generic delegation guidance without project context", () => { + for (const host of ["codex", "claude", "opencode", "copilot", "cursor"]) { + const content = render(instructionSource, host); + assert.match(content, /Use available agents when useful/); + assert.doesNotMatch(content, /@aidd/); + assert.doesNotMatch(content, /Memory|memory|aidd_docs|ARCHITECTURE/); + assert.doesNotMatch(content, /Keep scope|## Action|## Behavior|## Communication/); + } +}); + +test("global sync preserves personal rules, updates its block, and is idempotent", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-rules-")); + try { + const source = join(root, "source.md"); + const destination = join(root, "codex/AGENTS.md"); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(source, instructionSource); + const personal = "Personal rules with no trailing newline"; + writeFileSync(destination, personal); + sync(source, [["codex", destination]]); + const first = readFileSync(destination, "utf8"); + sync(source, [["codex", destination]]); + assert.equal(readFileSync(destination, "utf8"), first); + writeFileSync(source, instructionSource.replace("Use available agents when useful.", "Updated delegation guidance.")); + sync(source, [["codex", destination]]); + const updated = readFileSync(destination, "utf8"); + assert.ok(updated.startsWith(personal)); + assert.match(updated, /Updated delegation guidance/); + assert.doesNotMatch(updated, /Use available agents when useful\./); + assert.equal(updated.split("aidd_global_instructions:start").length, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("global sync replaces legacy duplicated instructions without changing personal text", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-migration-")); + try { + const source = join(root, "source.md"); + const destination = join(root, "AGENTS.md"); + const prefix = "## Behavior\nMy personal rules.\n\n"; + const suffix = "\n\nMy private closing note.\n"; + writeFileSync(source, instructionSource); + writeFileSync(destination, prefix + "\n## Behavior\nLegacy duplicated rules.\n## Communication\nLegacy communication.\n" + suffix); + sync(source, [["codex", destination]]); + const migrated = readFileSync(destination, "utf8"); + assert.ok(migrated.startsWith(prefix)); + assert.ok(migrated.endsWith(suffix)); + assert.doesNotMatch(migrated, /Legacy|Keep scope|## Communication|## Action/); + assert.equal(migrated.match(/^## Behavior$/gm).length, 1); + assert.match(migrated, /Use available agents when useful/); + sync(source, [["codex", destination]]); + assert.equal(readFileSync(destination, "utf8"), migrated); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("global agent extraction works without project memory and stops at the next section", () => { + const source = "## Agents\nUse available agents when useful.\n\n## Project rules\nPrivate project rules.\n"; + assert.match(render(source, "claude"), /Use available agents when useful/); + assert.doesNotMatch(render(source, "claude"), /Project rules|Private project/); + assert.match(render(source.split("\n## Project rules")[0], "codex"), /Use available agents when useful/); +}); + +test("first OpenCode sync retains effective Claude personal rules without its managed block", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-fallback-")); + try { + const source = join(root, "source.md"); + const claude = join(root, "CLAUDE.md"); + const opencode = join(root, "opencode/AGENTS.md"); + writeFileSync(source, instructionSource); + writeFileSync(claude, "Claude personal rules.\n"); + sync(source, [["claude", claude]]); + sync(source, [["opencode", opencode]], claude); + const installed = readFileSync(opencode, "utf8"); + assert.match(installed, /Claude personal rules/); + assert.match(installed, /Use available agents when useful/); + assert.doesNotMatch(installed, /@aidd/); + writeFileSync(claude, "Changed fallback.\n"); + sync(source, [["opencode", opencode]], claude); + assert.equal(readFileSync(opencode, "utf8"), installed); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("malformed managed blocks and missing agent sections leave all global files untouched", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-invalid-")); + try { + const source = join(root, "source.md"); + const codex = join(root, "AGENTS.md"); + const claude = join(root, "CLAUDE.md"); + writeFileSync(source, instructionSource); + writeFileSync(codex, "Keep Codex.\n"); + for (const malformed of ["", "", "".repeat(2)]) { + writeFileSync(claude, malformed); + assert.throws(() => sync(source, [["codex", codex], ["claude", claude]]), /Malformed/); + assert.equal(readFileSync(codex, "utf8"), "Keep Codex.\n"); + assert.equal(readFileSync(claude, "utf8"), malformed); + } + writeFileSync(source, "Unbounded context.\n"); + assert.throws(() => sync(source, [["codex", codex]]), /Agents section/); + assert.equal(readFileSync(codex, "utf8"), "Keep Codex.\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Copilot and Cursor global instructions retain personal rules and native rule activation", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-native-rules-")); + try { + const source = join(root, "source.md"), copilot = join(root, "copilot-instructions.md"), cursor = join(root, "aidd-agents.mdc"); + writeFileSync(source, instructionSource); + writeFileSync(copilot, "Copilot personal rule.\n"); + sync(source, [["copilot", copilot], ["cursor", cursor]]); + assert.match(readFileSync(copilot, "utf8"), /^Copilot personal rule\.\n/); + const rule = readFileSync(cursor, "utf8"); + assert.match(rule, /^---\nalwaysApply: true\n---\n/); + for (const file of [copilot, cursor]) { + assert.match(readFileSync(file, "utf8"), /Use available agents when useful/); + assert.doesNotMatch(readFileSync(file, "utf8"), /aidd_docs|Memory|ARCHITECTURE/); + } + writeFileSync(cursor, rule + "\nPersonal closing rule.\n"); + sync(source, [["copilot", copilot], ["cursor", cursor]]); + const updated = readFileSync(cursor, "utf8"); + assert.match(updated, /^---\nalwaysApply: true\n---\n/); + assert.ok(updated.endsWith("Personal closing rule.\n")); + sync(source, [["cursor", cursor]]); + assert.equal(readFileSync(cursor, "utf8"), updated); + } finally { rmSync(root, { recursive: true, force: true }); } +}); function executable(path, body) { writeFileSync(path, `#!/bin/sh\nset -eu\n${body}\n`); chmodSync(path, 0o755); } +function fixture(home, plugins = ["aidd-dev"]) { + const source = join(home, "framework"); + if (!existsSync(source)) { + for (const [plugin, skill] of [["aidd-dev", "01-plan"], ["aidd-pm", "03-prd"]]) { + if (!plugins.includes(plugin)) continue; + const pluginDir = join(source, "plugins", plugin); + mkdirSync(join(pluginDir, ".claude-plugin"), { recursive: true }); + writeFileSync(join(pluginDir, ".claude-plugin/plugin.json"), "{}\n"); + mkdirSync(join(pluginDir, "skills", skill), { recursive: true }); + writeFileSync(join(pluginDir, "skills", skill, "SKILL.md"), "source skill\n"); + } + writeFileSync(join(source, "AGENTS.md"), instructionSource); + } + return source; +} + /** 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 @@ -19,22 +171,48 @@ function executable(path, body) { const BASE_PATH = process.platform === "win32" ? (process.env.PATH ?? "") : ["/usr/bin", "/bin"].join(delimiter); -function runSync(fakeBin, home, args = ["all"]) { +function runSync(fakeBin, home, args = ["all"], overrides = {}) { + executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); // `bash`, resolved on PATH, never the literal `/bin/bash`: that path does not exist on // Windows, where bash is the one Git for Windows ships. `spawnSync` resolves a bare name // through the platform's own rules on both. return spawnSync("bash", [join(repo, "scripts/dev-sync.sh"), ...args], { - cwd: repo, + cwd: home, env: { ...process.env, PATH: [fakeBin, BASE_PATH].join(delimiter), HOME: home, + FW: fixture(home), BUILD: join(home, "build"), + CODEX_CACHE: join(home, ".codex/plugins/cache"), + CLAUDE_CACHE: join(home, ".claude/plugins/cache"), + OPENCODE_SKILLS: join(home, ".config/opencode/skills"), + CODEX_INSTRUCTIONS: join(home, ".codex/AGENTS.md"), + CLAUDE_INSTRUCTIONS: join(home, ".claude/CLAUDE.md"), + OPENCODE_INSTRUCTIONS: join(home, ".config/opencode/AGENTS.md"), + COPILOT_INSTRUCTIONS: join(home, ".copilot/copilot-instructions.md"), + CURSOR_INSTRUCTIONS: join(home, ".cursor/rules/aidd-agents.mdc"), + CURSOR_PLUGINS: join(home, ".cursor/plugins/local"), + OPENCODE_DISABLE_CLAUDE_CODE: "", + OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "", + ...overrides, }, encoding: "utf8", }); } +test("reload fixtures run from their temporary home", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cwd-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "aidd-opencode-reload"), "node -e 'process.stdout.write(process.cwd())' > \"$HOME/observed-cwd\""); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(realpathSync(readFileSync(join(home, "observed-cwd"), "utf8").trim()), realpathSync(home)); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("managed OpenCode reload uses the fixed-purpose helper without a local build", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-")); try { @@ -48,39 +226,707 @@ test("managed OpenCode reload uses the fixed-purpose helper without a local buil assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /reload opencode\s+managed:ok/); assert.equal(existsSync(join(home, "build")), false); + assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("local OpenCode reload installs only the selected plugin skills globally", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); +for (const flag of ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"]) { + test(`OpenCode first reload respects disabled Claude fallback via ${flag}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-fallback-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync(join(home, ".claude/CLAUDE.md"), "Excluded Claude personal rules.\n"); + executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + const result = runSync(fakeBin, home, [], { [flag]: "1" }); + assert.equal(result.status, 0, result.stderr); + const installed = readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"); + assert.doesNotMatch(installed, /Excluded Claude/); + assert.match(installed, /Use available agents when useful/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("reload delivers global instructions to all three hosts while preserving personal rules", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-rules-")); try { const fakeBin = join(root, "bin"); const home = join(root, "home"); mkdirSync(fakeBin); mkdirSync(home); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev-stale"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev-stale/SKILL.md"), "stale\n"); + const source = fixture(home, ["aidd-pm"]); + writeFileSync(join(source, "AGENTS.md"), instructionSource); + const destinations = [".codex/AGENTS.md", ".claude/CLAUDE.md", ".config/opencode/AGENTS.md"]; + for (const path of destinations) { + mkdirSync(dirname(join(home, path)), { recursive: true }); + writeFileSync(join(home, path), "Personal rules.\n"); + } + for (const host of ["codex", "claude", "opencode"]) executable(join(fakeBin, host), "exit 0"); + executable(join(fakeBin, "npx"), String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--out" ]; then shift; out=$1; fi + shift +done +mkdir -p "$out/.opencode/skills/aidd-pm-03-prd" +printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-pm-03-prd/SKILL.md"`); + const first = runSync(fakeBin, home, []); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /global instructions\s+ok/); + const contents = destinations.map((path) => { + const content = readFileSync(join(home, path), "utf8"); + assert.ok(content.startsWith("Personal rules.\n")); + assert.match(content, /Use available agents when useful/); + assert.doesNotMatch(content, /@aidd/); + assert.doesNotMatch(content, /Memory|aidd_docs|ARCHITECTURE/); + return content; + }); + const second = runSync(fakeBin, home, []); + assert.equal(second.status, 0, second.stderr); + destinations.forEach((path, index) => assert.equal(readFileSync(join(home, path), "utf8"), contents[index])); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +for (const layout of ["flat", "nested"]) { + test(`local OpenCode reload refreshes the framework namespace using ${layout} output`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(home); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev-stale"), { recursive: true }); + writeFileSync(join(home, ".config/opencode/skills/aidd-dev-stale/SKILL.md"), "stale\n"); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev/removed"), { recursive: true }); + writeFileSync(join(home, ".config/opencode/skills/aidd-dev/removed/SKILL.md"), "stale\n"); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev/01-plan"), { recursive: true }); + writeFileSync(join(home, ".config/opencode/skills/aidd-dev/01-plan/SKILL.md"), "old\n"); + const unrelated = join(home, ".config/opencode/skills/aidd-pm/03-prd/SKILL.md"); + mkdirSync(join(home, ".config/opencode/skills/aidd-pm/03-prd"), { recursive: true }); + writeFileSync(unrelated, "keep\n"); + const agentDir = join(home, ".config/opencode/agents"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync(join(agentDir, "aidd-dev-checker.md"), "old agent\n"); + writeFileSync(join(agentDir, "aidd-dev-removed.md"), "stale\n"); + writeFileSync(join(agentDir, "personal.md"), "keep\n"); + writeFileSync(join(agentDir, "aidd-pm-review.md"), "keep\n"); + executable(join(fakeBin, "opencode"), "exit 0"); + const skillName = layout === "nested" ? "aidd-dev/01-plan" : "aidd-dev-01-plan"; + executable( + join(fakeBin, "npx"), + String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--out" ]; then shift; out=$1; fi + shift +done +skill="$out/.opencode/skills/${skillName}" +mkdir -p "$skill/actions" "$skill/assets" +printf '%s\n' '---' 'name: aidd-dev-01-plan' 'description: test' '---' > "$skill/SKILL.md" +printf '%s\n' 'updated action' > "$skill/actions/04-plan.md" +printf '%s\n' 'updated template' > "$skill/assets/plan-template.md" +`, + ); + + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + const installed = join(home, ".config/opencode/skills", skillName); + assert.match(readFileSync(join(installed, "SKILL.md"), "utf8"), /name: aidd-dev-01-plan/); + assert.equal(readFileSync(join(installed, "actions/04-plan.md"), "utf8"), "updated action\n"); + assert.equal(readFileSync(join(installed, "assets/plan-template.md"), "utf8"), "updated template\n"); + assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev-stale")), false); + assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev/removed")), false); + assert.equal(readFileSync(unrelated, "utf8"), "keep\n"); + assert.match(result.stdout, /install opencode\s+skills:ok/); + assert.equal(readFileSync(join(agentDir, "aidd-dev-checker.md"), "utf8"), "old agent\n"); + assert.equal(readFileSync(join(agentDir, "aidd-dev-removed.md"), "utf8"), "stale\n"); + assert.equal(existsSync(join(agentDir, "aidd-dev-executor.md")), false); + assert.equal(readFileSync(join(agentDir, "personal.md"), "utf8"), "keep\n"); + assert.equal(readFileSync(join(agentDir, "aidd-pm-review.md"), "utf8"), "keep\n"); + + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("local OpenCode reload rejects missing framework skills before removing installed ones", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-missing-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + const installed = join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev-01-plan"), { recursive: true }); + writeFileSync(installed, "keep\n"); executable(join(fakeBin, "opencode"), "exit 0"); - executable( - join(fakeBin, "npx"), - String.raw`out="" + executable(join(fakeBin, "npx"), "exit 0"); + + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built OpenCode skill for aidd-dev-01-plan/); + assert.doesNotMatch(result.stdout, /skills:ok/); + assert.equal(readFileSync(installed, "utf8"), "keep\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("default OpenCode reload installs every plugin's skills without agents", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-all-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(home); + fixture(home, ["aidd-dev", "aidd-pm"]); + const skills = ["aidd-dev-01-plan", "aidd-pm-03-prd"]; + const buildSkills = skills.map((name) => `mkdir -p "$out/.opencode/skills/${name}"\nprintf '%s\\n' 'skill' > "$out/.opencode/skills/${name}/SKILL.md"`).join("\n"); + executable(join(fakeBin, "opencode"), "exit 0"); + executable(join(fakeBin, "npx"), `out="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift; out=$1; fi shift done -mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" -printf '%s\n' '---' 'name: aidd-dev-01-plan' 'description: test' '---' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md"`, - ); +${buildSkills}`); - const result = runSync(fakeBin, home, ["aidd-dev"]); + const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - const installed = join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"); - assert.match(readFileSync(installed, "utf8"), /name: aidd-dev-01-plan/); - assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev-stale")), false); - assert.match(result.stdout, /install opencode\s+skills:ok/); + for (const skill of skills) { + assert.equal(readFileSync(join(home, ".config/opencode/skills", `${skill}/SKILL.md`), "utf8"), "skill\n"); + } + assert.equal(existsSync(join(home, ".config/opencode/agents")), false); + assert.match(result.stdout, /skills:ok/); } finally { rmSync(root, { recursive: true, force: true }); } }); + +for (const badOutput of ["missing", "empty"]) { + test(`OpenCode rejects ${badOutput} expected skill output despite another built skill`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-partial-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + const source = fixture(home); + const second = join(source, "plugins/aidd-dev/skills/02-implement"); + mkdirSync(second); + writeFileSync(join(second, "SKILL.md"), "source skill\n"); + const installed = join(home, ".config/opencode/skills/aidd-dev-02-implement"); + mkdirSync(installed, { recursive: true }); + writeFileSync(join(installed, "SKILL.md"), "keep\n"); + executable(join(fakeBin, "opencode"), "exit 0"); + executable(join(fakeBin, "npx"), String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--out" ]; then shift; out=$1; fi + shift +done +mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" +printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" +${badOutput === "empty" ? 'mkdir -p "$out/.opencode/skills/aidd-dev-02-implement"; touch "$out/.opencode/skills/aidd-dev-02-implement/SKILL.md"' : ""}`); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built OpenCode skill for aidd-dev-02-implement/); + assert.equal(readFileSync(join(installed, "SKILL.md"), "utf8"), "keep\n"); + assert.doesNotMatch(result.stdout, /skills:ok|Done\./); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +for (const host of ["opencode", "codex", "copilot", "cursor"]) { + test(`reload fails when the installed ${host} host's build fails`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-build-failed-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, host), "exit 0"); + executable(join(fakeBin, "npx"), "exit 7"); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`${host} build failed`)); + assert.doesNotMatch(result.stdout, /Done\./); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +for (const host of ["codex", "claude", "copilot"]) { + test(`reload fails when the installed ${host} host rejects a plugin`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-install-failed-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + fixture(home, ["aidd-pm"]); + executable(join(fakeBin, host), `if [ "$1 $2" = 'plugin marketplace' ] && [ "$3" = list ]; then echo '[]'; fi\nif [ "$1" = plugin ] && [ "$2" = ${host === "codex" ? "add" : "install"} ]; then exit 8; fi\nexit 0`); + executable(join(fakeBin, "npx"), host === "copilot" ? nativeBuildStub() : "exit 0"); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /plugin install failed for aidd-pm/); + assert.doesNotMatch(result.stdout, /Done\./); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +function nativeBuildStub(omitSkill = false) { + return String.raw`out=""; tool="" +while [ "$#" -gt 0 ]; do + case "$1" in --out) shift; out=$1 ;; --to) shift; tool=$1 ;; esac + shift +done +for source in "$FW/plugins/"*/; do + plugin="$(basename "$source")" + target="$out/plugins/$plugin" + manifest=".$tool-plugin" + [ "$tool" != copilot ] || manifest=".plugin" + mkdir -p "$target/$manifest" "$target/skills" + printf '{"name":"%s"}\n' "$plugin" > "$target/$manifest/plugin.json" + ${omitSkill ? ":" : 'cp -R "$FW/plugins/$plugin/skills/." "$target/skills/"'} +done`; +} + +for (const binary of ["cursor-agent", "agent", "cursor"]) { + test(`Cursor reload through ${binary} installs every built plugin and preserves personal content`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + fixture(home, ["aidd-dev", "aidd-pm"]); + const personal = [".cursor/agents/executor.md", ".cursor/rules/personal.mdc", ".cursor/mcp.json", ".cursor/plugins/local/personal/notes.md"]; + for (const file of personal) { + mkdirSync(dirname(join(home, file)), { recursive: true }); + writeFileSync(join(home, file), "personal bytes\n"); + } + const stale = join(home, ".cursor/plugins/local/aidd-dev/agents/removed.md"); + mkdirSync(dirname(stale), { recursive: true }); + writeFileSync(stale, "retired role\n"); + executable(join(fakeBin, binary), "echo 'Start the Cursor Agent'"); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + for (const [plugin, skill] of [["aidd-dev", "01-plan"], ["aidd-pm", "03-prd"]]) { + assert.equal(readFileSync(join(home, `.cursor/plugins/local/${plugin}/skills/${skill}/SKILL.md`), "utf8"), "source skill\n"); + } + assert.equal(existsSync(join(home, ".cursor/plugins/local/aidd-dev/agents")), false); + assert.match(readFileSync(join(home, ".cursor/rules/aidd-agents.mdc"), "utf8"), /Use available agents when useful/); + assert.equal(existsSync(stale), false); + for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); + const first = readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"); + assert.equal(runSync(fakeBin, home, []).status, 0); + assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), first); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("Cursor reload rejects incomplete native output before changing installed plugins", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-incomplete-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const installed = join(home, ".cursor/plugins/local/aidd-dev/agents/executor.md"); + mkdirSync(dirname(installed), { recursive: true }); + writeFileSync(installed, "old role\n"); + executable(join(fakeBin, "cursor-agent"), "exit 0"); + executable(join(fakeBin, "npx"), nativeBuildStub(true)); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built cursor skill/); + assert.equal(readFileSync(installed, "utf8"), "old role\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("another tool named agent does not trigger Cursor installation", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-other-agent-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "agent"), "echo 'Grok Build TUI'"); + executable(join(fakeBin, "npx"), "exit 9"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(home, "build/cursor")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +const copilotMarketplaceList = String.raw`node -e 'process.stdout.write(JSON.stringify([{name:"aidd-framework",source:"Local: "+process.argv[1]}]))'`; +const copilotStub = String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" +case "$1 $2 $3" in + 'plugin marketplace list') + if [ -f "$HOME/copilot-source" ]; then + ${copilotMarketplaceList} "$(cat "$HOME/copilot-source")" + else echo '[]'; fi ;; + 'plugin marketplace add') + if [ -f "$HOME/copilot-source" ]; then echo 'Marketplace "aidd-framework" already registered' >&2; exit 1; fi + printf '%s' "$4" > "$HOME/copilot-source" ;; + 'plugin marketplace remove') rm "$HOME/copilot-source" ;; + 'plugin marketplace update') : ;; +esac`; + +test("Copilot fixture serializes Windows paths and quotes as JSON", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-json-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); mkdirSync(home); + const source = String.raw`D:\Work\Example "quoted"\Framework`; + writeFileSync(join(home, "copilot-source"), source); + executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); + const binary = join(fakeBin, "copilot"); + executable(binary, copilotStub); + const result = spawnSync("bash", [binary, "plugin", "marketplace", "list", "--json"], { + cwd: home, + env: { ...process.env, HOME: home, PATH: [fakeBin, BASE_PATH].join(delimiter) }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), [{ name: "aidd-framework", source: `Local: ${source}` }]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot reload builds all native plugins, migrates its own source, and preserves personal files", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); mkdirSync(home); + const source = fixture(home, ["aidd-dev", "aidd-pm"]); + writeFileSync(join(home, "copilot-source"), source); + const personal = [".copilot/agents/executor.agent.md", ".copilot/instructions/personal.instructions.md", ".copilot/settings.json"]; + for (const file of personal) { + mkdirSync(dirname(join(home, file)), { recursive: true }); + writeFileSync(join(home, file), "personal bytes\n"); + } + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const first = runSync(fakeBin, home, []); + assert.equal(first.status, 0, first.stderr); + assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); + for (const plugin of ["aidd-dev", "aidd-pm"]) { + assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), new RegExp(`plugin install ${plugin}@aidd-framework`)); + assert.equal(JSON.parse(readFileSync(join(home, `build/copilot/plugins/${plugin}/.plugin/plugin.json`), "utf8")).name, plugin); + } + assert.match(readFileSync(join(home, ".copilot/copilot-instructions.md"), "utf8"), /Use available agents when useful/); + assert.equal(runSync(fakeBin, home, []).status, 0); + assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace update aidd-framework/); + for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot refuses another source registered under the same marketplace name", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-foreign-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); mkdirSync(home); + writeFileSync(join(home, "copilot-source"), "/another/framework"); + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /different Copilot marketplace source/); + assert.equal(readFileSync(join(home, "copilot-source"), "utf8"), "/another/framework"); + assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove|plugin install/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot rejects incomplete native output before registering or installing", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-incomplete-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub(true)); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built copilot skill/); + assert.equal(existsSync(join(home, "copilot-calls")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot registers an inherited own marketplace without trying to remove it", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-inherited-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" +case "$1 $2 $3" in + 'plugin marketplace list') ${copilotMarketplaceList} "$FW" ;; + 'plugin marketplace remove') echo 'Marketplace "aidd-framework" is not registered' >&2; exit 1 ;; + 'plugin marketplace add') printf '%s' "$4" > "$HOME/copilot-source" ;; +esac`); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); + assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +for (const command of ["add", "update", "install"]) { + test(`Copilot ${command} failure retains the native cause`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-cause-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), String.raw`case "$1 $2 $3" in + 'plugin marketplace list') ${command === "update" ? `${copilotMarketplaceList} "$BUILD/copilot"` : "echo '[]'"} ;; + 'plugin marketplace ${command}'${command === "install" ? "|\'plugin install aidd-dev@aidd-framework\'" : ""}) echo 'fixture native permission denied' >&2; exit 9 ;; +esac`); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /fixture native permission denied/); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("reload rejects plugin selection arguments before changing any host", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-filter-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const result = runSync(fakeBin, home, ["aidd-dev"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /every plugin/); + assert.equal(existsSync(join(home, "build")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("reload uses the checkout's built CLI instead of a published translator", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-cli-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const source = fixture(home), binary = join(source, "cli/dist/cli.js"); + mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, String.raw`const fs = require("node:fs"), path = require("node:path"); +const args = process.argv.slice(2); +if (args[0] !== "translate") process.exit(2); +const out = args[args.indexOf("--out") + 1]; +const source = args[1]; +for (const plugin of fs.readdirSync(path.join(source, "plugins"))) { + const target = path.join(out, "plugins", plugin); + fs.mkdirSync(path.join(target, ".cursor-plugin"), { recursive: true }); + fs.writeFileSync(path.join(target, ".cursor-plugin/plugin.json"), JSON.stringify({ name: plugin })); + fs.cpSync(path.join(source, "plugins", plugin, "skills"), path.join(target, "skills"), { recursive: true }); + +}`); + executable(join(fakeBin, "cursor-agent"), "echo 'Start the Cursor Agent'"); + executable(join(fakeBin, "npx"), "exit 19"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), "source skill\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot instructions follow its native home with task overrides taking precedence", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-home-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"), nativeHome = join(home, "native-copilot"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, [], { COPILOT_HOME: nativeHome, COPILOT_INSTRUCTIONS: "" }); + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(join(nativeHome, "copilot-instructions.md"), "utf8"), /Use available agents when useful/); + assert.equal(existsSync(join(home, ".copilot/copilot-instructions.md")), false); + assert.equal(runSync(fakeBin, home, [], { COPILOT_HOME: nativeHome }).status, 0); + assert.match(readFileSync(join(home, ".copilot/copilot-instructions.md"), "utf8"), /Use available agents when useful/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("OpenCode skills follow its native home with task overrides taking precedence", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-opencode-home-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-config"); + mkdirSync(fakeBin); + executable(join(fakeBin, "opencode"), "exit 0"); + executable(join(fakeBin, "npx"), String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = --out ]; then shift; out=$1; fi + shift +done +mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" +printf '%s\n' 'native skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" +`); + const result = runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config, OPENCODE_SKILLS: "", OPENCODE_INSTRUCTIONS: "" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(config, "opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); + assert.equal(existsSync(join(config, "opencode/agents")), false); + assert.equal(existsSync(join(home, ".config/opencode/skills")), false); + assert.equal(runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config }).status, 0); + assert.equal(readFileSync(join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +for (const customPluginRoot of [false, true]) { + test(`Claude cache follows its native ${customPluginRoot ? "plugin root" : "config home"}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-claude-home-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-claude"); + const plugins = customPluginRoot ? join(home, "native-plugins") : join(config, "plugins"); + mkdirSync(fakeBin); + executable(join(fakeBin, "claude"), String.raw`if [ "$1 $2" = 'plugin install' ]; then + cache="$CLAUDE_CONFIG_DIR/plugins" + [ -z "$CLAUDE_CODE_PLUGIN_CACHE_DIR" ] || cache="$CLAUDE_CODE_PLUGIN_CACHE_DIR" + cache="$cache/cache/aidd-framework/aidd-dev/2.6.0" + mkdir -p "$cache" + printf '%s\n' 'native plugin' > "$cache/plugin-installed" +fi`); + const result = runSync(fakeBin, home, [], { CLAUDE_CONFIG_DIR: config, CLAUDE_CODE_PLUGIN_CACHE_DIR: customPluginRoot ? plugins : "", CLAUDE_CACHE: "", CLAUDE_INSTRUCTIONS: "" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(plugins, "cache/aidd-framework/aidd-dev/2.6.0/plugin-installed"), "utf8"), "native plugin\n"); + assert.match(readFileSync(join(config, "CLAUDE.md"), "utf8"), /Use available agents when useful/); + assert.equal(existsSync(join(home, ".claude/plugins")), false); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { + test(`OpenCode preserves its actual Claude fallback despite ${override}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-fallback-override-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync(join(home, ".claude/CLAUDE.md"), "Effective OpenCode personal instructions.\n"); + const custom = join(home, "custom-claude"); + mkdirSync(custom); + writeFileSync(join(custom, "CLAUDE.md"), "Other Claude instructions.\n"); + executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + const result = runSync(fakeBin, home, [], override === "CLAUDE_CONFIG_DIR" + ? { CLAUDE_CONFIG_DIR: custom, CLAUDE_INSTRUCTIONS: "" } + : { CLAUDE_INSTRUCTIONS: join(custom, "CLAUDE.md") }); + assert.equal(result.status, 0, result.stderr); + const installed = readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"); + assert.match(installed, /Effective OpenCode personal instructions/); + assert.doesNotMatch(installed, /Other Claude instructions/); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +/** A built CLI and host stubs that log every call, so order and flags are observable. */ +function optInFixture(root, optIn, hosts = ["claude", "codex", "opencode"]) { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(home); + const source = fixture(home); + if (optIn !== undefined) { + mkdirSync(join(source, "scripts"), { recursive: true }); + writeFileSync(join(source, "scripts/reload-agents.json"), typeof optIn === "string" ? optIn : JSON.stringify(optIn)); + } + mkdirSync(join(source, "cli/dist"), { recursive: true }); + writeFileSync(join(source, "cli/dist/cli.js"), String.raw` +const { appendFileSync, mkdirSync, writeFileSync } = require("node:fs"); +const args = process.argv.slice(2); +if (args[0] !== "agents") { + const out = args[args.indexOf("--out") + 1] + "/.opencode/skills/aidd-dev-01-plan"; + mkdirSync(out, { recursive: true }); + writeFileSync(out + "/SKILL.md", "skill\n"); + process.exit(0); +} +appendFileSync(process.env.HOME + "/calls.log", "cli " + args.join(" ") + "\n"); +if (args.includes("cursor")) { console.error("cursor has no user-level agents."); process.exit(1); } +`); + for (const host of hosts) executable(join(fakeBin, host), `echo "${host} $*" >> "$HOME/calls.log"`); + const calls = () => (existsSync(join(home, "calls.log")) ? readFileSync(join(home, "calls.log"), "utf8") : ""); + return { fakeBin, home, calls }; +} + +test("reload installs opted-in user agents first, with --main only where asked", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-agents-")); + try { + const { fakeBin, home, calls } = optInFixture(root, { + "claude-code": { provider: "anthropic", main: true }, + codex: { provider: "openai" }, + }); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global agents\s+claude-code:ok\+main codex:ok\n/); + const lines = calls().trim().split("\n"); + assert.deepEqual(lines.slice(0, 2), [ + "cli agents install --scope user --ide claude-code --provider anthropic --main", + "cli agents install --scope user --ide codex --provider openai", + ]); + assert.ok(lines.length > 2, "plugins still install after the agents"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("reload without an opt-in file installs no agent", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-opt-in-")); + try { + const { fakeBin, home, calls } = optInFixture(root); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stdout, /global agents/); + assert.doesNotMatch(calls(), /cli agents/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("reload skips opted-in OpenCode on a managed host", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-agents-")); + try { + const { fakeBin, home, calls } = optInFixture(root, { opencode: { provider: "anthropic" } }, ["opencode"]); + executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global agents\s+opencode:skip\(managed host\)/); + assert.doesNotMatch(calls(), /cli agents/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("reload names an opted-in IDE it skips", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-skip-")); + try { + const { fakeBin, home, calls } = optInFixture(root, { codex: { provider: "openai" } }, ["claude"]); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global agents\s+codex:skip\(not installed\)/); + assert.doesNotMatch(calls(), /cli agents/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +for (const [label, optIn, error] of [ + ["a refused IDE", { cursor: { provider: "anthropic" } }, /cursor has no user-level agents/], + ["an invalid entry", { codex: { provider: "openai", main: "yes" } }, /codex\.main must be true or omitted/], + ["a misspelled key", { codex: { provider: "openai", mian: true } }, /codex has unknown keys: mian/], + ["malformed JSON", "{ codex: ", /Invalid agent opt-in file/], +]) { + test(`reload stops on ${label} before changing any plugin`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-bad-opt-in-")); + try { + const { fakeBin, home, calls } = optInFixture(root, optIn); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, error); + assert.doesNotMatch(calls(), /^(claude|codex|opencode) /m); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("Codex reload without a built CLI leaves agents unchanged", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-codex-personal-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const agents = join(home, ".codex/agents"); + mkdirSync(agents, { recursive: true }); + const existing = ["aidd-dev-executor.toml", "aidd-dev-personal.toml", "executor.toml"]; + for (const file of existing) writeFileSync(join(agents, file), `Personal ${file}\n`); + executable(join(fakeBin, "codex"), "exit 0"); + executable(join(fakeBin, "npx"), "exit 0"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + for (const file of existing) assert.equal(readFileSync(join(agents, file), "utf8"), `Personal ${file}\n`); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 29cb7d18c..3d1184387 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,66 +1,214 @@ #!/usr/bin/env bash -# (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. +# (Re)install every plugin into Claude, Codex, OpenCode, Copilot and Cursor from THIS checkout. +# Claude reads the raw repo, already native; Codex installs from a tree +# the aidd CLI builds, so what runs locally matches what ships. # # 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. # -# 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. +# Agents are opt-in through the gitignored scripts/reload-agents.json (or RELOAD_AGENTS; see +# reload-agents.example.json): each listed IDE runs the built CLI's `agents install --scope user`, +# adding `--main` when `"main": true`. The file only adds; `agents remove --scope user` undoes. +# Agents run before plugins, so a failure changes no plugin; IDEs listed earlier stay installed. +# Global rules: sync only the Agents section in the managed block, preserving personal +# rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. +# Paths honor native home/cache overrides; explicit task paths take precedence. +# Sources checked 2026-10-14: +# https://developers.openai.com/codex/guides/agents-md +# https://code.claude.com/docs/en/memory +# OpenCode fallback and disable flags checked 2026-10-16: +# https://opencode.ai/docs/rules/#claude-code-compatibility +# Cursor local plugins: real directories only; local imports must be allowed (read 2026-10-15). +# https://cursor.com/docs/plugins#test-plugins-locally +# Copilot native list/install verified with 1.0.5 and 1.0.98 (read 2026-10-15). +# https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference +# Global rules: Copilot personal instructions; Cursor always-on .mdc rules (read 2026-10-15). +# https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions +# https://prod.cursor.com/help/customization/rules set -euo pipefail shopt -s nullglob +if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != all ]; }; then + echo "Reload syncs every plugin; plugin selection arguments are unsupported." >&2; exit 1 +fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 +AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # published fallback for sources without a built CLI BUILD="${BUILD:-$HOME/.cache/aidd-framework-dev}" # per-tool native trees the marketplaces point at -CODEX_CACHE="${CODEX_CACHE:-$HOME/.codex/plugins/cache}" -CLAUDE_CACHE="${CLAUDE_CACHE:-$HOME/.claude/plugins/cache}" -CODEX_AGENTS="${CODEX_AGENTS:-$HOME/.codex/agents}" -OPENCODE_SKILLS="${OPENCODE_SKILLS:-$HOME/.config/opencode/skills}" +CODEX_CACHE="${CODEX_CACHE:-${CODEX_HOME:-$HOME/.codex}/plugins/cache}" +CLAUDE_CACHE="${CLAUDE_CACHE:-${CLAUDE_CODE_PLUGIN_CACHE_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins}/cache}" +OPENCODE_SKILLS="${OPENCODE_SKILLS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills}" +CURSOR_PLUGINS="${CURSOR_PLUGINS:-$HOME/.cursor/plugins/local}" +RELOAD_AGENTS="${RELOAD_AGENTS:-$FW/scripts/reload-agents.json}" +CODEX_INSTRUCTIONS="${CODEX_INSTRUCTIONS:-${CODEX_HOME:-$HOME/.codex}/AGENTS.md}" +CLAUDE_INSTRUCTIONS="${CLAUDE_INSTRUCTIONS:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md}" +OPENCODE_INSTRUCTIONS="${OPENCODE_INSTRUCTIONS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/AGENTS.md}" +COPILOT_INSTRUCTIONS="${COPILOT_INSTRUCTIONS:-${COPILOT_HOME:-$HOME/.copilot}/copilot-instructions.md}" +CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc}" HAVE_CODEX=0; command -v codex >/dev/null 2>&1 && HAVE_CODEX=1 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 +HAVE_COPILOT=0; command -v copilot >/dev/null 2>&1 && HAVE_COPILOT=1 +HAVE_CURSOR=0 +if command -v cursor >/dev/null 2>&1 || command -v cursor-agent >/dev/null 2>&1; then + HAVE_CURSOR=1 +elif command -v agent >/dev/null 2>&1 && agent --help 2>/dev/null | grep 'Start the Cursor Agent' >/dev/null; then + HAVE_CURSOR=1 +fi build_tool() { local tool="$1" rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" local mode=marketplace [ "$tool" != opencode ] || mode=flat - npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + if [ -f "$FW/cli/dist/cli.js" ]; then + node "$FW/cli/dist/cli.js" translate "$FW" \ + --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + else + npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ + --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + fi +} + +validate_opencode_skills() { + # Check every expected nonempty skill before replacing plugin namespaces. + local name skill skill_name + for name in "$@"; do + for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do + skill_name="$(basename "$(dirname "$skill")")" + if [ ! -s "$BUILD/opencode/.opencode/skills/$name/$skill_name/SKILL.md" ] && + [ ! -s "$BUILD/opencode/.opencode/skills/$name-$skill_name/SKILL.md" ]; then + echo "no built OpenCode skill for $name-$skill_name (missing or empty SKILL.md)" >&2 + return 1 + fi + done + done +} + +validate_native_plugins() { + local tool="$1" manifest="$2" name skill + for name in "${targets[@]}"; do + if [ ! -s "$BUILD/$tool/plugins/$name/$manifest" ]; then + echo "no built $tool manifest for $name" >&2; return 1 + fi + for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do + if [ ! -s "$BUILD/$tool/plugins/$name/skills/$(basename "$(dirname "$skill")")/SKILL.md" ]; then + echo "no built $tool skill for $name-$(basename "$(dirname "$skill")")" >&2; return 1 + fi + done + done } sync_opencode_skills() { + # Accept nested and legacy flat builds; preserve personal and unselected plugin content. local name source skill destination + local -a skills mkdir -p "$OPENCODE_SKILLS" + source="$BUILD/opencode/.opencode/skills" for name in "$@"; do - source="$BUILD/opencode/.opencode/skills" + skills=("$source/$name/"*/ "$source/$name-"*/) + rm -rf -- "$OPENCODE_SKILLS/$name" find "$OPENCODE_SKILLS" -mindepth 1 -maxdepth 1 -type d -name "$name-*" -exec rm -rf -- {} + - for skill in "$source/$name-"*/; do - destination="$OPENCODE_SKILLS/$(basename "$skill")" + for skill in "${skills[@]}"; do + case "$skill" in + "$source/$name/"*) destination="$OPENCODE_SKILLS/$name/$(basename "$skill")" ;; + *) destination="$OPENCODE_SKILLS/$(basename "$skill")" ;; + esac + mkdir -p "$(dirname "$destination")" cp -R "$skill" "$destination" done done } +install_opted_in_agents() { + # The CLI owns paths, ownership and refusals; this only maps the opt-in file to its flags. + [ -f "$RELOAD_AGENTS" ] || return 0 + local opted_in ide provider main have result + opted_in="$(node -e ' + const fail = (message) => { console.error(message); process.exit(1); }; + let config; + try { config = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); } + catch (error) { fail(error.message); } + if (typeof config !== "object" || config === null || Array.isArray(config)) + fail("expected an object keyed by IDE"); + const text = (value) => typeof value === "string" && /^[\w.-]+$/.test(value); + for (const [ide, entry] of Object.entries(config)) { + if (!text(ide)) fail(`invalid IDE name "${ide}"`); + if (!text(entry?.provider)) fail(`${ide} needs a provider`); + const unknown = Object.keys(entry).filter((key) => key !== "provider" && key !== "main"); + if (unknown.length > 0) fail(`${ide} has unknown keys: ${unknown.join(", ")}`); + if (entry.main !== undefined && entry.main !== true) fail(`${ide}.main must be true or omitted`); + console.log(`${ide}\t${entry.provider}\t${entry.main === true}`); + } + ' "$RELOAD_AGENTS")" || { echo "Invalid agent opt-in file: $RELOAD_AGENTS" >&2; return 1; } + [ -n "$opted_in" ] || return 0 + printf '%-22s' "global agents" + if [ ! -f "$FW/cli/dist/cli.js" ]; then echo " skip (no built CLI; run make reload)"; return 0; fi + while IFS=$'\t' read -r ide provider main; do + case "$ide" in + claude-code) have="$HAVE_CLAUDE" ;; + codex) have="$HAVE_CODEX" ;; + copilot) have="$HAVE_COPILOT" ;; + opencode) have="$HAVE_OPENCODE"; [ "$HAVE_MANAGED_OPENCODE" = 0 ] || have=managed ;; + *) have=1 ;; # the CLI rejects an IDE without user-level agents + esac + case "$have" in + 0) printf ' %s:skip(not installed)' "$ide"; continue ;; + managed) printf ' %s:skip(managed host)' "$ide"; continue ;; + esac + local -a args=(agents install --scope user --ide "$ide" --provider "$provider") + [ "$main" != true ] || args+=(--main) + if ! result="$(node "$FW/cli/dist/cli.js" "${args[@]}" 2>&1)"; then + echo " $ide:FAIL"; printf '%s\n' "$result" >&2 + echo "Fix the cause, or rebuild with 'pnpm --dir cli build' if the CLI predates this checkout." >&2 + return 1 + fi + if [ "$main" = true ]; then printf ' %s:ok+main' "$ide"; else printf ' %s:ok' "$ide"; fi + done <<<"$opted_in" + echo +} + register_marketplace() { case "$1" in - # Codex needs the built tree (the raw repo is Claude-syntax; Codex wants TOML/.codex-plugin). + # Codex needs the native .codex-plugin tree. codex) codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true codex plugin marketplace add "$BUILD/codex" >/dev/null 2>&1 ;; - # 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 + # Claude installs the native repository. Scope every op to user: 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 ;; + copilot) + local registered_source + registered_source="$(copilot plugin marketplace list --json | node -e ' + const entries = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + if (!Array.isArray(entries)) throw new Error("Invalid Copilot marketplace list"); + const entry = entries.find(item => item.name === process.argv[1]); + if (entry && typeof entry.source !== "string") throw new Error("Invalid Copilot marketplace source"); + process.stdout.write(entry?.source ?? ""); + ' "$MKT")" || return 1 + case "$registered_source" in + "") copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; + "Local: $BUILD/copilot") copilot plugin marketplace update "$MKT" >/dev/null ;; + "Local: $FW") + # Native list also includes inherited Claude registrations, which remove cannot change. + local add_result + if add_result="$(copilot plugin marketplace add "$BUILD/copilot" 2>&1)"; then + return 0 + fi + case "$add_result" in + *"already registered"*) + copilot plugin marketplace remove "$MKT" --force >/dev/null || return 1 + copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; + *) printf '%s\n' "$add_result" >&2; return 1 ;; + esac ;; + *) echo "Refusing a different Copilot marketplace source for $MKT: $registered_source" >&2; return 1 ;; + esac ;; esac } @@ -74,73 +222,75 @@ sync_one() { rm -rf "$CODEX_CACHE/$MKT/$name" if codex plugin add "$name@$MKT" >/dev/null 2>&1; then printf ' codex:ok' - # Codex ignores plugin-bundled agents (manifest gap) - drop the built TOML where it looks. - local toml n=0 - for toml in "$BUILD/codex/plugins/$name/codex-agents/"*.toml; do - mkdir -p "$CODEX_AGENTS"; cp -f "$toml" "$CODEX_AGENTS/"; n=$((n + 1)) - done - [ "$n" -gt 0 ] && printf '(+%d agents)' "$n" else printf ' codex:FAIL' + echo "Codex plugin install failed for $name; rerun codex plugin add $name@$MKT for details." >&2 + return 1 fi fi if [ "$HAVE_CLAUDE" = 1 ]; then 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 `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)" - if [ -n "$dest" ]; then - mkdir -p "${dest}agents" - for src in "$FW/plugins/$name/agents/"*.md; do - n=$((n + 1)) - cmp -s "$src" "${dest}agents/$(basename "$src")" 2>/dev/null || { cp -f "$src" "${dest}agents/"; fixed=$((fixed + 1)); } - done - [ "$fixed" -gt 0 ] && printf '(+%d agents, repaired %d)' "$n" "$fixed" || printf '(+%d agents)' "$n" - else - printf '(agents:NO-INSTALLPATH)' - fi - fi else printf ' claude:FAIL' + echo "Claude plugin install failed for $name; rerun claude plugin install $name@$MKT --scope user for details." >&2 + return 1 + fi + fi + if [ "$HAVE_COPILOT" = 1 ]; then + if copilot plugin install "$name@$MKT" >/dev/null; then + printf ' copilot:ok' + else + echo "Copilot plugin install failed for $name" >&2; return 1 fi fi echo } -if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then - echo "No Claude, Codex, or OpenCode installation found - nothing to install."; exit 0 +if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ] && [ "$HAVE_COPILOT" = 0 ] && [ "$HAVE_CURSOR" = 0 ]; then + echo "No supported host installation found - nothing to install."; exit 0 fi +install_opted_in_agents || exit 1 + targets=() -if [ $# -eq 0 ] || [ "${1:-}" = "all" ]; then - for d in "$FW"/plugins/*/; do targets+=("$(basename "$d")"); done -else - targets=("$@") -fi +for d in "$FW"/plugins/*/; do targets+=("$(basename "$d")"); done -# Codex needs a native build (md -> toml); register against it. Claude installs from the raw +# Codex needs a native plugin build; register against it. Claude installs from the raw # repo (already native), so it only needs the marketplace registered - no build. if [ "$HAVE_CODEX" = 1 ]; then printf '%-22s' "build codex" - build_tool codex && { register_marketplace codex; echo "ok"; } || { echo "FAIL"; HAVE_CODEX=0; } + build_tool codex || { echo "FAIL"; echo "codex build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } + register_marketplace codex || { echo "FAIL"; echo "Codex marketplace registration failed for $BUILD/codex." >&2; exit 1; } + echo "ok" fi if [ "$HAVE_CLAUDE" = 1 ]; then register_marketplace claude fi if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then printf '%-22s' "build opencode" - build_tool opencode && echo "ok" || { echo "FAIL"; HAVE_OPENCODE=0; } + build_tool opencode && echo "ok" || { echo "FAIL"; echo "opencode build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } +fi +if [ "$HAVE_CURSOR" = 1 ]; then + printf '%-22s' "build cursor" + build_tool cursor || { echo "FAIL"; echo "cursor build failed" >&2; exit 1; } + validate_native_plugins cursor .cursor-plugin/plugin.json || { echo "FAIL"; exit 1; } + echo "ok" +fi +if [ "$HAVE_COPILOT" = 1 ]; then + printf '%-22s' "build copilot" + build_tool copilot || { echo "FAIL"; echo "copilot build failed" >&2; exit 1; } + validate_native_plugins copilot .plugin/plugin.json || { echo "FAIL"; exit 1; } + register_marketplace copilot || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } + echo "ok" fi for t in "${targets[@]}"; do sync_one "$t"; done if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then printf '%-22s' "install opencode" + validate_opencode_skills "${targets[@]}" sync_opencode_skills "${targets[@]}" echo "skills:ok" fi @@ -148,5 +298,30 @@ if [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then printf '%-22s' "reload opencode" aidd-opencode-reload fi +if [ "$HAVE_CURSOR" = 1 ]; then + printf '%-22s' "install cursor" + mkdir -p "$CURSOR_PLUGINS" + for t in "${targets[@]}"; do + rm -rf -- "$CURSOR_PLUGINS/$t" + cp -R "$BUILD/cursor/plugins/$t" "$CURSOR_PLUGINS/$t" + done + echo "plugins:ok (requires local plugin imports)" +fi + +instructions=() +[ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") +[ "$HAVE_CLAUDE" = 0 ] || instructions+=(claude "$CLAUDE_INSTRUCTIONS") +if [ "$HAVE_OPENCODE" = 1 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then + instructions+=(opencode "$OPENCODE_INSTRUCTIONS") +fi +[ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") +[ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") +printf '%-22s' "global instructions" +# OpenCode uses this fixed Claude fallback, independent of Claude config overrides. +instruction_fallback="$HOME/.claude/CLAUDE.md" +case "${OPENCODE_DISABLE_CLAUDE_CODE:-}" in 1|true) instruction_fallback="" ;; esac +case "${OPENCODE_DISABLE_CLAUDE_CODE_PROMPT:-}" in 1|true) instruction_fallback="" ;; esac +node "$SCRIPT_DIR/sync-global-instructions.cjs" "$FW/AGENTS.md" "$instruction_fallback" "${instructions[@]}" +echo "ok (delegation guidance only)" -echo "Done. Restart Claude/Codex; OpenCode discovers the refreshed skills without a restart." +echo "Done. Restart hosts or reload their configuration for refreshed plugins and instructions." diff --git a/scripts/reload-agents.example.json b/scripts/reload-agents.example.json new file mode 100644 index 000000000..028b1180f --- /dev/null +++ b/scripts/reload-agents.example.json @@ -0,0 +1,5 @@ +{ + "claude-code": { "provider": "anthropic" }, + "codex": { "provider": "openai" }, + "opencode": { "provider": "anthropic", "main": true } +} diff --git a/scripts/sync-global-instructions.cjs b/scripts/sync-global-instructions.cjs new file mode 100644 index 000000000..bf78a14d1 --- /dev/null +++ b/scripts/sync-global-instructions.cjs @@ -0,0 +1,55 @@ +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs"); +const { dirname } = require("node:path"); + +const start = ""; +const end = ""; + +function render(source, host) { + const beginning = source.search(/^## Agents[ \t]*\r?$/m); + if (beginning < 0) throw new Error("AGENTS.md has no Agents section; refusing global sync"); + const boundary = source.indexOf("\n## ", beginning + 1); + if (!["codex", "claude", "opencode", "copilot", "cursor"].includes(host)) throw new Error(`Unknown instruction host: ${host}`); + return source.slice(beginning, boundary < 0 ? undefined : boundary).trim(); +} + +function merge(existing, content) { + const starts = existing.split(start).length - 1; + const ends = existing.split(end).length - 1; + if (starts !== ends || starts > 1 || (starts === 1 && existing.indexOf(start) > existing.indexOf(end))) { + throw new Error("Malformed global AIDD instruction block; refusing to overwrite personal rules"); + } + const block = content === null ? "" : `${start}\n${content}\n${end}`; + if (starts === 1) { + return existing.slice(0, existing.indexOf(start)) + block + existing.slice(existing.indexOf(end) + end.length); + } + if (content === null) return existing; + return existing + (existing.endsWith("\n\n") || !existing ? "" : existing.endsWith("\n") ? "\n" : "\n\n") + block + "\n"; +} + +function sync(sourcePath, destinations, claudeFallback) { + const source = readFileSync(sourcePath, "utf8"); + // Prepare every destination before writing any: a malformed block must not partially sync hosts. + const updates = destinations.map(([host, path]) => { + let existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + if (host === "cursor" && !existsSync(path)) existing = "---\nalwaysApply: true\n---\n"; + // Creating OpenCode's global file disables its Claude fallback. Keep those personal rules. + if (host === "opencode" && !existsSync(path) && claudeFallback && existsSync(claudeFallback)) { + existing = merge(readFileSync(claudeFallback, "utf8"), null); + } + return { path, content: merge(existing, render(source, host)) }; + }); + for (const { path, content } of updates) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } +} + +module.exports = { render, merge, sync }; + +if (require.main === module) { + const [sourcePath, claudeFallback, ...args] = process.argv.slice(2); + if (!sourcePath || claudeFallback === undefined || args.length % 2) throw new Error("Usage: sync-global-instructions.cjs SOURCE CLAUDE_FALLBACK [HOST DESTINATION ...]"); + const destinations = []; + for (let i = 0; i < args.length; i += 2) destinations.push([args[i], args[i + 1]]); + sync(sourcePath, destinations, claudeFallback); +} diff --git a/scripts/sync-readme-counts.mjs b/scripts/sync-readme-counts.mjs index 6c47ae300..b97739e7c 100644 --- a/scripts/sync-readme-counts.mjs +++ b/scripts/sync-readme-counts.mjs @@ -31,7 +31,8 @@ const totalAgents = plugins.reduce((n, p) => n + agentsOf(p), 0); let md = readFileSync(README, "utf8"); const before = md; -const hero = `${totalPlugins} plugins · ${totalSkills} skills · ${totalAgents} agents`; +const hero = `${totalPlugins} plugins · ${totalSkills} skills` + + (totalAgents ? ` · ${totalAgents} agents` : ""); md = md.replace(/[\s\S]*?/, `${hero}`); for (const name of plugins) {