From 6cc32c9df4f95471836ae73a69d5b47dc24a6505 Mon Sep 17 00:00:00 2001 From: Dan Mestas <93289084+danmestas@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:04:22 -0500 Subject: [PATCH 1/2] docs: add wardrobe dual-emitter implementation plan --- ...driven-dual-emitter-implementation-plan.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/bones-powers/plans/2026-06-02-wardrobe-driven-dual-emitter-implementation-plan.md diff --git a/docs/bones-powers/plans/2026-06-02-wardrobe-driven-dual-emitter-implementation-plan.md b/docs/bones-powers/plans/2026-06-02-wardrobe-driven-dual-emitter-implementation-plan.md new file mode 100644 index 0000000..9c53481 --- /dev/null +++ b/docs/bones-powers/plans/2026-06-02-wardrobe-driven-dual-emitter-implementation-plan.md @@ -0,0 +1,79 @@ +# Wardrobe-Driven Dual Emitter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use bones-powers:subagent-driven-development (recommended) or bones-powers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Transform suit from a content owner into a thin, reproducible emitter that consumes wardrobe template repo(s) as the single source of truth for skills, agents, and MCP servers, producing correct artifacts for Claude Code and Gemini CLI with zero manual drift. + +**Architecture:** Suit records a wardrobe template repo path on `suit init`. Every `suit up` reads the canonical content from that repo, applies the existing outfit/cut/accessory composition, and emits via dual adapters (claude-code, gemini) that produce the harness-native trees plus thin `AGENTS.md` shims. MCP servers are first-class components emitted to `.mcp.json` and Gemini settings. + +**Tech Stack:** TypeScript (NodeNext), existing suit resolver (`resolution.ts`), `ProjectWriter`, harness adapters. + +--- + +## Task 1: Branch Setup and Plan Commit `[slot: infra]` + +**Files:** +- Create: (branch) +- Modify: (none) + +- [ ] **Step 1: Create feature branch** + ```bash + git checkout -b feat/wardrobe-dual-emitter + ``` + Expected: On feature branch. + +- [ ] **Step 2: Commit the approved design spec** + ```bash + git add docs/bones-powers/specs/2026-06-02-wardrobe-driven-dual-emitter-design.md + git commit -m "docs: add wardrobe-driven dual-emitter design spec" + ``` + +## Task 2: Persistent Wardrobe Source Configuration `[slot: core]` + +**Files:** +- Modify: `src/lib/ac/init.ts` +- Modify: `src/lib/content-store.ts` + +- [ ] **Step 1: Extend `ContentStore` to persist the source URL/path.** + (Add a `source` field to the store metadata). + +- [ ] **Step 2: Modify `runInit` to call `store.setSource(url)` after successful clone.** + +## Task 3: Dual-Emitter Content Loading `[slot: core]` + +**Files:** +- Modify: `src/lib/ac/up.ts` +- Modify: `src/lib/ac/compose.ts` + +- [ ] **Step 1: In `runUp`, resolve the wardrobe path from the content store before calling `composeBundle`.** +- [ ] **Step 2: Pass the wardrobe-derived components to the adapters.** + +## Task 4: Thin AGENTS.md Shim Emission `[slot: core]` + +**Files:** +- Modify: `src/lib/harness-adapters/claude-code.ts` +- Modify: `src/lib/harness-adapters/gemini.ts` + +- [ ] **Step 1: Update Claude adapter `emit` to check for portable rules and write `@AGENTS.md` shim.** +- [ ] **Step 2: Update Gemini adapter similarly.** + +## Task 5: MCP Server Emission `[slot: core]` + +**Files:** +- Modify: `src/lib/harness-adapters/claude-code.ts` +- Modify: `src/lib/harness-adapters/gemini.ts` + +- [ ] **Step 1: Implement `emitMcp` logic for `.mcp.json`.** +- [ ] **Step 2: Implement `emitMcp` logic for Gemini settings.** + +## Task 6: Legacy Content Cleanup `[slot: infra]` + +**Files:** +- Delete/Modify: `.claude/`, `.gemini/`, `GEMINI.md` + +- [ ] **Step 1: Remove legacy hand-maintained content.** +- [ ] **Step 2: Update README to point to `suit init `.** + +## Task 7: Verification `[slot: test]` + +- [ ] **Step 1: Manual/automated verification of end-to-end flow.** From a138e0d4f7ab8cf2972c1c2404efc6e4bebd83c9 Mon Sep 17 00:00:00 2001 From: Dan Mestas <93289084+danmestas@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:32:53 -0500 Subject: [PATCH 2/2] feat: emit wardrobe config for Claude and Gemini --- README.md | 33 ++-- docs/USAGE.md | 68 ++++---- ...-02-wardrobe-driven-dual-emitter-design.md | 158 ++++++++++++++++++ src/adapters/claude-code.ts | 23 ++- src/adapters/gemini.ts | 48 +++++- src/lib/ac/compose.ts | 13 +- src/lib/rules.ts | 17 ++ src/lib/types.ts | 4 + src/lib/validate.ts | 2 +- src/tests/ac/up.test.ts | 75 +++++++++ src/tests/adapters/claude-code.test.ts | 104 ++++++++++-- .../{.mcp.fragment.json => .mcp.json} | 0 .../rules-compose/expected/AGENTS.md | 7 + .../rules-compose/expected/CLAUDE.md | 8 +- src/tests/adapters/gemini.test.ts | 74 ++++---- .../gemini/agent-basic/component/SKILL.md | 19 +++ .../expected/agents/code-reviewer.md | 11 ++ src/tests/adapters/gemini/integration.test.ts | 6 +- .../gemini/rules-compose/expected/AGENTS.md | 7 + .../gemini/rules-compose/expected/GEMINI.md | 8 +- src/tests/content-store.test.ts | 1 + src/tests/integration.test.ts | 6 +- src/tests/validate.test.ts | 6 +- 23 files changed, 577 insertions(+), 121 deletions(-) create mode 100644 docs/bones-powers/specs/2026-06-02-wardrobe-driven-dual-emitter-design.md rename src/tests/adapters/claude-code/mcp-basic/expected/{.mcp.fragment.json => .mcp.json} (100%) create mode 100644 src/tests/adapters/claude-code/rules-compose/expected/AGENTS.md create mode 100644 src/tests/adapters/gemini/agent-basic/component/SKILL.md create mode 100644 src/tests/adapters/gemini/agent-basic/expected/agents/code-reviewer.md create mode 100644 src/tests/adapters/gemini/rules-compose/expected/AGENTS.md diff --git a/README.md b/README.md index ccb6ad7..bd84fc0 100644 --- a/README.md +++ b/README.md @@ -24,23 +24,26 @@ This puts two binaries on your PATH: ## Quick start -The standard workflow: `suit up` to dress the project, work with native harness CLIs, `suit off` when you're done. +The standard workflow: choose a wardrobe template repo (usually your own fork), `suit init ` once to record and clone that source of truth, then `suit up` to emit derived harness files into each project. Work with native harness CLIs, and `suit off` when you're done. ```bash # Install once npm install -g @agent-ops/suit -# Point at any suit-compatible content repo (one-time setup per machine) -suit init https://github.com/your-username/your-config +# Point at your wardrobe fork/template repo (one-time setup per machine). +# That repo owns canonical outfits, cuts, accessories, skills, agents, hooks, +# rules, commands, and MCP config. +suit init https://github.com/your-username/your-wardrobe # In any project, dress it with an outfit + cut (+ optional accessories): cd ~/projects/foo suit up --outfit backend --cut focused # or, on a TTY, just `suit up` and pick from a numbered list -# Now use your harnesses normally — they pick up the dressed config: -claude # native invocation; inherits .claude/CLAUDE.md, skills, hooks -codex --skip-git-repo-check +# Now use your harnesses normally — they pick up the emitted artifacts: +claude # native invocation; inherits derived .claude/ content +gemini # native invocation; inherits derived .gemini/ content +codex --skip-git-repo-check # reads the portable AGENTS.md rules layer pi --provider openrouter # Inspect or remove when you're done: @@ -66,11 +69,13 @@ suit list outfits suit list cuts suit list accessories suit status -suit sync # pull latest from your content repo +suit sync # pull latest from your wardrobe repo ``` For the deeper walkthrough — content tier resolution, authoring, refuse-when-dirty semantics, drift detection — see [USAGE.md](docs/USAGE.md). +`suit up` output is derived from the wardrobe. Treat emitted `.claude/` and `.gemini/` content as generated: change the wardrobe repo, then re-run `suit up`, rather than hand-editing harness directories. `AGENTS.md` is the portable rules layer for broader harness compatibility; `CLAUDE.md` and `GEMINI.md` are thin generated shims for their native CLIs. + ## Runtime injection (`suit inject`) `suit inject` materializes a single wardrobe component into a harness home so a worker that's already running can pick it up — without restarting it. Where `suit up` dresses a whole project, `suit inject` adds one component to an existing harness home. @@ -97,23 +102,25 @@ Injected components are recorded in `.suit/lock.json` under a distinct `injected > Injecting into a *remote* worker by name (`--target /`, over the agent mesh) is on the roadmap. Today `suit inject` targets a local harness home via `--home`. -## Local content repo (point suit at a checkout) +## Local wardrobe checkout (point suit at a checkout) -If you're maintaining a content repo locally and want suit to read from it without cloning into `~/.local/share/suit/content/`: +If you're maintaining a wardrobe repo locally and want suit to read from it without cloning into `~/.local/share/suit/content/`: ```bash -export SUIT_CONTENT_PATH=~/projects/your-config +export SUIT_CONTENT_PATH=~/projects/your-wardrobe suit list outfits suit claude --outfit backend ``` -`SUIT_CONTENT_PATH` overrides the default cloned-content location for the current shell. +`SUIT_CONTENT_PATH` overrides the default cloned-wardrobe location for the current shell. ## How it works -`suit` reads YAML-frontmatter outfit and cut definitions, computes a per-session resolution (which skills to keep, which to drop, what cut prompt to inject), then prelaunches the target harness with a filtered view of `~/./` mirrored to a tempdir. Your real `~/./` is never modified. +The wardrobe repo is the source of truth. It owns canonical outfits, cuts, accessories, skills, agents, hooks, rules, commands, and MCP configuration. `suit init ` clones that repo into `~/.local/share/suit/content/`; `suit sync` pulls it forward; `SUIT_CONTENT_PATH` points suit at a local checkout while authoring. + +`suit up` reads the wardrobe, computes a per-project resolution, and emits derived Claude Code and Gemini CLI artifacts into the project (`.claude/`, `.gemini/`, plus the portable root files each harness consumes). Those emitted files are not the editing surface. Edit the wardrobe and re-emit; use `suit current` to inspect what was applied and `suit off` to remove it. -For Codex and Copilot (which read `AGENTS.md` and `copilot-instructions.md` from the project root), `suit` invokes `suit-build docs` to generate filtered markdown into a tempdir and runs the harness with that as the working directory. +`AGENTS.md` is the compatibility layer for portable rules. If another harness needs shared instructions in this increment, put them in AGENTS.md rather than expanding suit's emitter surface. Root `CLAUDE.md` and `GEMINI.md` should stay thin/generated shims that point their CLIs at the portable rules and emitted harness-specific content. ## Development diff --git a/docs/USAGE.md b/docs/USAGE.md index 3f94076..55bae14 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -4,18 +4,20 @@ A task-oriented walkthrough of `suit`. The [README](../README.md) has install + ## 1. Overview -`suit` is a CLI that filters and composes harness configs (Claude Code, Codex, Gemini CLI, GitHub Copilot, APM, Pi) using YAML-frontmatter outfit files, cut files, and skills. The CLI ships separately from the content it reads: you install `@agent-ops/suit` once, point it at a content repo (clone the canonical [`suit-template`](https://github.com/danmestas/suit-template) or fork your own), then launch any harness through `suit ` to apply the outfit and cut you want for that session. +`suit` is a CLI that applies a wardrobe to local agent harnesses. The wardrobe template repo is the source of truth: it owns canonical outfits, cuts, accessories, skills, agents, hooks, rules, commands, and MCP configuration. The CLI ships separately from that wardrobe. Install `@agent-ops/suit` once, point it at the canonical [`suit-template`](https://github.com/danmestas/suit-template) or your own fork with `suit init `, then use `suit up` to emit derived Claude Code and Gemini CLI artifacts into a project. The mental model is: | Layer | Where it lives | Mutability | |---|---|---| | Tool | `@agent-ops/suit` from npm | Stable; bumped on release | -| Content | Cloned content repo at `~/.local/share/suit/content/` | You edit / pull | -| Overlays | `~/.config/suit/` (user) and `/.suit/` (project) | Optional; override content | +| Wardrobe source | Your template repo / fork | Canonical editing surface | +| Cloned wardrobe | `~/.local/share/suit/content/` | Managed by `suit init` / `suit sync`; edit a working fork, then sync | +| Overlays | `~/.config/suit/` (user) and `/.suit/` (project) | Optional local overrides | +| Emitted project artifacts | `.claude/`, `.gemini/`, root `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` | Derived from wardrobe; do not hand-edit | | Harness HOME | `~/./` (e.g., `~/.claude/`) | Never touched by `suit` | -`suit` mirrors the relevant pieces of `~/./` into a per-session tempdir, drops in the kept skills, generates an injected prompt or `AGENTS.md`, and spawns the harness binary against that tempdir. Your real harness home stays untouched. +`AGENTS.md` is the portable rules layer. Root `CLAUDE.md` and `GEMINI.md` should be treated as thin generated shims for their native CLIs, and emitted `.claude/` / `.gemini/` content is generated output. If broader harness compatibility needs shared instructions in this increment, put them in `AGENTS.md` rather than expanding suit's emitter surface. For the deeper "why" behind individual decisions, see [docs/adr/](./adr/). @@ -35,16 +37,16 @@ suit status # confirm content + harness presence | `suit` | The runtime launcher you invoke directly | | `suit-build` | A build helper that `suit` shells out to for Codex/Copilot prelaunch (rarely invoked by hand) | -Both must be on PATH together; if one is missing you'll see `ENOENT: spawn suit-build` when launching Codex or Copilot. A `suit init` with no URL reads `suit.templateUrl` from the package's `package.json` (default: `https://github.com/danmestas/suit-template`) and clones it into `~/.local/share/suit/content/`. Pass a positional URL to use a different template: +Both must be on PATH together; if one is missing you'll see `ENOENT: spawn suit-build` when launching Codex or Copilot. A `suit init` with no URL reads `suit.templateUrl` from the package's `package.json` (default: `https://github.com/danmestas/suit-template`) and clones that wardrobe into `~/.local/share/suit/content/`. Most teams should fork the template repo, make that fork the canonical wardrobe, and initialize from it: ```bash -suit init https://github.com/your-org/your-config +suit init https://github.com/your-org/your-wardrobe ``` If the target directory already exists, `suit init` refuses without `--force`. Re-clone with: ```bash -suit init --force https://github.com/your-org/your-config +suit init --force https://github.com/your-org/your-wardrobe ``` After init, run `suit status` to confirm: @@ -76,14 +78,14 @@ suit show `suit` has **two modes of working** (v0.5+): -- **`suit up` / `suit off` — project-state mutator** (the standard daily-driver flow). `suit up` writes resolved components into the project's `.claude/`, `.codex/`, `.pi/` etc. directly, backed by `.suit/lock.json`. Native invocations of `claude`, `codex`, `pi` from inside the dressed project pick up the suit automatically — no wrapper needed. Right for "wear this for the next few hours of work in this project." +- **`suit up` / `suit off` — project-state mutator** (the standard daily-driver flow). `suit up` resolves the wardrobe and writes derived artifacts into the project, including `.claude/`, `.gemini/`, and portable root instruction files. Native invocations of `claude` and `gemini` from inside the dressed project pick up the emitted configuration — no wrapper needed. Right for "wear this for the next few hours of work in this project." - **`suit ` — stateless launcher**. Spawns the harness binary against an ephemeral prelaunch tempdir, deletes the tempdir on exit. Right for "try this outfit for one query without dressing the project." -Both modes of working use the same outfit/cut/accessory composition model. The mutator is the default — reach for the launcher when you specifically want one-off behavior. +Both modes of working use the same outfit/cut/accessory composition model. The mutator is the default — reach for the launcher when you specifically want one-off behavior. Do not manually edit emitted `.claude/` or `.gemini/` files; edit the wardrobe and re-run `suit up`. ### 3.1 `suit up` — dress the project (the standard flow) -Writes the resolved components directly into the project's harness directories (`.claude/`, `.codex/`, `.pi/`, etc.) backed by `.suit/lock.json`. Native invocations of the harness CLIs from inside the project then pick up the suit automatically — no `suit ` wrapper required. +Writes resolved, derived components directly into the project (notably `.claude/`, `.gemini/`, and root instruction shims) backed by `.suit/lock.json`. Native invocations of the harness CLIs from inside the project then pick up the suit automatically — no `suit ` wrapper required. ```bash cd ~/projects/foo @@ -109,6 +111,8 @@ suit up # interactive picker on a TTY JSON fragment files emitted by multiple components (e.g. `.claude/settings.fragment.json` from each hook) are deep-merged so all contributions land in one file. Markdown emits don't merge — they're already composed at the adapter layer. +Generated harness artifacts are not authoritative. If `.claude/` or `.gemini/` content is wrong, fix the outfit/cut/accessory/skill/agent/hook/rule/command/MCP definition in the wardrobe repo and re-run `suit up`; local edits to emitted files will be detected as drift and can be overwritten by `--force`. + ### 3.2 `suit off` — undress the project Reads `.suit/lock.json`, removes every tracked file (verifying sha256), removes empty parent dirs, deletes the lockfile. Idempotent — running on an undressed project is a no-op. @@ -302,17 +306,17 @@ Already up to date ### 3.10 `suit init [] [--force]` -Clones a content repo. Without ``, reads `suit.templateUrl` from the installed package's `package.json`. Forks of `suit` itself can change that field to point at their own template. +Clones the wardrobe source. Without ``, reads `suit.templateUrl` from the installed package's `package.json`. Forks of `suit` itself can change that field to point at their own template, but normal users should pass their wardrobe fork/template repo explicitly. ```bash -suit init # default suit-template -suit init https://github.com/your-org/your-config # explicit URL -suit init --force https://github.com/your-org/your-config # blow away existing +suit init # default suit-template +suit init https://github.com/your-org/your-wardrobe # your canonical wardrobe +suit init --force https://github.com/your-org/your-wardrobe # re-clone existing content dir ``` Exit codes: 0 on success, 1 on git failure, 2 on missing URL with no `templateUrl` configured. -After clone, `suit init` warns (but does not fail) if the cloned repo has neither `outfits/` nor `cuts/` — that probably means you pointed at the wrong repo. +After clone, `suit init` warns (but does not fail) if the cloned repo has neither `outfits/` nor `cuts/` — that probably means you pointed at the wrong repo. A valid wardrobe may also contain canonical `accessories/`, `skills/`, `agents/`, `hooks/`, `rules/`, `commands/`, and MCP configuration. ## 4. Outfits, cuts, and skills @@ -478,19 +482,19 @@ Step by step: Your real `~/./` is never modified. See ADR-0002 for the two-binary split that makes step 5 work. -## 5. Content sources and resolution order +## 5. Wardrobe sources and resolution order -Outfits, cuts, accessories, and skills are looked up across three tiers, highest priority first: +Wardrobe components are looked up across three tiers, highest priority first. The cloned wardrobe is the durable source of truth; project/user overlays are escape hatches for local overrides. | Priority | Tier | Path | |---|---|---| | 1 | Project overlay | `/.suit/outfits/.md` (and `cuts/`, `accessories/`, `skills/`) | | 2 | User overlay | `~/.config/suit/outfits/.md` (and `cuts/`, `accessories/`, `skills/`) | -| 3 | Default content | `~/.local/share/suit/content/outfits//outfit.md` (or `.md` for non-builtin tiers; `accessories//accessory.md` likewise) | +| 3 | Cloned wardrobe | `~/.local/share/suit/content/outfits//outfit.md` (or `.md` for non-builtin tiers; `accessories//accessory.md` likewise) | Note the slight shape difference: builtin uses a `/outfit.md` directory layout, while the overlay tiers accept a flat `.md`. Both work — `suit list` shows whichever tier found it. -Worked example. Say you have a `backend` outfit in your cloned content (builtin tier) and want to tweak it for one repo: +Worked example. Say your cloned wardrobe has a `backend` outfit and you want to tweak it for one repo: ```bash mkdir -p /path/to/my-repo/.suit/outfits @@ -519,15 +523,15 @@ To make an override that follows you across all repos, drop the same file at `~/ | Variable | Effect | |---|---| -| `SUIT_CONTENT_PATH` | Replace tier 3 entirely. Useful for dev-mode against a working content repo. | +| `SUIT_CONTENT_PATH` | Replace tier 3 entirely. Useful for dev-mode against a working wardrobe checkout. | | `XDG_DATA_HOME` | Move tier 3 to `$XDG_DATA_HOME/suit/content/` instead of `~/.local/share/suit/content/`. | | `XDG_CONFIG_HOME` | Move tier 2 to `$XDG_CONFIG_HOME/suit/` instead of `~/.config/suit/`. | -`SUIT_CONTENT_PATH` wins over `XDG_DATA_HOME` for the content directory specifically. Common dev pattern: +`SUIT_CONTENT_PATH` wins over `XDG_DATA_HOME` for the cloned wardrobe directory specifically. Common dev pattern: ```bash -export SUIT_CONTENT_PATH=~/projects/agent-config -suit list outfits # reads from your working content repo +export SUIT_CONTENT_PATH=~/projects/your-wardrobe +suit list outfits # reads from your working wardrobe checkout suit claude --outfit backend ``` @@ -539,11 +543,11 @@ Choose a tier first: | Goal | Tier | Path | |---|---|---| -| Sharable outfit/cut/skill maintained in version control | Default content | `~/.local/share/suit/content/outfits//outfit.md` (or in the source repo and pulled via `suit sync`) | +| Sharable outfit/cut/accessory/skill/agent/hook/rule/command/MCP config maintained in version control | Cloned wardrobe | `~/.local/share/suit/content/outfits//outfit.md` (or in the source repo and pulled via `suit sync`) | | Personal override across all repos | User | `~/.config/suit/outfits/.md` | | Repo-specific override | Project | `/.suit/outfits/.md` | -Most authoring happens in the default-content tier — that's the cloned repo. Edit it, commit, push to your fork; everywhere else `suit sync` picks it up. +Most authoring happens in the wardrobe source repo — usually your fork of `suit-template`. Edit that repo, commit, push, then run `suit sync` (or set `SUIT_CONTENT_PATH` to a local checkout while authoring). Do not author by editing emitted project files such as `.claude/agents/...`, `.gemini/...`, root `CLAUDE.md`, or root `GEMINI.md`; those are regenerated by `suit up`. The canonical `suit-template` (and forks of it) ships slash commands under `.claude/commands/` to scaffold new content with AI assistance: @@ -647,9 +651,9 @@ If `suit status` looks healthy but a launch silently fails, re-run with `--verbo | Command | Purpose | Most-common form | |---|---|---| | `suit --help` | Show usage | `suit --help` | -| `suit init []` | Clone content repo | `suit init` (uses `templateUrl`) | +| `suit init []` | Clone wardrobe repo | `suit init https://github.com/you/your-wardrobe` | | `suit init --force ` | Re-clone, overwriting | `suit init --force https://github.com/.../...` | -| `suit sync` | `git pull` content repo | `suit sync` | +| `suit sync` | `git pull` cloned wardrobe | `suit sync` | | `suit status` | Show version, content, harness presence | `suit status` (or bare `suit`) | | `suit doctor` | Verify each harness binary on PATH | `suit doctor` | | `suit list outfits` | List discoverable outfits | `suit list outfits` | @@ -664,14 +668,14 @@ If `suit status` looks healthy but a launch silently fails, re-run with `--verbo | Env var | Effect | |---|---| -| `SUIT_CONTENT_PATH` | Override content dir | -| `XDG_DATA_HOME` | Reroot cloned content | +| `SUIT_CONTENT_PATH` | Override cloned wardrobe dir | +| `XDG_DATA_HOME` | Reroot cloned wardrobe | | `XDG_CONFIG_HOME` | Reroot user overlay | | Tier | Path | Beats | |---|---|---| -| Project | `/.suit/` | User, builtin | -| User | `~/.config/suit/` | Builtin | -| Builtin | `~/.local/share/suit/content/` | — | +| Project | `/.suit/` | User, cloned wardrobe | +| User | `~/.config/suit/` | Cloned wardrobe | +| Cloned wardrobe | `~/.local/share/suit/content/` | — | See also: [ADR index](./adr/README.md) for design decisions, especially ADR-0001 (three-repo split), ADR-0002 (`suit` vs `suit-build`), ADR-0003 (`SUIT_CONTENT_PATH`), and ADR-0008 (ContentStore as a deep module). diff --git a/docs/bones-powers/specs/2026-06-02-wardrobe-driven-dual-emitter-design.md b/docs/bones-powers/specs/2026-06-02-wardrobe-driven-dual-emitter-design.md new file mode 100644 index 0000000..f2480ed --- /dev/null +++ b/docs/bones-powers/specs/2026-06-02-wardrobe-driven-dual-emitter-design.md @@ -0,0 +1,158 @@ +# Design Spec: Wardrobe-Driven Dual Emitter for Claude Code + Gemini CLI + +**Date:** 2026-06-02 +**Status:** Approved — ready for implementation plan +**Scope:** B (deepen quality/stability for Claude + Gemini) + A (eliminate drift) + B (add MCP declaration) + +--- + +## 1. Problem Statement + +Suit currently maintains manual, duplicated content in `.claude/`, `.gemini/`, `CLAUDE.md`, and `GEMINI.md`. This creates drift, manual sync burden, and violates the single-source-of-truth principle. The wardrobe template repo already exists as the canonical content store, but suit does not consume it as the sole source for skills, agents, and (future) MCP servers. The goal is to make suit a thin, reproducible emitter that reads from wardrobe template repo(s) and produces correct, non-divergent artifacts for Claude Code and Gemini CLI. + +--- + +## 2. Goals & Non-Goals + +### Goals +- Single source of truth: all canonical skills, agents, hooks, rules, commands, and MCP servers live in wardrobe template repo(s) controlled by the user (or the default public wardrobe). +- Zero-drift emission: every `suit up` (and `suit claude` / `suit gemini`) produces fresh `.claude/` and `.gemini/` trees from the same wardrobe commit + same composition. +- MCP as first-class component: `type: mcp` declarations with portable `mcpServers` shape are emitted correctly for both Claude (`.mcp.json`) and Gemini (settings entry). +- Thin AGENTS.md adapters: `CLAUDE.md` and `GEMINI.md` become one-line shims (`@AGENTS.md`) or contain only the suit marker; portable rules live in the generated `AGENTS.md`. +- Existing manual content in the suit repo is removed or converted to generated output only. + +### Non-Goals (explicitly deferred) +- Wider harness emission (Cursor, Windsurf, Zed, Codex TOML, etc.). +- Local override directories (`local-skills/`, etc.). +- Offline caching or `suit sync` command. +- Permission/safety layer (OS sandbox, pr-policy accessory). +- Changes to the outfit/cut/accessory composition model itself. + +--- + +## 3. Architecture + +### 3.1 Components + +**Wardrobe template repo (source of truth)** +- Git repository containing `outfits/`, `cuts/`, `accessories/`, `skills/`, `agents/`, `hooks/`, `rules/`, `commands/`, and `mcp/` (or `mcp-servers/`). +- Every component carries YAML frontmatter: `name`, `version`, `type`, `targets`, `description`, optional `category` / `include` / `enable` / `disable` / `permissions`. +- Read-only input for suit. Never mutated by suit. + +**Suit (applicator / dual emitter)** +- Records the template repo path on `suit init ` (stored in `~/.suit/config.json` or `.suit/config.yaml`). +- On every emission (`suit up`, `suit claude`, `suit gemini`): + 1. Reads the wardrobe template repo (or uses `SUIT_CONTENT_PATH`). + 2. Applies outfit → fit → cut → accessory composition (existing resolver). + 3. Emits via per-target adapters: + - Claude Code adapter → `.claude/` tree + `.mcp.json` + thin `CLAUDE.md` + - Gemini adapter → `.gemini/` tree + settings MCP entry + thin `GEMINI.md` + - Shared AGENTS.md composer → `AGENTS.md` (portable rules) or thin shims +- The emitted trees are always derived; hand-edits are overwritten on next emission. + +**Key invariant** +There is exactly one source of truth for any component. Emitted artifacts are byte-for-byte reproducible from the same wardrobe commit + same outfit/cut/accessory selection. + +### 3.2 Resolution Order (multiple template repos) + +1. User-specified template repo (via `suit init` or `SUIT_CONTENT_PATH`). +2. Default public wardrobe (`https://github.com/danmestas/wardrobe`) as fallback. +3. (Future) Local overrides in the current repo — not implemented in v1. + +Component identity is `(name, type)`. Version is carried but does not affect identity for composition. + +--- + +## 4. MCP as First-Class Component + +### 4.1 Declaration + +An MCP server is declared with `type: mcp` (or as a top-level `mcpServers:` block inside an outfit/cut/accessory). The existing `MCPBlock` schema captures the portable shape: + +```yaml +--- +name: memory +type: mcp +version: 1.0.0 +targets: [claude-code, gemini] +description: Persistent memory server for cross-session recall. +mcp: + command: npx + args: ["-y", "@modelcontextprotocol/server-memory"] + env: {} +--- +``` + +### 4.2 Emission + +- **Claude Code**: writes `.mcp.json` at repo root (or merges into `.claude/settings.json`) using the exact `mcpServers` object. +- **Gemini**: writes the inner server object into the `mcpServers` section of `~/.gemini/settings.json` (or project-scoped override if supported). +- Both emitters produce byte-identical inner objects for the 7 verbatim readers. +- `enable: { mcps: [...] }` / `disable: { mcps: [...] }` blocks (already present in the globals-targeting schema) control inclusion. + +No TOML or Codex emission in this scope. + +--- + +## 5. AGENTS.md Thin Adapters + +### 5.1 Portable Rules + +Rules that are pure instruction text (no harness-specific syntax) are eligible for `AGENTS.md`. In the wardrobe template they carry `targets: [...]` and are treated as portable by convention (or a future `portable: true` flag). + +### 5.2 Emission Behavior + +- When the composed set contains portable rules, the Claude and Gemini emitters write one-line shims: + - `CLAUDE.md` → `@AGENTS.md` (plus suit outfit marker comment if needed). + - `GEMINI.md` → `@AGENTS.md`. +- The full `AGENTS.md` (portable rules, optionally agents/skills sections) is written at repo root. +- Non-portable rules, skills, agents, hooks, and commands continue to emit into harness-native directories. + +### 5.3 Drift Elimination + +Because `CLAUDE.md` and `GEMINI.md` are generated shims and `AGENTS.md` is generated from the same wardrobe source, manual divergence is impossible. The previous full-copy `GEMINI.md` in the suit repo is removed. + +--- + +## 6. Emission Lifecycle & Commands + +**Primary emission points** +- `suit up --outfit X [--cut Y] [--accessory Z...]` — reads wardrobe, composes, emits all derived artifacts. +- `suit claude ...` and `suit gemini ...` — convenience wrappers that invoke the same emission path before launch. +- `suit init ` — records the source; does not emit. + +**Safety** +- Emission is a full replace of the derived trees (`.claude/`, `.gemini/`). No in-place patching. +- Existing `ProjectWriter` refuse-when-dirty preflight remains. +- Lockfile (`.suit/lock.yaml`) records wardrobe commit SHA + outfit/cut/accessory selection for `suit status` reporting. + +**Idempotency** +Re-running the same `suit up` command with unchanged inputs produces identical emitted trees. + +--- + +## 7. Local Overrides (Deferred) + +A future optional layer may support `local-skills/`, `local-agents/`, `local-mcp/` directories inside the current repo. Local definitions would merge with wardrobe content using deterministic precedence (same `(name, type)` → local wins on version or explicit flag). This increment does not implement or depend on local overrides. + +--- + +## 8. Acceptance Criteria + +Before the implementation is considered complete, the following must hold: + +1. `suit init ` followed by `suit up --outfit engineer --cut executing` produces a `.claude/` tree and a `.gemini/` tree whose skill/agent content is identical in substance (modulo harness-specific directory layout). +2. Changing a skill in the wardrobe template repo and re-running `suit up` updates both emitted trees; no manual sync step is required. +3. Declaring an MCP server in the wardrobe template (with appropriate `targets`) causes `.mcp.json` (Claude) and the Gemini settings entry to appear after emission. +4. `CLAUDE.md` and `GEMINI.md` are thin shims (`@AGENTS.md`) or contain only the suit marker; the portable rules live in `AGENTS.md`. +5. No content under `.claude/skills/`, `.gemini/skills/`, or `.claude/agents/` in the suit source tree is treated as canonical. + +--- + +## 9. Open Questions (None Remaining) + +All clarifying questions were resolved during the brainstorming process. The design is complete and approved. + +--- + +**End of design spec.** diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index d0b99e2..b6a1af9 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import YAML from 'yaml'; import type { Adapter, ComponentSource, EmittedFile, AdapterContext } from '../lib/types.js'; -import { selectRules, composeRulesBody, isOwnerOfRulesFile } from '../lib/rules.js'; +import { selectRules, selectRulesForAnyTarget, composeRulesBody, isOwnerOfRulesFile } from '../lib/rules.js'; import { applyPassthroughPermissions } from './_permissions.js'; function yamlValue(v: string): string { @@ -99,10 +99,21 @@ function emitAgent(component: ComponentSource): EmittedFile[] { function emitRules(component: ComponentSource, ctx: AdapterContext): EmittedFile[] { if (!isOwnerOfRulesFile(component, ctx.allComponents, 'claude-code')) return []; const scope = component.manifest.scope ?? 'project'; - const sorted = selectRules(ctx.allComponents, 'claude-code', scope); - const content = composeRulesBody(sorted); - const filename = scope === 'user' ? '.claude/CLAUDE.md' : 'CLAUDE.md'; - return [{ path: filename, content }]; + if (scope === 'user') { + const sorted = selectRules(ctx.allComponents, 'claude-code', scope); + const content = composeRulesBody(sorted); + return [{ path: '.claude/CLAUDE.md', content }]; + } + const files: EmittedFile[] = [{ path: 'CLAUDE.md', content: '@AGENTS.md\n' }]; + if ((ctx.agentsMdOwnerTarget ?? 'claude-code') === 'claude-code') { + const sorted = selectRulesForAnyTarget( + ctx.allComponents, + ctx.targets ?? ['claude-code'], + 'project', + ); + files.unshift({ path: 'AGENTS.md', content: composeRulesBody(sorted) }); + } + return files; } async function emitHook(component: ComponentSource): Promise { @@ -146,7 +157,7 @@ function emitMcp(component: ComponentSource): EmittedFile[] { }, }, }; - return [{ path: '.mcp.fragment.json', content: `${JSON.stringify(fragment, null, 2)}\n` }]; + return [{ path: '.mcp.json', content: `${JSON.stringify(fragment, null, 2)}\n` }]; } function emitPlugin(component: ComponentSource, ctx: AdapterContext): EmittedFile[] { diff --git a/src/adapters/gemini.ts b/src/adapters/gemini.ts index 4452f0b..1f72d4d 100644 --- a/src/adapters/gemini.ts +++ b/src/adapters/gemini.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import YAML from 'yaml'; import type { Adapter, ComponentSource, @@ -8,6 +9,7 @@ import type { } from '../lib/types.js'; import { selectRules, + selectRulesForAnyTarget, composeRulesBody, isOwnerOfRulesFile, } from '../lib/rules.js'; @@ -22,6 +24,10 @@ const GEMINI_EVENTS = new Set([ 'AfterAgent', ]); +function yamlValue(v: string): string { + return YAML.stringify(v).trimEnd(); +} + function validateGeminiHookEvent(event: string, componentName: string): void { if (!GEMINI_EVENTS.has(event)) { throw new Error( @@ -47,6 +53,8 @@ export const geminiAdapter: Adapter = { return emitRules(component, ctx); case 'hook': return emitHook(component); + case 'agent': + return emitAgent(component); case 'mcp': return emitMcp(component); case 'outfit': @@ -58,7 +66,7 @@ export const geminiAdapter: Adapter = { // at resolution time. Outfits, however, may carry a `permissions:` block // that emits .gemini/settings.fragment.json (see emitOutfit). return []; - // agent and plugin are schema-rejected by validate.ts (compatibility matrix). + // plugin is schema-rejected by validate.ts (compatibility matrix). default: throw new Error( `gemini adapter: type "${component.manifest.type}" is not supported on Gemini`, @@ -117,16 +125,40 @@ function emitSkill(component: ComponentSource): EmittedFile[] { ]; } +function emitAgent(component: ComponentSource): EmittedFile[] { + const { manifest, body } = component; + const lines = [ + '---', + `name: ${yamlValue(manifest.name)}`, + `description: ${yamlValue(manifest.description)}`, + ]; + if (manifest.agent?.tools) lines.push(`tools: [${manifest.agent.tools.join(', ')}]`); + if (manifest.agent?.model) lines.push(`model: ${yamlValue(manifest.agent.model)}`); + if (manifest.agent?.color) lines.push(`color: ${yamlValue(manifest.agent.color)}`); + lines.push('---'); + + return [ + { + path: `agents/${manifest.name}.md`, + content: `${lines.join('\n')}\n\n${body.trimStart()}`, + }, + ]; +} + function emitRules(component: ComponentSource, ctx: AdapterContext): EmittedFile[] { if (!isOwnerOfRulesFile(component, ctx.allComponents, 'gemini')) return []; const scope = component.manifest.scope ?? 'project'; - const sorted = selectRules(ctx.allComponents, 'gemini', scope); - const content = composeRulesBody(sorted); - // Project scope: GEMINI.md at repo root. User scope: ~/.gemini/GEMINI.md - // (path here is relative to dist/gemini/; the user-scope path is the - // installer's responsibility to relocate). - const filename = scope === 'user' ? '.gemini/GEMINI.md' : 'GEMINI.md'; - return [{ path: filename, content }]; + if (scope === 'user') { + const sorted = selectRules(ctx.allComponents, 'gemini', scope); + const content = composeRulesBody(sorted); + return [{ path: '.gemini/GEMINI.md', content }]; + } + const files: EmittedFile[] = [{ path: 'GEMINI.md', content: '@AGENTS.md\n' }]; + if ((ctx.agentsMdOwnerTarget ?? 'gemini') === 'gemini') { + const sorted = selectRulesForAnyTarget(ctx.allComponents, ctx.targets ?? ['gemini'], 'project'); + files.unshift({ path: 'AGENTS.md', content: composeRulesBody(sorted) }); + } + return files; } async function emitHook(component: ComponentSource): Promise { diff --git a/src/lib/ac/compose.ts b/src/lib/ac/compose.ts index 0b28821..0fd1a37 100644 --- a/src/lib/ac/compose.ts +++ b/src/lib/ac/compose.ts @@ -131,6 +131,8 @@ async function emitForTarget( projectDir: string, repoConfig: Record>, restrictTo: Set | undefined, + targets: Target[], + agentsMdOwnerTarget: Target | undefined, ): Promise { const adapter = getAdapter(target); if (!adapter) { @@ -155,8 +157,10 @@ async function emitForTarget( const ctx = { config: (repoConfig[target] ?? {}) as Record, - allComponents: eligible, + allComponents: catalog, repoRoot: projectDir, + targets, + agentsMdOwnerTarget, }; const out: PendingFile[] = []; @@ -324,6 +328,11 @@ export async function composeBundle( }); const repoConfig = await loadRepoConfig(args.projectDir); + const agentsMdOwnerTarget = targets.includes('claude-code') + ? 'claude-code' + : targets.includes('gemini') + ? 'gemini' + : undefined; const allFiles: PendingFile[] = []; for (const target of targets) { const targetFiles = await emitForTarget( @@ -333,6 +342,8 @@ export async function composeBundle( args.projectDir, repoConfig as Record>, args.restrictTo, + targets, + agentsMdOwnerTarget, ); allFiles.push(...targetFiles); } diff --git a/src/lib/rules.ts b/src/lib/rules.ts index b985d25..00acda9 100644 --- a/src/lib/rules.ts +++ b/src/lib/rules.ts @@ -53,6 +53,23 @@ export function selectRules( return topoSortRules(filtered); } +/** + * Filter rules to those targeting at least one active target for a shared + * portable instructions file such as root AGENTS.md. + */ +export function selectRulesForAnyTarget( + all: ComponentSource[], + targets: Target[], + scope: 'project' | 'user', +): ComponentSource[] { + const active = new Set(targets); + const filtered = all + .filter((c) => c.manifest.type === 'rules') + .filter((c) => (c.manifest.scope ?? 'project') === scope) + .filter((c) => c.manifest.targets.some((target) => active.has(target))); + return topoSortRules(filtered); +} + /** * Compose a rules body — `## ` headers separating each rule's body. * Output ends with a single trailing newline. diff --git a/src/lib/types.ts b/src/lib/types.ts index 72339a4..6be3e59 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -117,4 +117,8 @@ export interface AdapterContext { allComponents: ComponentSource[]; /** Repo root absolute path. */ repoRoot: string; + /** Active emit targets for this composition; adapters may use this for shared root files. */ + targets?: Target[]; + /** Target responsible for emitting shared root AGENTS.md, when any. */ + agentsMdOwnerTarget?: Target; } diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 05cada1..ba92dcb 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -14,7 +14,7 @@ const MATRIX: Record> = { skill: { 'claude-code': 'ok', codex: 'ok', gemini: 'ok', pi: 'ok' }, plugin: { 'claude-code': 'ok', codex: 'error', gemini: 'error', pi: 'ok' }, hook: { 'claude-code': 'ok', codex: 'ok', gemini: 'ok', pi: 'warn' }, - agent: { 'claude-code': 'ok', codex: 'ok', gemini: 'error', pi: 'ok' }, + agent: { 'claude-code': 'ok', codex: 'ok', gemini: 'ok', pi: 'ok' }, rules: { 'claude-code': 'ok', codex: 'ok', gemini: 'ok', pi: 'ok' }, mcp: { 'claude-code': 'ok', codex: 'ok', gemini: 'ok', pi: 'warn' }, outfit: { 'claude-code': 'ok', codex: 'ok', gemini: 'ok', pi: 'ok' }, diff --git a/src/tests/ac/up.test.ts b/src/tests/ac/up.test.ts index ab4113c..d098981 100644 --- a/src/tests/ac/up.test.ts +++ b/src/tests/ac/up.test.ts @@ -190,6 +190,81 @@ describe('runUp — basic apply', () => { expect(out).toMatch(/Lockfile:/); }); + it('writes one shared AGENTS.md for mixed Claude and Gemini project rules', async () => { + const wardrobe = await mkdirT('suit-wardrobe-mixed-rules-'); + await fs.mkdir(path.join(wardrobe, 'outfits', 'engineer'), { recursive: true }); + await fs.writeFile( + path.join(wardrobe, 'outfits', 'engineer', 'outfit.md'), + `--- +name: engineer +version: 1.0.0 +type: outfit +description: Engineering outfit +targets: [claude-code, gemini] +categories: [tooling] +--- +engineer body +`, + ); + await fs.mkdir(path.join(wardrobe, 'rules', 'claude-only'), { recursive: true }); + await fs.writeFile( + path.join(wardrobe, 'rules', 'claude-only', 'SKILL.md'), + `--- +name: claude-only +version: 1.0.0 +type: rules +description: Claude-only rule +targets: [claude-code] +scope: project +--- +Claude rule body. +`, + ); + await fs.mkdir(path.join(wardrobe, 'rules', 'gemini-only'), { recursive: true }); + await fs.writeFile( + path.join(wardrobe, 'rules', 'gemini-only', 'SKILL.md'), + `--- +name: gemini-only +version: 1.0.0 +type: rules +description: Gemini-only rule +targets: [gemini] +scope: project +--- +Gemini rule body. +`, + ); + const proj = await mkProject(); + const userDir = await mkdirT('suit-up-user-'); + const cap = capture(); + + const code = await runUp( + { + outfit: 'engineer', + cut: null, + accessories: [], + force: false, + projectDir: proj, + contentDir: wardrobe, + userDir, + isTTY: false, + }, + { stdout: cap.push, stderr: cap.pushE }, + ); + + expect(cap.err.join('')).toBe(''); + expect(code).toBe(0); + const agentsMd = await fs.readFile(path.join(proj, 'AGENTS.md'), 'utf8'); + expect(agentsMd).toContain('## claude-only'); + expect(agentsMd).toContain('Claude rule body.'); + expect(agentsMd).toContain('## gemini-only'); + expect(agentsMd).toContain('Gemini rule body.'); + const claudeMd = await fs.readFile(path.join(proj, 'CLAUDE.md'), 'utf8'); + expect(claudeMd).toContain('@AGENTS.md'); + const geminiMd = await fs.readFile(path.join(proj, 'GEMINI.md'), 'utf8'); + expect(geminiMd).toBe('@AGENTS.md\n'); + }); + it('refuses when a target file exists and is not suit-managed', async () => { const wardrobe = await mkWardrobe(); const proj = await mkProject(); diff --git a/src/tests/adapters/claude-code.test.ts b/src/tests/adapters/claude-code.test.ts index be32a1f..6c44a54 100644 --- a/src/tests/adapters/claude-code.test.ts +++ b/src/tests/adapters/claude-code.test.ts @@ -7,6 +7,7 @@ import { claudeCodeAdapter } from '../../adapters/claude-code.ts'; import { ManifestSchema } from '../../lib/schema.ts'; import type { ComponentSource } from '../../lib/types.ts'; import { runGolden } from './golden.ts'; +import { mergeBuffers } from '../../lib/merge.ts'; const HERE = path.resolve(fileURLToPath(import.meta.url), '..'); @@ -33,21 +34,54 @@ describe('claude-code adapter', () => { expect(result.diff).toEqual([]); }); - it('composes rules into one CLAUDE.md ordered by before/after', async () => { + it('composes project rules into AGENTS.md plus a CLAUDE.md shim', async () => { const root = path.join(HERE, 'claude-code/rules-compose'); const baseStyle = await loadComponent(path.join(root, 'component'), root); const prPolicy = await loadComponent(path.join(root, 'extra/rules/pr-policy'), root); const all = [baseStyle, prPolicy]; - const results = await Promise.all( - all.map((c) => - claudeCodeAdapter.emit(c, { config: {}, allComponents: all, repoRoot: root }), - ), + const files = ( + await Promise.all( + all.map((c) => + claudeCodeAdapter.emit(c, { config: {}, allComponents: all, repoRoot: root }), + ), + ) + ).flat(); + const expectedRules = await fs.readFile(path.join(root, 'expected/AGENTS.md'), 'utf8'); + expect(files.find((f) => f.path === 'AGENTS.md')?.content.toString()).toBe( + expectedRules, ); - const claudeMd = results.flat().find((f) => f.path === 'CLAUDE.md'); - const expected = await fs.readFile(path.join(root, 'expected/CLAUDE.md'), 'utf8'); - expect(claudeMd?.content.toString()).toBe(expected); - const claudeMdCount = results.flat().filter((f) => f.path === 'CLAUDE.md').length; - expect(claudeMdCount).toBe(1); + expect(files.find((f) => f.path === 'CLAUDE.md')?.content.toString()).toBe( + '@AGENTS.md\n', + ); + expect(files.filter((f) => f.path === 'AGENTS.md').length).toBe(1); + expect(files.filter((f) => f.path === 'CLAUDE.md').length).toBe(1); + }); + + it('preserves user-scope rules in .claude/CLAUDE.md', async () => { + const component: ComponentSource = { + dir: HERE, + relativeDir: 'rules/user-style', + manifest: ManifestSchema.parse({ + name: 'user-style', + version: '0.0.0', + description: 'User style rules', + type: 'rules', + scope: 'user', + targets: ['claude-code'], + }), + body: 'Keep personal Claude rules here.\n', + }; + const files = await claudeCodeAdapter.emit(component, { + config: {}, + allComponents: [component], + repoRoot: HERE, + }); + expect(files).toEqual([ + { + path: '.claude/CLAUDE.md', + content: '## user-style\n\nKeep personal Claude rules here.\n', + }, + ]); }); it('emits a hook component with settings fragment + script', async () => { @@ -55,11 +89,59 @@ describe('claude-code adapter', () => { expect(result.diff).toEqual([]); }); - it('emits an mcp component as .mcp.fragment.json', async () => { + it('emits an mcp component as .mcp.json', async () => { const result = await runGolden(claudeCodeAdapter, path.join(HERE, 'claude-code/mcp-basic')); expect(result.diff).toEqual([]); }); + it('emits multiple mcp components to the same mergeable .mcp.json path', async () => { + const root = path.join(HERE, 'claude-code/mcp-basic'); + const first = await loadComponent(path.join(root, 'component'), root); + const second: ComponentSource = { + ...first, + manifest: { + ...first.manifest, + name: 'other-mcp', + mcp: { + command: 'python', + args: ['server.py'], + }, + }, + }; + const firstEmit = await claudeCodeAdapter.emit(first, { + config: {}, + allComponents: [first, second], + repoRoot: root, + }); + const secondEmit = await claudeCodeAdapter.emit(second, { + config: {}, + allComponents: [first, second], + repoRoot: root, + }); + const firstMcp = firstEmit.find((f) => f.path === '.mcp.json'); + const secondMcp = secondEmit.find((f) => f.path === '.mcp.json'); + expect(firstMcp).toBeDefined(); + expect(secondMcp).toBeDefined(); + + const merged = mergeBuffers('.mcp.json', firstMcp!.content, secondMcp!.content); + expect(merged).not.toBeNull(); + expect(JSON.parse(merged!.toString())).toEqual({ + mcpServers: { + 'my-mcp': { + command: 'node', + args: ['server.js'], + env: { + LOG_LEVEL: 'debug', + }, + }, + 'other-mcp': { + command: 'python', + args: ['server.py'], + }, + }, + }); + }); + it('emits a plugin component listing included skills', async () => { const root = path.join(HERE, 'claude-code/plugin-basic'); const plugin = await loadComponent(path.join(root, 'component'), root); diff --git a/src/tests/adapters/claude-code/mcp-basic/expected/.mcp.fragment.json b/src/tests/adapters/claude-code/mcp-basic/expected/.mcp.json similarity index 100% rename from src/tests/adapters/claude-code/mcp-basic/expected/.mcp.fragment.json rename to src/tests/adapters/claude-code/mcp-basic/expected/.mcp.json diff --git a/src/tests/adapters/claude-code/rules-compose/expected/AGENTS.md b/src/tests/adapters/claude-code/rules-compose/expected/AGENTS.md new file mode 100644 index 0000000..45b3edd --- /dev/null +++ b/src/tests/adapters/claude-code/rules-compose/expected/AGENTS.md @@ -0,0 +1,7 @@ +## base-style + +Always prefer dependency injection. + +## pr-policy + +Open PRs against main. diff --git a/src/tests/adapters/claude-code/rules-compose/expected/CLAUDE.md b/src/tests/adapters/claude-code/rules-compose/expected/CLAUDE.md index 45b3edd..43c994c 100644 --- a/src/tests/adapters/claude-code/rules-compose/expected/CLAUDE.md +++ b/src/tests/adapters/claude-code/rules-compose/expected/CLAUDE.md @@ -1,7 +1 @@ -## base-style - -Always prefer dependency injection. - -## pr-policy - -Open PRs against main. +@AGENTS.md diff --git a/src/tests/adapters/gemini.test.ts b/src/tests/adapters/gemini.test.ts index 6d8f124..fcdce61 100644 --- a/src/tests/adapters/gemini.test.ts +++ b/src/tests/adapters/gemini.test.ts @@ -28,22 +28,54 @@ describe('gemini adapter', () => { expect(result.matched).toBe(true); }); - it('composes rules into a single GEMINI.md ordered by before/after', async () => { + it('composes project rules into AGENTS.md plus a GEMINI.md shim', async () => { const root = path.join(HERE, 'gemini/rules-compose'); const baseStyle = await loadComponent(path.join(root, 'component'), root); const prPolicy = await loadComponent(path.join(root, 'extra/rules/pr-policy'), root); const all = [baseStyle, prPolicy]; - const results = await Promise.all( - all.map((c) => - geminiAdapter.emit(c, { config: {}, allComponents: all, repoRoot: root }), - ), + const files = ( + await Promise.all( + all.map((c) => + geminiAdapter.emit(c, { config: {}, allComponents: all, repoRoot: root }), + ), + ) + ).flat(); + const expectedRules = await fs.readFile(path.join(root, 'expected/AGENTS.md'), 'utf8'); + expect(files.find((f) => f.path === 'AGENTS.md')?.content.toString()).toBe( + expectedRules, ); - const geminiMd = results.flat().find((f) => f.path === 'GEMINI.md'); - const expected = await fs.readFile(path.join(root, 'expected/GEMINI.md'), 'utf8'); - expect(geminiMd?.content.toString()).toBe(expected); - // Idempotent: only one rule emits the file. - const count = results.flat().filter((f) => f.path === 'GEMINI.md').length; - expect(count).toBe(1); + expect(files.find((f) => f.path === 'GEMINI.md')?.content.toString()).toBe( + '@AGENTS.md\n', + ); + expect(files.filter((f) => f.path === 'AGENTS.md').length).toBe(1); + expect(files.filter((f) => f.path === 'GEMINI.md').length).toBe(1); + }); + + it('preserves user-scope rules in .gemini/GEMINI.md', async () => { + const component: ComponentSource = { + dir: HERE, + relativeDir: 'rules/user-style', + manifest: ManifestSchema.parse({ + name: 'user-style', + version: '0.0.0', + description: 'User style rules', + type: 'rules', + scope: 'user', + targets: ['gemini'], + }), + body: 'Keep personal Gemini rules here.\n', + }; + const files = await geminiAdapter.emit(component, { + config: {}, + allComponents: [component], + repoRoot: HERE, + }); + expect(files).toEqual([ + { + path: '.gemini/GEMINI.md', + content: '## user-style\n\nKeep personal Gemini rules here.\n', + }, + ]); }); it('emits a hook component with .gemini/settings fragment + script', async () => { @@ -83,23 +115,9 @@ describe('gemini adapter', () => { expect(result.diff).toEqual([]); }); - it('throws a clear error when emitting an agent (validator should have caught it)', async () => { - const fake: ComponentSource = { - dir: '/tmp/fake', - relativeDir: 'agents/fake', - body: 'body', - manifest: { - name: 'fake', - version: '1.0.0', - description: 'd', - type: 'agent', - targets: ['gemini'], - agent: { tools: ['Read'] }, - } as ComponentSource['manifest'], - }; - await expect( - geminiAdapter.emit(fake, { config: {}, allComponents: [fake], repoRoot: '/tmp/fake' }), - ).rejects.toThrow(/not supported on Gemini/); + it('emits an agent component as Markdown with YAML frontmatter', async () => { + const result = await runGolden(geminiAdapter, path.join(HERE, 'gemini/agent-basic')); + expect(result.diff).toEqual([]); }); it('throws a clear error when emitting a plugin', async () => { diff --git a/src/tests/adapters/gemini/agent-basic/component/SKILL.md b/src/tests/adapters/gemini/agent-basic/component/SKILL.md new file mode 100644 index 0000000..9e1801b --- /dev/null +++ b/src/tests/adapters/gemini/agent-basic/component/SKILL.md @@ -0,0 +1,19 @@ +--- +name: code-reviewer +version: 1.0.0 +description: Reviews code for quality issues +type: agent +targets: + - gemini +agent: + tools: + - Read + - Grep + - Bash + model: gemini-2.5-pro + color: blue +--- + +# Code Reviewer + +Review the code for issues. diff --git a/src/tests/adapters/gemini/agent-basic/expected/agents/code-reviewer.md b/src/tests/adapters/gemini/agent-basic/expected/agents/code-reviewer.md new file mode 100644 index 0000000..b198639 --- /dev/null +++ b/src/tests/adapters/gemini/agent-basic/expected/agents/code-reviewer.md @@ -0,0 +1,11 @@ +--- +name: code-reviewer +description: Reviews code for quality issues +tools: [Read, Grep, Bash] +model: gemini-2.5-pro +color: blue +--- + +# Code Reviewer + +Review the code for issues. diff --git a/src/tests/adapters/gemini/integration.test.ts b/src/tests/adapters/gemini/integration.test.ts index 242ac03..ea79345 100644 --- a/src/tests/adapters/gemini/integration.test.ts +++ b/src/tests/adapters/gemini/integration.test.ts @@ -36,9 +36,11 @@ describe('gemini end-to-end build', () => { const skillBody = await fs.readFile(path.join(repo, 'dist/gemini/skills/foo/skill.md'), 'utf8'); expect(skillBody).toContain('Foo body.'); - // Rules composed in alphabetical-with-after order: style → pr-policy. + // Rules composed in AGENTS.md; GEMINI.md is a thin shim. const geminiMd = await fs.readFile(path.join(repo, 'dist/gemini/GEMINI.md'), 'utf8'); - expect(geminiMd.indexOf('## style')).toBeLessThan(geminiMd.indexOf('## pr-policy')); + expect(geminiMd).toBe('@AGENTS.md\n'); + const agentsMd = await fs.readFile(path.join(repo, 'dist/gemini/AGENTS.md'), 'utf8'); + expect(agentsMd.indexOf('## style')).toBeLessThan(agentsMd.indexOf('## pr-policy')); // MCP fragment present. const mcpFragment = JSON.parse( diff --git a/src/tests/adapters/gemini/rules-compose/expected/AGENTS.md b/src/tests/adapters/gemini/rules-compose/expected/AGENTS.md new file mode 100644 index 0000000..45b3edd --- /dev/null +++ b/src/tests/adapters/gemini/rules-compose/expected/AGENTS.md @@ -0,0 +1,7 @@ +## base-style + +Always prefer dependency injection. + +## pr-policy + +Open PRs against main. diff --git a/src/tests/adapters/gemini/rules-compose/expected/GEMINI.md b/src/tests/adapters/gemini/rules-compose/expected/GEMINI.md index 45b3edd..43c994c 100644 --- a/src/tests/adapters/gemini/rules-compose/expected/GEMINI.md +++ b/src/tests/adapters/gemini/rules-compose/expected/GEMINI.md @@ -1,7 +1 @@ -## base-style - -Always prefer dependency injection. - -## pr-policy - -Open PRs against main. +@AGENTS.md diff --git a/src/tests/content-store.test.ts b/src/tests/content-store.test.ts index d1c5ab2..f3a924a 100644 --- a/src/tests/content-store.test.ts +++ b/src/tests/content-store.test.ts @@ -74,6 +74,7 @@ describe('ContentStore.init', () => { expect(r.ok).toBe(true); expect(existsSync(path.join(target, '.git'))).toBe(true); expect(existsSync(path.join(target, 'README.md'))).toBe(true); + expect(existsSync(path.join(target, '.suit-source'))).toBe(false); }); it('returns ok=false if target exists and force=false', async () => { diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 52892d3..1b4f986 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -26,8 +26,10 @@ describe('end-to-end build', () => { const skillOut = await fs.readFile(path.join(repo, 'dist/claude-code/skills/foo/SKILL.md'), 'utf8'); expect(skillOut).toContain('Foo body.'); const claudeMd = await fs.readFile(path.join(repo, 'dist/claude-code/CLAUDE.md'), 'utf8'); - expect(claudeMd).toContain('## style'); - expect(claudeMd).toContain('Use spaces.'); + expect(claudeMd).toBe('@AGENTS.md\n'); + const agentsMd = await fs.readFile(path.join(repo, 'dist/claude-code/AGENTS.md'), 'utf8'); + expect(agentsMd).toContain('## style'); + expect(agentsMd).toContain('Use spaces.'); const pluginJson = await fs.readFile( path.join(repo, 'dist/claude-code/.claude-plugin/plugin.json'), 'utf8', diff --git a/src/tests/validate.test.ts b/src/tests/validate.test.ts index e5eca5c..c4b9e39 100644 --- a/src/tests/validate.test.ts +++ b/src/tests/validate.test.ts @@ -127,8 +127,8 @@ describe('validateComponents category warnings', () => { }); }); -describe('validateComponents — Gemini-specific rejections', () => { - it('rejects agent type targeting gemini', () => { +describe('validateComponents — Gemini-specific compatibility', () => { + it('allows agent type targeting gemini', () => { const errors = validateComponents([ mk({ name: 'rev', @@ -137,7 +137,7 @@ describe('validateComponents — Gemini-specific rejections', () => { agent: { tools: ['Read'], model: 'gemini-2.0-flash' }, }), ]); - expect(errors.some((e) => e.severity === 'error' && /agent.*gemini/i.test(e.message))).toBe(true); + expect(errors.some((e) => e.severity === 'error' && /agent.*gemini/i.test(e.message))).toBe(false); }); it('rejects plugin type targeting gemini', () => {