diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..53c7d22
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,39 @@
+name: Validate
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Test
+ run: npm test
+
+ - name: Test packed artifact
+ run: npm run test:package
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7184ebc
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,71 @@
+name: Release package
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ environment: package-release
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Verify tag commit is on main
+ run: |
+ git fetch --no-tags origin main
+ git merge-base --is-ancestor "$GITHUB_SHA" origin/main || {
+ echo "Release tags must point to commits reachable from origin/main."
+ exit 1
+ }
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ cache: npm
+
+ - name: Verify tag matches package version
+ run: >-
+ node -e 'const p=require("./package.json");
+ if (process.env.GITHUB_REF_NAME !== "v" + p.version) {
+ console.error("Tag " + process.env.GITHUB_REF_NAME + " does not match package version " + p.version);
+ process.exit(1); }'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Test
+ run: npm test
+
+ - name: Test packed artifact
+ run: npm run test:package
+
+ - name: Pack release artifact
+ id: pack
+ run: |
+ FILENAME="$(npm pack --json | node -e 'const data=JSON.parse(require("fs").readFileSync(0,"utf8")); process.stdout.write(data[0].filename)')"
+ cp "$FILENAME" ca-ai-tools-setup.tgz
+ node -e 'const p=require("./package.json"); require("fs").appendFileSync(process.env.GITHUB_OUTPUT, "prerelease=" + (p.version.includes("-") ? "true" : "false") + "\n")'
+ echo "Packed $FILENAME as ca-ai-tools-setup.tgz"
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: ca-ai-tools-setup.tgz
+ generate_release_notes: true
+ prerelease: ${{ steps.pack.outputs.prerelease == 'true' }}
diff --git a/.gitignore b/.gitignore
index dc3b2f1..0bbcffd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
node_modules/
dist/
+.idea/
.DS_Store
coverage/
metricinsights-*.tgz
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b8..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/ca-ai-tools-setup.iml b/.idea/ca-ai-tools-setup.iml
deleted file mode 100644
index c956989..0000000
--- a/.idea/ca-ai-tools-setup.iml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 03d9549..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index aa0e110..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 9ce4f4d..b8ef5ac 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,7 +51,8 @@ node dist/cli.js --target ../some-other-repo --assistants cursor,claude --dry-ru
**File overwrite policy (generator.ts):**
- Setup assistant markdown files (`setup-cursor-assistant.md`, `setup-claude-assistant.md`) are always overwritten
-- `CLAUDE.md`, `.cursorrules`, `.cursor/skills/*`, `.claude/skills/*`, `.claude/settings.json`, `AGENTS.md`, `.dev-environment.md` — created on first run, skipped on subsequent runs unless `--force`
+- `CLAUDE.md`, `.cursorrules`, `.cursor/skills/*`, `.claude/skills/*`, `.claude/settings.json`, `.dev-environment.md` — created on first run, skipped on subsequent runs unless `--force`
+- Existing `AGENTS.md` is never replaced, including with `--force`; missing generated agent rows are merged while repository-owned content is preserved
- Obsolete legacy QA paths (`ai-testing`, `ui-check` skills, `.claude/workflows/ui-check.md`) are removed on every re-run via `REMOVABLE_LEGACY_SETUP_PATHS`
- MCP JSON files — interactive prompt (Skip/Merge/Overwrite) in interactive mode; left unchanged with `--yes`; fully replaced with `--force`
diff --git a/CLAUDE.md b/CLAUDE.md
index 965d409..13eaaa9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,7 +50,8 @@ node dist/cli.js --target ../some-other-repo --assistants cursor,claude --dry-ru
**File overwrite policy (generator.ts):**
- Setup assistant markdown files (`setup-cursor-assistant.md`, `setup-claude-assistant.md`) are always overwritten
-- `CLAUDE.md`, `.cursorrules`, `.cursor/skills/ui-check/SKILL.md`, `.claude/skills/ui-check/SKILL.md`, `.claude/settings.json`, `AGENTS.md`, `.dev-environment.md` — created on first run, skipped on subsequent runs unless `--force`
+- `CLAUDE.md`, `.cursorrules`, `.cursor/skills/ui-check/SKILL.md`, `.claude/skills/ui-check/SKILL.md`, `.claude/settings.json`, `.dev-environment.md` — created on first run, skipped on subsequent runs unless `--force`
+- Existing `AGENTS.md` is never replaced, including with `--force`; missing generated agent rows are merged while repository-owned content is preserved
- MCP JSON files — interactive prompt (Skip/Merge/Overwrite) in interactive mode; left unchanged with `--yes`; fully replaced with `--force`
## Testing
diff --git a/README.md b/README.md
index 268e117..884cded 100644
--- a/README.md
+++ b/README.md
@@ -4,23 +4,25 @@ Bootstrap Metric Insights Linear CLI setup files for both Cursor and Claude.
## What this package generates
-**Shared (every run):** `LINEAR_CLI.md`, `AGENTS.md`, `.dev-environment.md`, `.assistant-setup/page-workflow-context.md`, `.assistant-setup/ca-ai-tools-setup.json`
+**Shared (every run):** `LINEAR_CLI.md`, `AGENTS.md`, `.dev-environment.md`,
+`.assistant-setup/page-workflow-context.md`, `.assistant-setup/SETUP_STATUS.md`,
+`.assistant-setup/ca-ai-tools-setup.json`
**Cursor rules (Cursor and/or Claude):** `.cursor/rules/*.mdc` — Claude Code follows the same rules. Emitted for **Claude-only** runs too.
-| Path | When |
-|------|------|
-| `setup-cursor-assistant.md` | Cursor selected |
-| `.cursorrules`, `.cursorignore` | Cursor selected |
-| `.cursor/rules/*` (code-style, linear-cli, linear-task-gates, portal-env-credentials, test-case-rules, test-suite-template, README; `figma-mcp.mdc` if Figma MCP) | Cursor and/or Claude |
-| `.cursor/skills/*` (ai-development + DOD-FULL, testing-flow, testing-with-linear, ui-check-simple, linear-report, linear-workflow, test-documentation, playwright-mcp, figma-implementation, form-builder; figma-code-connect + references if Figma MCP) | Cursor selected |
-| `.cursor/prompts/react-component-unit.md` | Cursor selected |
-| `.cursor/mcp.json`, `.cursor/ca-ai-tools-setup.json` | Cursor + MCP option |
-| `setup-claude-assistant.md`, `CLAUDE.md`, `.claude/settings.json` | Claude selected |
-| `.claude/skills/*` (same skill set as Cursor, under `.claude/skills/`) | Claude selected |
-| `.claude/agents/code-style.md` | Claude selected |
-| `.claude/agents/figma-mcp.md` | Claude + Figma MCP |
-| `.mcp.json` (repo root) | Claude + MCP option |
+| Path | When |
+| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
+| `setup-cursor-assistant.md` | Cursor selected |
+| `.cursorrules`, `.cursorignore` | Cursor selected |
+| `.cursor/rules/*` (assistant-setup-health, code-style, linear-cli, linear-task-gates, portal-env-credentials, test-case-rules, test-suite-template, README; `figma-mcp.mdc` if Figma MCP) | Cursor and/or Claude |
+| `.cursor/skills/*` (ai-development + DOD-FULL, testing-flow, testing-with-linear, ui-check-simple, linear-report, linear-workflow, test-documentation, playwright-mcp, figma-implementation, form-builder; figma-code-connect + references if Figma MCP) | Cursor selected |
+| `.cursor/prompts/react-component-unit.md` | Cursor selected |
+| `.cursor/mcp.json`, `.cursor/ca-ai-tools-setup.json` | Cursor + MCP option |
+| `setup-claude-assistant.md`, `CLAUDE.md`, `.claude/settings.json` | Claude selected |
+| `.claude/skills/*` (same skill set as Cursor, under `.claude/skills/`) | Claude selected |
+| `.claude/agents/code-style.md` | Claude selected |
+| `.claude/agents/figma-mcp.md` | Claude + Figma MCP |
+| `.mcp.json` (repo root) | Claude + MCP option |
Skip/`--force` behavior: setup assistant markdown is always refreshed; most other paths are created once, then skipped unless `--force` (see package docs below).
@@ -36,170 +38,209 @@ When auditing `templates/` for drift against a reference repo:
## Distribution
-This package is **private** and consumed **directly from its GitHub repository** (not the public npm registry). Push changes to the repo; consumers install with npm’s GitHub shorthand.
+This public repository ships a **prebuilt npm tarball** as a GitHub Release asset named
+**`ca-ai-tools-setup.tgz`**. A release tag such as **`v0.1.0`** must match `package.json`; release CI validates
+the source, smoke-tests the packed artifact, verifies the tag commit is on **`main`**, and attaches the tarball
+to the GitHub Release. Prerelease versions (`1.2.3-rc.1`) create a GitHub prerelease.
-Authenticate to the private repo the same way you clone it (SSH, or HTTPS with a [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and credential helper).
+No GitHub Packages registry or package token is required. Developers install the CLI directly from the public
+release asset:
-Optional: pin a **branch**, **tag**, or **commit** after `#` (for example `github:mi-examples/ca-ai-tools-setup#main` or `github:mi-examples/ca-ai-tools-setup#v0.1.0`).
-
-The **`prepare`** script runs **`npm run build`** after **`npm install`** (including installs from `github:…` and `npm pack`), so **`dist/`** is generated and is not committed to git. **`dist/`** contains only compiled **`src/`** (the CLI and library JS); tests stay in **`tests/*.ts`** and are run with **`tsx`** — they are not emitted into **`dist/`**.
+```bash
+# Latest stable release
+https://github.com/mi-examples/ca-ai-tools-setup/releases/latest/download/ca-ai-tools-setup.tgz
-## Usage
+# Exact version (preferred for reviewable setup/update PRs)
+https://github.com/mi-examples/ca-ai-tools-setup/releases/download/v0.1.0/ca-ai-tools-setup.tgz
+```
-Binary name: **`ca-ai-tools-setup`**. Package spec: **`github:mi-examples/ca-ai-tools-setup`** (optional pin: **`#main`**, **`#v0.1.0`**, commit hash). Below, **`TARGET`** is another repo path; omit **`--target`** to use the **current directory**.
+The `package-release` GitHub environment should require an internal reviewer before the release job can publish.
+To release, merge a reviewed version bump, create and push the matching `vX.Y.Z` tag, then approve the protected
+job. Roll back a target repository by running `check` and `update` with the previous release tarball and reviewing
+the reverse diff.
-The subsections **Interactive** through **Local clone** show **`npx`** invocations; swap the **`npx -p github:… ca-ai-tools-setup`** prefix for **`pnpm --package=… exec`**, **`yarn dlx …`**, or **`bunx …`** as in **Fetching the CLI with pnpm, Yarn, or Bun** — all other flags stay the same.
+The tarball contains prebuilt **`dist/**`** plus **`templates/**`**; developer machines do not compile TypeScript
+during installation.
-### Fetching the CLI with pnpm, Yarn, or Bun
+## Usage
-One-shot install + run from GitHub (equivalent to **`npx -p … ca-ai-tools-setup`**):
+Binary name: **`ca-ai-tools-setup`**. Below, **`TARGET`** is another repo path; omit **`--target`** to use the
+**current directory**.
-```bash
-pnpm --package=github:mi-examples/ca-ai-tools-setup exec ca-ai-tools-setup --assistants cursor,claude --yes
-```
+Set a package URL once, then reuse it with **`npx`** or **`pnpm`**:
```bash
-yarn dlx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants cursor,claude --yes
+export CA_AI_TOOLS_SETUP_TGZ=https://github.com/mi-examples/ca-ai-tools-setup/releases/latest/download/ca-ai-tools-setup.tgz
+# or pin a version:
+# export CA_AI_TOOLS_SETUP_TGZ=https://github.com/mi-examples/ca-ai-tools-setup/releases/download/v0.1.0/ca-ai-tools-setup.tgz
```
```bash
-bunx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants cursor,claude --yes
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup
+pnpm --package="$CA_AI_TOOLS_SETUP_TGZ" exec ca-ai-tools-setup
```
-- **pnpm:** **`pnpm exec`** runs the **`bin`** from the temporary **`--package`** install; add **`--`** before **`ca-ai-tools-setup`** only if your shell swallows flags meant for the CLI.
-- **Yarn:** requires **Yarn 2+** (**`yarn dlx`**). **Yarn 1 (Classic)** has no equivalent — use **`npx`** or **`pnpm exec`** for GitHub one-shots.
-- **Bun:** **`bunx`** (same idea as **`npx`**). You can also try **`bun x …`** if you standardize on Bun’s CLI.
+- **pnpm:** **`pnpm exec`** runs the **`bin`** from the temporary **`--package`** install; add **`--`** before
+ **`ca-ai-tools-setup`** only if your shell swallows flags meant for the CLI.
+- **Yarn / Bun:** prefer **`npx`** or **`pnpm`** for HTTPS tarball one-shots.
### Interactive (prompts for assistants, MCP, QA rules)
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup
-```
-
-```bash
-pnpm --package=github:mi-examples/ca-ai-tools-setup exec ca-ai-tools-setup
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup
```
```bash
-yarn dlx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --target ../my-app
```
-```bash
-bunx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup
-```
+### Non-interactive — defaults (`--yes`)
-```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app
-```
+**Selects** both assistants, Playwright MCP **on**, Figma MCP **off**, QA AI rules **off**. Emits
+**`.cursor/mcp.json`** / **`.mcp.json`** when MCP is enabled for the selected assistants.
```bash
-pnpm --package=github:mi-examples/ca-ai-tools-setup exec ca-ai-tools-setup --target ../my-app
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --assistants cursor,claude --yes
```
```bash
-yarn dlx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes
```
```bash
-bunx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app
+pnpm --package="$CA_AI_TOOLS_SETUP_TGZ" exec ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes
```
-### Non-interactive — defaults (`--yes`)
-
-**Selects** both assistants, Playwright MCP **on**, Figma MCP **off**, QA AI rules **off**. Emits **`.cursor/mcp.json`** / **`.mcp.json`** when MCP is enabled for the selected assistants.
+### Preview only (`--dry-run`)
-**npm:**
+No files written; QA AI rules init is **not** executed.
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants cursor,claude --yes
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes --dry-run
```
-**pnpm / Yarn / Bun:** use the same shape as in **Fetching the CLI with pnpm, Yarn, or Bun** (same flags: **`--assistants cursor,claude --yes`**). Example with **`--target`:**
+### One assistant only
```bash
-pnpm --package=github:mi-examples/ca-ai-tools-setup exec ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --assistants cursor --yes
```
```bash
-yarn dlx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --assistants claude --yes
```
+### MCP — disable Playwright or enable Figma
+
+Disable Playwright MCP (no **`.cursor/mcp.json`** / **`.mcp.json`** from this run unless Figma is on):
+
```bash
-bunx github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --assistants cursor,claude --yes --mcp-playwright none
```
+Enable **both** Playwright and Figma MCP (requires **`FIGMA_API_KEY`** where Figma is used):
+
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --assistants cursor,claude --yes --mcp-playwright yes --mcp-figma yes
```
-### Preview only (`--dry-run`)
+### QA AI rules (`@metricinsights/qa-ai-rules`)
-No files written; QA AI rules init is **not** executed.
+After generating files, runs **`init`** for the package using the detected runner (**`pnpm dlx`**, **`yarn dlx`**,
+**`bunx`**, or **`npx`**) with **`--cursor`** / **`--claude`** aligned to **`--assistants`**. Needs **`package.json`**
+in the target repo.
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes --dry-run
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --assistants cursor,claude --yes --qa-ai-rules yes
```
```bash
-pnpm --package=github:mi-examples/ca-ai-tools-setup exec ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes --dry-run
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --target ../my-app --assistants cursor --yes --qa-ai-rules yes
```
-### One assistant only
-
-```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants cursor --yes
-```
+### Overwrite existing generated files
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants claude --yes
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes --force
```
-### MCP — disable Playwright or enable Figma
+### Local clone (development)
-Disable Playwright MCP (no **`.cursor/mcp.json`** / **`.mcp.json`** from this run unless Figma is on):
+From this repository after **`npm install && npm run build`**:
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants cursor,claude --yes --mcp-playwright none
+node dist/cli.js --target ../my-app --assistants cursor,claude --dry-run
```
-Enable **both** Playwright and Figma MCP (requires **`FIGMA_API_KEY`** where Figma is used):
+## Tracked setup and update workflow
-```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants cursor,claude --yes --mcp-playwright yes --mcp-figma yes
-```
+All generated Cursor and Claude files are intended to be committed. Any authenticated developer may prepare
+the initial setup or an update; after the PR merges, everyone else receives the files through a normal pull.
+The installer is not added to the application package or lockfile.
-### QA AI rules (`@metricinsights/qa-ai-rules`)
+### Initial setup
-After generating files, runs **`init`** for the package using the detected runner (**`pnpm dlx`**, **`yarn dlx`**, **`bunx`**, or **`npx`**) with **`--cursor`** / **`--claude`** aligned to **`--assistants`**. Needs **`package.json`** in the target repo.
+Use an exact release tarball, inspect the complete generated diff, run the target repository's validation, and
+open a setup PR:
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --assistants cursor,claude --yes --qa-ai-rules yes
+export CA_AI_TOOLS_SETUP_TGZ=https://github.com/mi-examples/ca-ai-tools-setup/releases/download/v0.1.0/ca-ai-tools-setup.tgz
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup \
+ --target ../my-app --assistants cursor,claude --yes
```
-```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app --assistants cursor --yes --qa-ai-rules yes
-```
+### Check for changes
-### Overwrite existing generated files
+`check` is read-only. It returns exit code `0` when the tracked setup is synchronized, `2` when files or metadata
+need attention, and `1` for invalid input or I/O failures:
```bash
-npx -p github:mi-examples/ca-ai-tools-setup ca-ai-tools-setup --target ../my-app --assistants cursor,claude --yes --force
+export CA_AI_TOOLS_SETUP_TGZ=https://github.com/mi-examples/ca-ai-tools-setup/releases/download/v0.2.0/ca-ai-tools-setup.tgz
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup check ../my-app
```
-### Local clone (development)
+### Prepare an update PR
-From this repository after **`npm install`**:
+Run a preview, apply the update, review `git diff`, resolve any reported protected-file conflicts, validate the
+target repository, and open a normal PR. `update --dry-run` also exits `2` when changes or conflicts are pending:
```bash
-node dist/cli.js --target ../my-app --assistants cursor,claude --dry-run
+export CA_AI_TOOLS_SETUP_TGZ=https://github.com/mi-examples/ca-ai-tools-setup/releases/download/v0.2.0/ca-ai-tools-setup.tgz
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup update ../my-app --dry-run
+npx --yes --package="$CA_AI_TOOLS_SETUP_TGZ" ca-ai-tools-setup update ../my-app
```
+Update ownership rules:
+
+- Managed rules, skills, workflows, prompts, agents, and shared references update automatically only when their
+ current hash matches the recorded generated baseline.
+- Repository-owned files such as `.cursorrules`, `CLAUDE.md`, `.dev-environment.md`, and page context are
+ preserved and recorded as adopted content.
+- MCP JSON and Claude settings use semantic merge only when their baseline is unchanged.
+- Existing `AGENTS.md` content is never replaced, including with `--force`; missing generated agent rows are
+ merged into its registry table or appended as a separate generated section.
+- Locally modified managed/structured files block the entire update so a partial write cannot occur. Use
+ `--force` only when replacing those generated baselines is intentional.
+- Unchanged managed files removed by a release are deleted; modified or protected orphaned files are preserved
+ and reported.
+
+The metadata file records package version, release commit/template revision, and per-file SHA-256 hashes with
+line endings normalized for Windows/macOS/Linux checkouts. It contains no timestamp, so identical content
+produces identical tracked output.
+
+`.assistant-setup/SETUP_STATUS.md` is the agent-facing marker. Its absence means setup is missing or incomplete;
+its embedded package version identifies what generated the repository. The always-on
+`.cursor/rules/assistant-setup-health.mdc` rule tells Cursor and Claude to run the read-only `check` command when
+freshness matters and to request approval before any update.
+
## Options
+- `check [target]`: inspect a tracked setup without writing; exits `2` when an update or migration is required
+- `update [target]`: apply a deterministic tracked-file update using the configuration stored in setup metadata
+- `--version` / `-v`: print the installed CLI package version
- `--target `: target repo directory (resolved from the current working directory; omit or press Enter in the prompt to use the current directory)
- `--assistants `: comma-separated assistants, e.g. `cursor,claude`
-- `--dry-run`: preview created/skipped/overwritten files without writing
-- `--force`: overwrite existing generated files (no merge prompts; MCP files are fully replaced)
+- `--dry-run`: preview generation or update changes without writing; for `update`, exits `2` when changes or conflicts are pending
+- `--force`: overwrite generated managed/structured baselines; protected files remain preserved in `update` mode
- `--yes` / `-y`: non-interactive defaults (existing **`setup-cursor-assistant.md`** / **`setup-claude-assistant.md`** are always replaced; existing **`.cursor/mcp.json`** / **`.mcp.json`** are left unchanged unless you pass **`--force`**)
- `--mcp-playwright `: add or skip Playwright MCP files for the assistants you selected (`yes` / `true` / `1` / `cursor` / `on` vs `none` / `no` / `false` / `0` / `off`). **Cursor** → **`.cursor/mcp.json`**; **Claude** → **`.mcp.json`** at repo root. With **`--yes`** and no flag, defaults to **yes**
- `--mcp-figma `: add or skip Figma MCP files for the assistants you selected (`yes` / `true` / `1` / `figma` / `on` vs `none` / `no` / `false` / `0` / `off`). **Cursor** → **`.cursor/mcp.json`**; **Claude** → **`.mcp.json`** at repo root. With **`--yes`** and no flag, defaults to **no** (requires `FIGMA_API_KEY`)
@@ -235,15 +276,20 @@ npm install
npm test
```
-`npm install` runs `prepare` and builds `dist/`. Use `npm run build` alone when you only need a compile without reinstalling. Use **`npm run typecheck`** for **`tsc --noEmit`** over **`src/`** and **`tests/`** (no output).
+`npm install` installs development dependencies without producing `dist/`. Run `npm run build` for a local CLI.
+`prepack` builds the release artifact and records release provenance. Use **`npm run typecheck`** for
+**`tsc --noEmit`** over **`src/`** and **`tests/`**.
## Notes
- **Interactive MCP conflicts:** If any MCP server is enabled and **`.cursor/mcp.json`** or **`.mcp.json`** already exists, the CLI asks per file: **Skip** (keep as-is), **Merge** (union of `mcpServers`; generated server names override duplicates), or **Overwrite** (replace with the template). **`--dry-run`** and **`--yes`** skip these prompts; **`--force`** overwrites every generated path without merging.
- Legacy metadata migration: old files **`.cursor/linear-cli-setup.json`** and **`.assistant-setup/linear-cli-setup.json`** are migrated to new names on update when possible; with **`--force`**, old legacy files are removed.
- Obsolete QA flow cleanup (PP-3640): every re-run removes legacy **`ai-testing`** / **`ui-check`** skills and **`.claude/workflows/ui-check.md`** if they still exist from older bootstraps, then deletes any **empty parent folders** left behind (e.g. `.cursor/skills/ai-testing/`).
-- Setup assistant markdown files are always refreshed on each run; use `--force` to update other generated files in place. Root **`AGENTS.md`**, **`CLAUDE.md`** and **`.claude/settings.json`** (Claude only), and **`.cursorrules`** (Cursor only), follow the same rules as **`.dev-environment.md`**: created when missing, skipped if they already exist unless **`--force`**.
-- `.dev-environment.md` is generated as a personal local profile (including **Authentication**: `MI_ACCESS_TOKEN` for the dev proxy, `/data/page/index/auth/info` smoke check on localhost, session cookies); keep it up to date and add it to `.gitignore`. Store **`MI_USERNAME` / `MI_PASSWORD`** only in **`.mi-credentials.local.env`** (gitignored), never in `.dev-environment.md`.
+- Setup assistant markdown files are always refreshed by the legacy generation flow. For subsequent tracked updates,
+ prefer `check` and `update`, which use recorded baselines instead of blanket replacement.
+- `.dev-environment.md` is tracked repository guidance and may describe **Authentication** (`MI_ACCESS_TOKEN`,
+ `/data/page/index/auth/info`, session cookies), but must not contain credentials. Store **`MI_USERNAME` /
+ `MI_PASSWORD`** only in **`.mi-credentials.local.env`** (gitignored).
- Page workflow context file (`.assistant-setup/page-workflow-context.md`) is generated as a shared artifact and can be refined per project.
- **Node.js:** This package keeps **`engines.node` `>=20`** for running the bootstrap CLI. Repositories on **`@metricinsights/pp-dev` ≥ 1.0** need **Node.js ≥ 24** (declared in its `engines`); align `engines` and workflow images in those app repos when you adopt that pp-dev version.
- **CI:** Consumer app repositories may not have GitHub Actions (or other CI) yet—that is still often the exception—but the goal is for **build / lint / test on every change** to become the default. This tool does not generate CI files; add workflows in each app repo when you standardize, and pin the same Node version you use locally (see above for pp-dev).
diff --git a/package-lock.json b/package-lock.json
index 8d28e9c..82fb69c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,7 @@
"": {
"name": "@metricinsights/ca-ai-tools-setup",
"version": "0.1.0",
- "license": "ISC",
+ "license": "UNLICENSED",
"dependencies": {
"@clack/prompts": "^0.11.0",
"minimist": "^1.2.8"
diff --git a/package.json b/package.json
index 590cb39..2d8ff66 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,16 @@
{
"name": "@metricinsights/ca-ai-tools-setup",
"version": "0.1.0",
- "private": true,
"type": "module",
"description": "Bootstrap Cursor and Claude Linear CLI setup files in a repository",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/mi-examples/ca-ai-tools-setup.git"
+ },
+ "homepage": "https://github.com/mi-examples/ca-ai-tools-setup#readme",
+ "bugs": {
+ "url": "https://github.com/mi-examples/ca-ai-tools-setup/issues"
+ },
"bin": {
"ca-ai-tools-setup": "dist/cli.js"
},
@@ -16,10 +23,11 @@
"node": ">=20.0.0"
},
"scripts": {
- "build": "tsc -p tsconfig.build.json",
- "prepare": "npm run build",
+ "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
+ "prepack": "npm run build && node scripts/write-release-info.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "npm run build && tsx --test tests/**/*.test.ts",
+ "test:package": "node scripts/package-smoke.mjs",
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0 --fix",
"format": "prettier --write \"**/*.{ts,md,json}\""
@@ -43,5 +51,5 @@
"tsx": "^4.19.4",
"typescript": "^5.9.3"
},
- "license": "ISC"
+ "license": "UNLICENSED"
}
diff --git a/scripts/clean-dist.mjs b/scripts/clean-dist.mjs
new file mode 100644
index 0000000..fa31fbe
--- /dev/null
+++ b/scripts/clean-dist.mjs
@@ -0,0 +1,7 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+fs.rmSync(path.join(repoRoot, 'dist'), { recursive: true, force: true });
diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs
new file mode 100644
index 0000000..5b6347c
--- /dev/null
+++ b/scripts/package-smoke.mjs
@@ -0,0 +1,107 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
+const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ca-ai-tools-setup-package-'));
+let tarballPath;
+
+function run(command, args, options = {}) {
+ return execFileSync(command, args, {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'inherit'],
+ ...options,
+ });
+}
+
+function runNpm(args) {
+ const npmCliPath = process.env.npm_execpath;
+
+ return npmCliPath ? run(process.execPath, [npmCliPath, ...args]) : run('npm', args);
+}
+
+function listFiles(root) {
+ return fs.readdirSync(root, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile());
+}
+
+try {
+ const packOutput = runNpm(['pack', '--json']);
+ const packResult = JSON.parse(packOutput);
+
+ assert.equal(packResult.length, 1, 'npm pack should produce exactly one artifact');
+ tarballPath = path.join(repoRoot, packResult[0].filename);
+
+ const packedPaths = new Set(packResult[0].files.map((file) => file.path));
+
+ assert.ok(packedPaths.has('dist/cli.js'), 'packed artifact should contain the compiled CLI');
+ assert.ok(packedPaths.has('dist/release-info.json'), 'packed artifact should contain release provenance');
+ assert.ok(
+ [...packedPaths].some((filePath) => filePath.startsWith('templates/')),
+ 'packed artifact should contain templates',
+ );
+ assert.equal(
+ [...packedPaths].some(
+ (filePath) => filePath.startsWith('src/') || filePath.startsWith('tests/') || filePath.startsWith('scripts/'),
+ ),
+ false,
+ 'development source should not be published',
+ );
+
+ const installRoot = path.join(tempRoot, 'install');
+ const targetRoot = path.join(tempRoot, 'target');
+
+ fs.mkdirSync(installRoot, { recursive: true });
+ fs.mkdirSync(targetRoot, { recursive: true });
+
+ runNpm(['install', '--ignore-scripts', '--no-package-lock', '--prefix', installRoot, tarballPath]);
+
+ const packageRoot = path.join(installRoot, 'node_modules', '@metricinsights', 'ca-ai-tools-setup');
+ const cliPath = path.join(packageRoot, 'dist', 'cli.js');
+ const releaseInfo = JSON.parse(fs.readFileSync(path.join(packageRoot, 'dist', 'release-info.json'), 'utf8'));
+ const versionOutput = run(process.execPath, [cliPath, '--version'], { cwd: installRoot });
+
+ assert.equal(versionOutput, `${packageJson.version}\n`);
+ assert.match(releaseInfo.releaseCommit, /^(?:[0-9a-f]{40}|unknown)$/u);
+
+ run(
+ process.execPath,
+ [
+ cliPath,
+ '--target',
+ targetRoot,
+ '--assistants',
+ 'cursor,claude',
+ '--yes',
+ '--mcp-playwright',
+ 'yes',
+ '--mcp-figma',
+ 'yes',
+ ],
+ { cwd: installRoot },
+ );
+
+ const generatedFiles = listFiles(targetRoot);
+
+ assert.equal(generatedFiles.length, 71, 'packed CLI should generate the complete setup');
+ assert.ok(fs.existsSync(path.join(targetRoot, '.cursor', 'skills', 'ai-development', 'SKILL.md')));
+ assert.ok(fs.existsSync(path.join(targetRoot, '.cursor', 'rules', 'assistant-setup-health.mdc')));
+ assert.ok(fs.existsSync(path.join(targetRoot, '.claude', 'skills', 'ai-development', 'SKILL.md')));
+ assert.ok(fs.existsSync(path.join(targetRoot, '.assistant-setup', 'SETUP_STATUS.md')));
+ assert.ok(fs.existsSync(path.join(targetRoot, '.assistant-setup', 'ca-ai-tools-setup.json')));
+ assert.ok(fs.existsSync(path.join(targetRoot, '.cursor', 'mcp.json')));
+ assert.ok(fs.existsSync(path.join(targetRoot, '.mcp.json')));
+
+ console.log(`Packaged artifact generated ${generatedFiles.length} files successfully.`);
+} finally {
+ if (tarballPath) {
+ fs.rmSync(tarballPath, { force: true });
+ }
+
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+}
diff --git a/scripts/write-release-info.mjs b/scripts/write-release-info.mjs
new file mode 100644
index 0000000..1be73b1
--- /dev/null
+++ b/scripts/write-release-info.mjs
@@ -0,0 +1,25 @@
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+let releaseCommit = process.env.GITHUB_SHA?.trim();
+
+if (!releaseCommit) {
+ try {
+ releaseCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ } catch {
+ releaseCommit = 'unknown';
+ }
+}
+
+const outputPath = path.join(repoRoot, 'dist', 'release-info.json');
+
+fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+fs.writeFileSync(outputPath, `${JSON.stringify({ releaseCommit }, null, 2)}\n`, 'utf8');
diff --git a/src/cli-args.ts b/src/cli-args.ts
index b4e0253..b2ccc1b 100644
--- a/src/cli-args.ts
+++ b/src/cli-args.ts
@@ -8,22 +8,50 @@ export type CliArgs = {
'dry-run'?: boolean;
dryRun?: boolean;
force?: boolean;
+ version?: boolean;
'mcp-playwright'?: string;
'mcp-figma'?: string;
'qa-ai-rules'?: string;
};
+export type CliMode = 'generate' | 'check' | 'update';
+
export function parseCliArgs(argv = process.argv.slice(2)): CliArgs {
return minimist(argv, {
string: ['target', 'assistants', 'mcp-playwright', 'mcp-figma', 'qa-ai-rules', '_'],
- boolean: ['yes', 'dry-run', 'dryRun', 'force'],
+ boolean: ['yes', 'dry-run', 'dryRun', 'force', 'version'],
alias: {
y: 'yes',
dryRun: 'dry-run',
+ v: 'version',
},
}) as CliArgs;
}
+export function cliMode(args: CliArgs): CliMode {
+ const command = args._[0];
+
+ return command === 'check' || command === 'update' ? command : 'generate';
+}
+
+export function validateCliArgs(args: CliArgs): void {
+ const mode = cliMode(args);
+ const maximumPositionals = mode === 'generate' ? 1 : 2;
+
+ if (args._.length <= maximumPositionals) {
+ return;
+ }
+
+ if (mode === 'generate') {
+ throw new Error(
+ `Unknown command or too many positional arguments: ${args._.join(' ')}. ` +
+ 'Supported commands are check and update.',
+ );
+ }
+
+ throw new Error(`Too many positional arguments for ${mode}: ${args._.slice(1).join(' ')}`);
+}
+
export function mcpPlaywrightCliRaw(args: CliArgs): string | undefined {
const v = args['mcp-playwright'];
@@ -49,7 +77,7 @@ export function firstNonEmptyTarget(args: CliArgs): string | undefined {
return fromFlag;
}
- const positional = args._[0];
+ const positional = args._[cliMode(args) === 'generate' ? 0 : 1];
if (positional === undefined || positional === null) {
return undefined;
diff --git a/src/cli-prompts.ts b/src/cli-prompts.ts
index a41a2d1..4984937 100644
--- a/src/cli-prompts.ts
+++ b/src/cli-prompts.ts
@@ -11,13 +11,7 @@ import { resolveCliRepoRoot } from './path-policy.js';
import { type InteractiveDefaults } from './previous-setup.js';
import { type ExistingFileAction } from './generator.js';
import { type GeneratedFile } from './generators/types.js';
-import {
- firstNonEmptyTarget,
- mcpFigmaCliRaw,
- mcpPlaywrightCliRaw,
- qaAiRulesCliRaw,
- type CliArgs,
-} from './cli-args.js';
+import { firstNonEmptyTarget, mcpFigmaCliRaw, mcpPlaywrightCliRaw, qaAiRulesCliRaw, type CliArgs } from './cli-args.js';
export async function pickTargetDir(args: CliArgs): Promise {
const cwd = process.cwd();
@@ -175,6 +169,11 @@ export async function promptExistingMcpActions(
continue;
}
+ if (file.path === 'AGENTS.md') {
+ actions[file.path] = 'merge';
+ continue;
+ }
+
const mergeLabel =
file.path === '.cursor/mcp.json' || file.path === '.mcp.json'
? 'Merge — union mcpServers (generated keys override on name collision)'
diff --git a/src/cli-summary.ts b/src/cli-summary.ts
index 71c674c..9130eb8 100644
--- a/src/cli-summary.ts
+++ b/src/cli-summary.ts
@@ -1,6 +1,7 @@
import * as p from '@clack/prompts';
import { QA_AI_RULES_PACKAGE, SETUP_ASSISTANT_FILES, type Assistant } from './constants.js';
import { resolveFigmaMcpTargets, resolvePlaywrightMcpTargets, type GenerateResult } from './generator.js';
+import type { ReconcileResult } from './reconcile.js';
export type QaAiRulesSummaryHook = 'inactive' | 'dry-run' | 'success' | 'skipped-no-package-json';
@@ -159,9 +160,10 @@ export function printSummary(
printLine();
printLine('Skipped existing files:');
printLine(
- ' - Mergeable files (.cursor/mcp.json, .mcp.json, .claude/settings.json, AGENTS.md): ' +
+ ' - Mergeable files (.cursor/mcp.json, .mcp.json, .claude/settings.json): ' +
'run without --yes to choose skip/merge/overwrite',
);
+ printLine(' - AGENTS.md is always merged conservatively and is never replaced');
printLine(' - Any existing generated file: use --force to overwrite');
for (const file of result.skipped) {
@@ -187,3 +189,92 @@ export function printSummary(
}
}
}
+
+export function printReconcileSummary(targetDir: string, result: ReconcileResult, dryRun: boolean): void {
+ const label =
+ result.mode === 'check'
+ ? 'Setup check completed.'
+ : !result.applied && result.conflicts.length > 0
+ ? 'Setup update blocked by conflicts.'
+ : dryRun
+ ? 'Setup update preview completed.'
+ : 'Setup update completed.';
+
+ p.outro(label);
+ printLine();
+ printLine(`Target repository: ${targetDir}`);
+ printLine(`Package version: ${result.previousVersion} -> ${result.desiredVersion}`);
+
+ if (result.metadataMigrationRequired) {
+ printLine(result.applied && !dryRun ? 'Metadata: migrated to schema 6' : 'Metadata: schema 5 migration required');
+ } else if (result.metadataUpdated) {
+ printLine('Metadata: updated');
+ }
+
+ printLine();
+ printLine(`Created: ${result.created.length}`);
+ printLine(`Updated: ${result.updated.length}`);
+ printLine(`Merged: ${result.merged.length}`);
+ printLine(`Removed: ${result.removed.length}`);
+ printLine(`Preserved: ${result.preserved.length}`);
+ printLine(`Orphaned: ${result.orphaned.length}`);
+ printLine(`Conflicts: ${result.conflicts.length}`);
+ printLine(`Unchanged: ${result.unchanged.length}`);
+
+ const changedPaths = [
+ ...result.created.map((file) => `${file} (create)`),
+ ...result.updated.map((file) => `${file} (update)`),
+ ...result.merged.map((file) => `${file} (merge)`),
+ ...result.removed.map((file) => `${file} (remove)`),
+ ].sort();
+
+ if (changedPaths.length > 0) {
+ printLine();
+ printLine(result.mode === 'check' || dryRun ? 'Planned changes:' : 'Updated files:');
+
+ for (const file of changedPaths) {
+ printLine(` - ${file}`);
+ }
+ }
+
+ if (result.preserved.length > 0) {
+ printLine();
+ printLine('Protected or repository-owned files preserved:');
+
+ for (const file of result.preserved) {
+ printLine(` - ${file}`);
+ }
+ }
+
+ const preservedOrphans = result.orphaned.filter((file) => !result.removed.includes(file));
+
+ if (preservedOrphans.length > 0) {
+ printLine();
+ printLine('Obsolete files preserved for manual review:');
+
+ for (const file of preservedOrphans) {
+ printLine(` - ${file}`);
+ }
+ }
+
+ if (result.conflicts.length > 0) {
+ printLine();
+ printLine('Conflicts:');
+
+ for (const file of result.plan.files.filter((entry) => entry.state === 'conflict')) {
+ printLine(` - ${file.path}${file.reason ? ` — ${file.reason}` : ''}`);
+ }
+
+ printLine('Resolve these files or rerun update with --force to replace generated baselines.');
+ }
+
+ if (result.mode === 'update' && result.applied && !dryRun) {
+ printLine();
+ printLine('PR summary:');
+ printLine(` - Update ca-ai-tools-setup ${result.previousVersion} -> ${result.desiredVersion}`);
+ printLine(
+ ` - Files: ${result.created.length} created, ${result.updated.length} updated, ` +
+ `${result.merged.length} merged, ${result.removed.length} removed`,
+ );
+ }
+}
diff --git a/src/cli.ts b/src/cli.ts
index 5384e69..d0c75dd 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -3,8 +3,16 @@ import * as p from '@clack/prompts';
import { QA_AI_RULES_PACKAGE } from './constants.js';
import { generateSetup, getGeneratedFiles } from './generator.js';
import { runQaAiRulesSetup } from './qa-ai-rules-setup.js';
-import { loadPreviousInteractiveDefaults } from './previous-setup.js';
-import { parseCliArgs } from './cli-args.js';
+import { loadPreviousInteractiveDefaults, type InteractiveDefaults } from './previous-setup.js';
+import {
+ cliMode,
+ mcpFigmaCliRaw,
+ mcpPlaywrightCliRaw,
+ parseCliArgs,
+ qaAiRulesCliRaw,
+ type CliArgs,
+ validateCliArgs,
+} from './cli-args.js';
import {
pickAssistants,
pickFigmaMcpInclude,
@@ -13,11 +21,16 @@ import {
pickTargetDir,
promptExistingMcpActions,
} from './cli-prompts.js';
-import { printSummary, type QaAiRulesSummaryHook } from './cli-summary.js';
-
-async function run(): Promise {
- const args = parseCliArgs();
+import { printReconcileSummary, printSummary, type QaAiRulesSummaryHook } from './cli-summary.js';
+import { parseAssistantsArg } from './assistants.js';
+import { parsePlaywrightMcpArg } from './playwright-mcp-choice.js';
+import { parseFigmaMcpArg } from './figma-mcp-choice.js';
+import { parseQaAiRulesArg } from './qa-ai-rules-choice.js';
+import { checkSetup, updateSetup, type ReconcileConfiguration } from './reconcile.js';
+import { loadSetupMetadata } from './setup-metadata.js';
+import { getCliPackageVersion } from './setup-log.js';
+async function runGenerate(args: CliArgs): Promise {
p.intro('Create Linear Assistant Setup');
const targetDir = await pickTargetDir(args);
@@ -88,6 +101,119 @@ async function run(): Promise {
);
}
+function resolveReconcileConfiguration(args: CliArgs, previous: InteractiveDefaults): ReconcileConfiguration {
+ return {
+ assistants: parseAssistantsArg(args.assistants) ?? previous.assistants,
+ playwrightMcpInclude: parsePlaywrightMcpArg(mcpPlaywrightCliRaw(args)) ?? previous.playwrightMcpInclude,
+ figmaMcpInclude: parseFigmaMcpArg(mcpFigmaCliRaw(args)) ?? previous.figmaMcpInclude,
+ qaAiRulesInclude: parseQaAiRulesArg(qaAiRulesCliRaw(args)) ?? previous.qaAiRulesInclude,
+ };
+}
+
+function runExplicitQaSetup(targetDir: string, configuration: ReconcileConfiguration, args: CliArgs): void {
+ const explicitlyEnabled = parseQaAiRulesArg(qaAiRulesCliRaw(args)) === true;
+
+ if (!explicitlyEnabled || args.dryRun) {
+ return;
+ }
+
+ const qaResult = runQaAiRulesSetup(targetDir, configuration.assistants);
+
+ if (qaResult.ok) {
+ return;
+ }
+
+ if (qaResult.reason === 'no-package-json') {
+ console.warn(`[ca-ai-tools-setup] Skipped ${QA_AI_RULES_PACKAGE}: target repository has no package.json.`);
+
+ return;
+ }
+
+ throw new Error(
+ qaResult.reason === 'run-failed'
+ ? `${QA_AI_RULES_PACKAGE} init failed (${qaResult.runnerLabel ?? 'runner'})${
+ qaResult.detail ? `: ${qaResult.detail}` : ''
+ }`
+ : 'QA AI rules setup failed',
+ );
+}
+
+async function runReconcile(args: CliArgs, mode: 'check' | 'update'): Promise {
+ p.intro(mode === 'check' ? 'Check Linear Assistant Setup' : 'Update Linear Assistant Setup');
+
+ const targetDir = await pickTargetDir({ ...args, yes: true });
+ const metadata = loadSetupMetadata(targetDir);
+ const previous = loadPreviousInteractiveDefaults(targetDir);
+
+ if (!previous) {
+ if (metadata.kind === 'invalid') {
+ throw new Error(`Invalid setup metadata: ${metadata.detail}`);
+ }
+
+ throw new Error(
+ `Setup metadata not found or unsupported: .assistant-setup/ca-ai-tools-setup.json. ` +
+ 'Run the initial setup first.',
+ );
+ }
+
+ const configuration = resolveReconcileConfiguration(args, previous);
+ const files = getGeneratedFiles(
+ configuration.assistants,
+ configuration.playwrightMcpInclude,
+ configuration.figmaMcpInclude,
+ configuration.qaAiRulesInclude,
+ );
+ const commonOptions = {
+ targetDir,
+ files,
+ metadata,
+ force: Boolean(args.force),
+ ...configuration,
+ };
+ const result =
+ mode === 'check'
+ ? checkSetup(commonOptions)
+ : updateSetup({
+ ...commonOptions,
+ dryRun: Boolean(args.dryRun),
+ });
+
+ if (mode === 'update' && result.applied) {
+ runExplicitQaSetup(targetDir, configuration, args);
+ }
+
+ printReconcileSummary(targetDir, result, Boolean(args.dryRun));
+
+ if (
+ ((mode === 'check' || (mode === 'update' && args.dryRun)) && result.plan.hasChanges) ||
+ result.conflicts.length > 0
+ ) {
+ process.exitCode = 2;
+ }
+}
+
+async function run(): Promise {
+ const args = parseCliArgs();
+
+ validateCliArgs(args);
+
+ if (args.version) {
+ process.stdout.write(`${getCliPackageVersion()}\n`);
+
+ return;
+ }
+
+ const mode = cliMode(args);
+
+ if (mode === 'generate') {
+ await runGenerate(args);
+
+ return;
+ }
+
+ await runReconcile(args, mode);
+}
+
run().catch((error: unknown) => {
p.cancel('Operation failed.');
console.error(error instanceof Error ? error.message : error);
diff --git a/src/constants.ts b/src/constants.ts
index e02c0b0..45f8524 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -4,7 +4,11 @@ export type Assistant = (typeof ASSISTANTS)[number];
export const DEFAULT_ASSISTANTS: Assistant[] = ['cursor', 'claude'];
-export const METADATA_VERSION = 5;
+export const METADATA_VERSION = 6;
+
+/** Stable prebuilt CLI tarball attached to GitHub Releases. */
+export const RELEASE_TGZ_LATEST =
+ 'https://github.com/mi-examples/ca-ai-tools-setup/releases/latest/download/ca-ai-tools-setup.tgz';
/** npm package installed/configured when `--qa-ai-rules` is enabled. */
export const QA_AI_RULES_PACKAGE = '@metricinsights/qa-ai-rules';
diff --git a/src/generator.ts b/src/generator.ts
index 5aaa7f6..c37a545 100644
--- a/src/generator.ts
+++ b/src/generator.ts
@@ -1,17 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
-import {
- METADATA_VERSION,
- QA_AI_RULES_PACKAGE,
- SETUP_ASSISTANT_FILES,
- type Assistant,
-} from './constants.js';
+import { SETUP_ASSISTANT_FILES, type Assistant } from './constants.js';
import { generateCursorFiles } from './generators/cursor.js';
import { generateClaudeFiles } from './generators/claude.js';
import { buildCursorRuleFiles } from './generators/portal-page-ai.js';
import { isMergeablePath, mergeFile } from './mcp-json-merge.js';
import type { GeneratedFile } from './generators/types.js';
import { readTemplate } from './templates.js';
+import {
+ createSetupMetadata,
+ createSetupStatusContent,
+ normalizeSetupPath,
+ serializeSetupMetadata,
+ SETUP_METADATA_PATH,
+ SETUP_STATUS_PATH,
+} from './setup-metadata.js';
export type ExistingFileAction = 'skip' | 'merge' | 'overwrite';
@@ -142,30 +145,6 @@ export function getGeneratedFiles(
files.push(...buildCursorRuleFiles(figmaTargets.projectRootFile));
}
- const sharedMetadata = {
- version: METADATA_VERSION,
- assistants,
- playwrightMcp: mcpTargets,
- figmaMcp: figmaTargets,
- devEnvironment: {
- file: '.dev-environment.md',
- generated: true,
- },
- pageWorkflowContext: {
- file: '.assistant-setup/page-workflow-context.md',
- generated: true,
- },
- linearCliReference: {
- file: 'LINEAR_CLI.md',
- generated: true,
- },
- qaAiRules: {
- enabled: qaAiRulesInclude,
- package: QA_AI_RULES_PACKAGE,
- },
- generatedAt: new Date().toISOString(),
- };
-
files.push({
path: '.assistant-setup/page-workflow-context.md',
content: readTemplate('assistant-setup/page-workflow-context.md'),
@@ -176,11 +155,6 @@ export function getGeneratedFiles(
content: readTemplate('assistant-setup/dev-environment.md'),
});
- files.push({
- path: '.assistant-setup/ca-ai-tools-setup.json',
- content: JSON.stringify(sharedMetadata, null, 2) + '\n',
- });
-
files.push({
path: 'LINEAR_CLI.md',
content: readTemplate('LINEAR_CLI.md'),
@@ -191,6 +165,25 @@ export function getGeneratedFiles(
content: readTemplate('AGENTS.md'),
});
+ const metadataConfiguration = {
+ assistants,
+ playwrightMcp: mcpTargets,
+ figmaMcp: figmaTargets,
+ qaAiRulesEnabled: qaAiRulesInclude,
+ };
+
+ files.push({
+ path: SETUP_STATUS_PATH,
+ content: createSetupStatusContent(metadataConfiguration),
+ });
+
+ const metadata = createSetupMetadata(metadataConfiguration, files);
+
+ files.push({
+ path: SETUP_METADATA_PATH,
+ content: serializeSetupMetadata(metadata),
+ });
+
return files;
}
@@ -298,6 +291,19 @@ function writeOneFile(
const exists = fs.existsSync(destination);
const actions = options.existingFileActions;
+ if (exists && normalizeSetupPath(file.path) === 'AGENTS.md') {
+ if (!options.dryRun) {
+ const existingContent = fs.readFileSync(destination, 'utf8');
+ const merged = mergeFile(file.path, existingContent, file.content);
+
+ fs.writeFileSync(destination, merged, 'utf8');
+ }
+
+ result.merged.push(file.path);
+
+ return;
+ }
+
if (options.force) {
if (!options.dryRun) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
@@ -404,7 +410,10 @@ export function generateSetup(options: GenerateOptions): GenerateResult {
migrateLegacyFiles(options.targetDir, { force: options.force, dryRun: options.dryRun }, result);
removeObsoleteSetupFiles(options.targetDir, { dryRun: options.dryRun }, result);
- for (const file of files) {
+ const metadataFile = files.find((file) => normalizeSetupPath(file.path) === SETUP_METADATA_PATH);
+ const setupFiles = files.filter((file) => normalizeSetupPath(file.path) !== SETUP_METADATA_PATH);
+
+ for (const file of setupFiles) {
writeOneFile(
options.targetDir,
file,
@@ -417,5 +426,52 @@ export function generateSetup(options: GenerateOptions): GenerateResult {
);
}
+ if (metadataFile) {
+ const installedContent = new Map();
+
+ for (const file of setupFiles) {
+ const normalizedPath = normalizeSetupPath(file.path);
+ const destination = path.join(options.targetDir, file.path);
+ const content =
+ !options.dryRun && fs.existsSync(destination) ? fs.readFileSync(destination, 'utf8') : file.content;
+
+ installedContent.set(normalizedPath, content);
+ }
+
+ const metadata = createSetupMetadata(
+ {
+ assistants: options.assistants,
+ playwrightMcp: resolvePlaywrightMcpTargets(options.assistants, options.playwrightMcpInclude),
+ figmaMcp: resolveFigmaMcpTargets(options.assistants, Boolean(options.figmaMcpInclude)),
+ qaAiRulesEnabled: Boolean(options.qaAiRulesInclude),
+ },
+ setupFiles,
+ installedContent,
+ );
+
+ for (const mergedPath of result.merged) {
+ const record = metadata.files[normalizeSetupPath(mergedPath)];
+
+ if (record) {
+ record.baseline = 'merged';
+ }
+ }
+
+ writeOneFile(
+ options.targetDir,
+ {
+ path: SETUP_METADATA_PATH,
+ content: serializeSetupMetadata(metadata),
+ },
+ {
+ // Metadata must describe the files installed during this run.
+ force: true,
+ dryRun: options.dryRun,
+ existingFileActions: options.existingFileActions,
+ },
+ result,
+ );
+ }
+
return result;
}
diff --git a/src/generators/cursor.ts b/src/generators/cursor.ts
index 44262d2..ed92244 100644
--- a/src/generators/cursor.ts
+++ b/src/generators/cursor.ts
@@ -51,8 +51,11 @@ function renderCursorMcpSection(options: GenerateCursorOptions): string {
'- Confirm **`.cursor/mcp.json`** exists and contains the expected `mcpServers` entries.',
'- If someone removed the file, recreate it and merge with any existing `mcpServers` keys.',
'- For **Figma MCP**, export **`FIGMA_API_KEY`** before starting the server, then reload MCP in Cursor.',
- '- If Figma MCP is enabled, follow **`.cursor/rules/figma-mcp.mdc`** and **`.cursor/skills/figma-code-connect/SKILL.md`** when applicable.',
- '- **Enable servers manually** in Cursor (**Settings → Features → MCP**): project MCP from `.cursor/mcp.json` is not auto-enabled — toggle each server on, then refresh MCP (see **Step 2.5** in this setup doc).',
+ '- If Figma MCP is enabled, follow **`.cursor/rules/figma-mcp.mdc`** and ' +
+ '**`.cursor/skills/figma-code-connect/SKILL.md`** when applicable.',
+ '- **Enable servers manually** in Cursor (**Settings → Features → MCP**): project MCP from ' +
+ '`.cursor/mcp.json` is not auto-enabled — toggle each server on, then refresh MCP ' +
+ '(see **Step 2.5** in this setup doc).',
'- After any edit to `mcp.json`, reload MCP in Cursor and confirm selected tools are available.',
'',
'```json',
diff --git a/src/generators/portal-page-ai.ts b/src/generators/portal-page-ai.ts
index f2e6d57..485073c 100644
--- a/src/generators/portal-page-ai.ts
+++ b/src/generators/portal-page-ai.ts
@@ -11,6 +11,7 @@ const FIGMA_CODE_CONNECT_TEMPLATES = [
] as const;
const PORTAL_PAGE_RULES = [
+ 'cursor/rules/assistant-setup-health.mdc',
'cursor/rules/code-style.mdc',
'cursor/rules/frontend-architecture.mdc',
'cursor/rules/commit-convention.mdc',
@@ -24,10 +25,7 @@ const PORTAL_PAGE_RULES = [
] as const;
/** Shared skills mirrored under `.cursor/skills/` and `.claude/skills/`. */
-const SHARED_PORTAL_SKILLS = [
- 'skills/ai-development/SKILL.md',
- 'skills/ai-development/DOD-FULL.md',
-] as const;
+const SHARED_PORTAL_SKILLS = ['skills/ai-development/SKILL.md', 'skills/ai-development/DOD-FULL.md'] as const;
/** Cursor-only skills (Claude Code uses `.claude/workflows/` for QA orchestration). */
const CURSOR_ONLY_SKILLS = [
@@ -49,10 +47,7 @@ function skillTemplateToOutputPath(templateRel: string): string {
return `${parts[1]}/${parts[2]}`;
}
-function buildSkillFilesForAssistant(
- assistant: 'cursor' | 'claude',
- includeFigmaMcp: boolean,
-): GeneratedFile[] {
+function buildSkillFilesForAssistant(assistant: 'cursor' | 'claude', includeFigmaMcp: boolean): GeneratedFile[] {
const skillsRoot = assistant === 'cursor' ? '.cursor/skills' : '.claude/skills';
const files: GeneratedFile[] = SHARED_PORTAL_SKILLS.map((rel) => ({
@@ -89,10 +84,7 @@ function buildSkillFilesForAssistant(
}
/** Shared Portal Page skills for Cursor (`.cursor/skills/`) or Claude (`.claude/skills/`). */
-export function buildPortalPageSkillFiles(
- assistant: 'cursor' | 'claude',
- includeFigmaMcp: boolean,
-): GeneratedFile[] {
+export function buildPortalPageSkillFiles(assistant: 'cursor' | 'claude', includeFigmaMcp: boolean): GeneratedFile[] {
return buildSkillFilesForAssistant(assistant, includeFigmaMcp);
}
diff --git a/src/mcp-json-merge.ts b/src/mcp-json-merge.ts
index 201a8a2..b98f957 100644
--- a/src/mcp-json-merge.ts
+++ b/src/mcp-json-merge.ts
@@ -184,11 +184,12 @@ export function mergeClaudeSettingsJson(existingContent: string, incomingContent
}
/**
- * Merge `AGENTS.md`: append table rows from incoming for agent files not already listed in existing.
- * Identifies data rows by the pattern `| \`filename\` |` and inserts new ones after the last existing row.
+ * Merge `AGENTS.md` without replacing repository-owned content.
+ * Missing generated agent rows are inserted into an existing registry table or appended as a new section.
*/
export function mergeAgentsMd(existingContent: string, incomingContent: string): string {
- const dataRowPattern = /^\| `([^`]+)` \|/;
+ const dataRowPattern = /^\|\s*`([^`]+)`\s*\|/;
+ const tableHeaderPattern = /^\|\s*File\s*\|\s*Purpose\s*\|/i;
const existingLines = existingContent.split('\n');
const incomingLines = incomingContent.split('\n');
@@ -211,7 +212,11 @@ export function mergeAgentsMd(existingContent: string, incomingContent: string):
return existingContent;
}
- // Insert after the last existing data row
+ if (existingContent.trim().length === 0) {
+ return incomingContent;
+ }
+
+ // Prefer the end of an existing agent registry table.
let insertAt = -1;
for (let i = existingLines.length - 1; i >= 0; i--) {
@@ -221,8 +226,23 @@ export function mergeAgentsMd(existingContent: string, incomingContent: string):
}
}
+ // A table may exist without data rows yet; insert after its separator.
+ if (insertAt === -1) {
+ const headerIndex = existingLines.findIndex((line) => tableHeaderPattern.test(line));
+
+ if (headerIndex !== -1 && existingLines[headerIndex + 1]?.trimStart().startsWith('|')) {
+ insertAt = headerIndex + 2;
+ }
+ }
+
if (insertAt === -1) {
- return existingContent.trimEnd() + '\n' + newRows.join('\n') + '\n';
+ return (
+ `${existingContent.trimEnd()}\n\n` +
+ '## Registered agents added by ca-ai-tools-setup\n\n' +
+ '| File | Purpose |\n' +
+ '| ---- | ------- |\n' +
+ `${newRows.join('\n')}\n`
+ );
}
const result = [...existingLines];
diff --git a/src/package-manager.ts b/src/package-manager.ts
index ad02ce0..7816a9f 100644
--- a/src/package-manager.ts
+++ b/src/package-manager.ts
@@ -152,10 +152,7 @@ export type SpawnPackageArgvOptions = {
log?: (message: string) => void;
};
-function logSpawnError(
- log: ((message: string) => void) | undefined,
- result: SpawnSyncReturns,
-): void {
+function logSpawnError(log: ((message: string) => void) | undefined, result: SpawnSyncReturns): void {
if (!log) {
return;
}
@@ -207,7 +204,8 @@ function spawnWindowsDirect(
/**
* Run a one-shot package-manager command (`npx`, `pnpm dlx`, …).
- * On Windows: `npm exec --package=` uses shell (cmd-safe, finds `npm` in PATH); bare `@scope` uses direct spawn with resolved `.cmd`.
+ * On Windows, `npm exec --package=` uses the shell so cmd can find `npm` in PATH.
+ * A bare `@scope` package uses direct spawn with a resolved `.cmd` executable.
*/
export function spawnPackageArgv(
argv: readonly string[],
@@ -295,10 +293,7 @@ function yarnMajorFromPackageManagerField(field: string): number | null {
}
function isYarnBerryLayout(targetDir: string): boolean {
- return (
- fs.existsSync(path.join(targetDir, '.yarnrc.yml')) ||
- fs.existsSync(path.join(targetDir, '.yarn', 'releases'))
- );
+ return fs.existsSync(path.join(targetDir, '.yarnrc.yml')) || fs.existsSync(path.join(targetDir, '.yarn', 'releases'));
}
/**
diff --git a/src/previous-setup.ts b/src/previous-setup.ts
index 80df1c4..64f75ea 100644
--- a/src/previous-setup.ts
+++ b/src/previous-setup.ts
@@ -1,6 +1,5 @@
-import fs from 'node:fs';
-import path from 'node:path';
import { ASSISTANTS, DEFAULT_ASSISTANTS, type Assistant } from './constants.js';
+import { loadSetupMetadata } from './setup-metadata.js';
export type InteractiveDefaults = {
assistants: Assistant[];
@@ -9,8 +8,6 @@ export type InteractiveDefaults = {
qaAiRulesInclude: boolean;
};
-const METADATA_PATH = '.assistant-setup/ca-ai-tools-setup.json';
-
function isAssistant(value: unknown): value is Assistant {
return typeof value === 'string' && (ASSISTANTS as readonly string[]).includes(value);
}
@@ -58,25 +55,22 @@ function parseMcpInclude(meta: Record, key: 'playwrightMcp' | '
* can default to the previous run's choices.
*/
export function loadPreviousInteractiveDefaults(targetDir: string): InteractiveDefaults | null {
- const fullPath = path.join(targetDir, METADATA_PATH);
-
- if (!fs.existsSync(fullPath)) {
- return null;
- }
-
- let raw: unknown;
+ const loaded = loadSetupMetadata(targetDir);
- try {
- raw = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
- } catch {
+ if (loaded.kind === 'missing' || loaded.kind === 'invalid') {
return null;
}
- if (!raw || typeof raw !== 'object') {
- return null;
+ if (loaded.kind === 'current') {
+ return {
+ assistants: loaded.metadata.assistants,
+ playwrightMcpInclude: loaded.metadata.playwrightMcp.cursorFile || loaded.metadata.playwrightMcp.projectRootFile,
+ figmaMcpInclude: loaded.metadata.figmaMcp.cursorFile || loaded.metadata.figmaMcp.projectRootFile,
+ qaAiRulesInclude: loaded.metadata.qaAiRules.enabled,
+ };
}
- const meta = raw as Record;
+ const meta = loaded.raw;
const assistants = parseAssistants(meta) ?? DEFAULT_ASSISTANTS;
const playwright = parseMcpInclude(meta, 'playwrightMcp');
diff --git a/src/reconcile.ts b/src/reconcile.ts
new file mode 100644
index 0000000..24ee7b5
--- /dev/null
+++ b/src/reconcile.ts
@@ -0,0 +1,527 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { Assistant } from './constants.js';
+import type { GeneratedFile } from './generators/types.js';
+import { isMergeablePath, mergeFile } from './mcp-json-merge.js';
+import {
+ createSetupMetadata,
+ hashContent,
+ isSafeSetupPath,
+ type FileOwnership,
+ type LoadedSetupMetadata,
+ normalizeSetupPath,
+ ownershipForPath,
+ serializeSetupMetadata,
+ SETUP_METADATA_PATH,
+ type SetupFileRecord,
+} from './setup-metadata.js';
+import { getCliPackageProvenance } from './setup-log.js';
+import { REMOVABLE_LEGACY_SETUP_PATHS, resolveFigmaMcpTargets, resolvePlaywrightMcpTargets } from './generator.js';
+
+export type ReconcileState = 'clean' | 'missing' | 'outdated' | 'modified' | 'conflict' | 'preserved' | 'orphaned';
+
+export type ReconcileAction = 'none' | 'create' | 'overwrite' | 'merge' | 'remove';
+
+export type ReconcileFilePlan = {
+ path: string;
+ ownership: FileOwnership;
+ state: ReconcileState;
+ action: ReconcileAction;
+ reason?: string;
+ desiredContent?: string;
+ currentContent?: string;
+ previous?: SetupFileRecord;
+};
+
+export type ReconcilePlan = {
+ files: ReconcileFilePlan[];
+ metadataMigrationRequired: boolean;
+ metadataOutdated: boolean;
+ hasChanges: boolean;
+ hasConflicts: boolean;
+};
+
+export type ReconcileConfiguration = {
+ assistants: Assistant[];
+ playwrightMcpInclude: boolean;
+ figmaMcpInclude: boolean;
+ qaAiRulesInclude: boolean;
+};
+
+export type ReconcileOptions = ReconcileConfiguration & {
+ targetDir: string;
+ files: GeneratedFile[];
+ metadata: LoadedSetupMetadata;
+ force: boolean;
+ dryRun: boolean;
+};
+
+export type ReconcileResult = {
+ mode: 'check' | 'update';
+ applied: boolean;
+ metadataMigrationRequired: boolean;
+ metadataUpdated: boolean;
+ previousVersion: string;
+ desiredVersion: string;
+ created: string[];
+ updated: string[];
+ merged: string[];
+ removed: string[];
+ preserved: string[];
+ unchanged: string[];
+ conflicts: string[];
+ missing: string[];
+ outdated: string[];
+ orphaned: string[];
+ plan: ReconcilePlan;
+};
+
+function readCurrentFile(targetDir: string, filePath: string): string | undefined {
+ const destination = path.join(targetDir, filePath);
+
+ return fs.existsSync(destination) ? fs.readFileSync(destination, 'utf8') : undefined;
+}
+
+function desiredSetupFiles(files: GeneratedFile[]): GeneratedFile[] {
+ const desiredFiles = files
+ .filter((file) => normalizeSetupPath(file.path) !== SETUP_METADATA_PATH)
+ .map((file) => ({ ...file, path: normalizeSetupPath(file.path) }))
+ .sort((left, right) => left.path.localeCompare(right.path));
+
+ for (const file of desiredFiles) {
+ if (!isSafeSetupPath(file.path)) {
+ throw new Error(`Generated setup path is unsafe: ${file.path}`);
+ }
+ }
+
+ return desiredFiles;
+}
+
+function collisionPlan(
+ file: GeneratedFile,
+ currentContent: string,
+ previous: SetupFileRecord | undefined,
+ force: boolean,
+ metadataIsLegacy: boolean,
+): ReconcileFilePlan {
+ const ownership = ownershipForPath(file.path);
+ const currentHash = hashContent(currentContent);
+ const desiredHash = hashContent(file.content);
+ const agentsMergePlan = (reason: string): ReconcileFilePlan => ({
+ path: file.path,
+ ownership,
+ state: 'modified',
+ action: 'merge',
+ reason,
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ });
+
+ if (!previous) {
+ if (currentHash === desiredHash) {
+ return {
+ path: file.path,
+ ownership,
+ state: 'clean',
+ action: 'none',
+ desiredContent: file.content,
+ currentContent,
+ };
+ }
+
+ if (file.path === 'AGENTS.md') {
+ return agentsMergePlan('Existing AGENTS.md will be preserved while missing generated rows are added.');
+ }
+
+ if (ownership === 'protected') {
+ return {
+ path: file.path,
+ ownership,
+ state: 'preserved',
+ action: 'none',
+ reason: metadataIsLegacy
+ ? 'Protected file adopted while migrating schema 5 metadata.'
+ : 'Existing protected file was adopted without replacement.',
+ desiredContent: file.content,
+ currentContent,
+ };
+ }
+
+ return {
+ path: file.path,
+ ownership,
+ state: force ? 'modified' : 'conflict',
+ action: force ? 'overwrite' : 'none',
+ reason: metadataIsLegacy
+ ? 'Existing content cannot be verified against schema 5 metadata.'
+ : 'Existing content has no generated baseline.',
+ desiredContent: file.content,
+ currentContent,
+ };
+ }
+
+ if (currentHash !== previous.contentHash) {
+ if (file.path === 'AGENTS.md') {
+ return agentsMergePlan('Repository changes in AGENTS.md will be preserved during the generated row merge.');
+ }
+
+ if (ownership === 'protected') {
+ return {
+ path: file.path,
+ ownership,
+ state: 'modified',
+ action: 'none',
+ reason: 'Protected file contains repository-owned changes.',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+ }
+
+ return {
+ path: file.path,
+ ownership,
+ state: force ? 'modified' : 'conflict',
+ action: force ? 'overwrite' : 'none',
+ reason: 'File differs from its recorded generated baseline.',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+ }
+
+ if (previous.baseline !== 'adopted' && previous.sourceHash === desiredHash) {
+ return {
+ path: file.path,
+ ownership,
+ state: 'clean',
+ action: 'none',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+ }
+
+ if (file.path === 'AGENTS.md' && previous.baseline === 'adopted' && currentHash !== desiredHash) {
+ return agentsMergePlan('Adopted AGENTS.md content will be preserved during the generated row merge.');
+ }
+
+ if (previous.baseline === 'adopted' && currentHash !== desiredHash && ownership !== 'protected') {
+ return {
+ path: file.path,
+ ownership,
+ state: force ? 'modified' : 'conflict',
+ action: force ? 'overwrite' : 'none',
+ reason: 'Adopted content has no verified generated baseline.',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+ }
+
+ if (currentHash === desiredHash) {
+ return {
+ path: file.path,
+ ownership,
+ state: 'clean',
+ action: 'none',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+ }
+
+ if (ownership === 'protected') {
+ return {
+ path: file.path,
+ ownership,
+ state: 'preserved',
+ action: 'none',
+ reason: 'Protected file is preserved; review the new template separately.',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+ }
+
+ if (ownership === 'structured' && isMergeablePath(file.path)) {
+ return {
+ path: file.path,
+ ownership,
+ state: 'outdated',
+ action: 'merge',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+ }
+
+ return {
+ path: file.path,
+ ownership,
+ state: 'outdated',
+ action: 'overwrite',
+ desiredContent: file.content,
+ currentContent,
+ previous,
+ };
+}
+
+export function buildReconcilePlan(options: ReconcileOptions): ReconcilePlan {
+ if (options.metadata.kind === 'missing') {
+ throw new Error(`Setup metadata not found: ${SETUP_METADATA_PATH}. Run the initial setup first.`);
+ }
+
+ if (options.metadata.kind === 'invalid') {
+ throw new Error(`Invalid setup metadata: ${options.metadata.detail}`);
+ }
+
+ const metadataIsLegacy = options.metadata.kind === 'legacy';
+ const previousMetadata = options.metadata.kind === 'current' ? options.metadata.metadata : undefined;
+ const desiredFiles = desiredSetupFiles(options.files);
+ const desiredPaths = new Set(desiredFiles.map((file) => file.path));
+ const plans: ReconcileFilePlan[] = [];
+
+ for (const file of desiredFiles) {
+ const currentContent = readCurrentFile(options.targetDir, file.path);
+ const previous = previousMetadata?.files[file.path];
+
+ if (currentContent === undefined) {
+ plans.push({
+ path: file.path,
+ ownership: ownershipForPath(file.path),
+ state: 'missing',
+ action: 'create',
+ desiredContent: file.content,
+ previous,
+ });
+ continue;
+ }
+
+ plans.push(collisionPlan(file, currentContent, previous, options.force, metadataIsLegacy));
+ }
+
+ if (previousMetadata) {
+ for (const [filePath, previous] of Object.entries(previousMetadata.files)) {
+ if (desiredPaths.has(filePath)) {
+ continue;
+ }
+
+ const currentContent = readCurrentFile(options.targetDir, filePath);
+
+ if (currentContent === undefined) {
+ continue;
+ }
+
+ const unchanged = hashContent(currentContent) === previous.contentHash;
+ const removable = previous.ownership === 'managed' && unchanged;
+
+ plans.push({
+ path: filePath,
+ ownership: previous.ownership,
+ state: 'orphaned',
+ action: removable ? 'remove' : 'none',
+ reason: removable
+ ? 'Managed file is no longer produced by this release.'
+ : 'Obsolete file was preserved because it is protected or differs from its generated baseline.',
+ currentContent,
+ previous,
+ });
+ }
+ }
+
+ if (metadataIsLegacy) {
+ const plannedPaths = new Set(plans.map((file) => file.path));
+
+ for (const legacyPath of REMOVABLE_LEGACY_SETUP_PATHS) {
+ if (plannedPaths.has(legacyPath) || !fs.existsSync(path.join(options.targetDir, legacyPath))) {
+ continue;
+ }
+
+ plans.push({
+ path: legacyPath,
+ ownership: 'managed',
+ state: options.force ? 'orphaned' : 'conflict',
+ action: options.force ? 'remove' : 'none',
+ reason: 'Legacy generated file has no schema 6 hash baseline.',
+ currentContent: readCurrentFile(options.targetDir, legacyPath),
+ });
+ }
+ }
+
+ plans.sort((left, right) => left.path.localeCompare(right.path));
+
+ const provenance = getCliPackageProvenance();
+ const metadataOutdated =
+ previousMetadata !== undefined &&
+ (previousMetadata.provenance.version !== provenance.version ||
+ previousMetadata.provenance.templateRevision !== provenance.templateRevision);
+ const metadataMigrationRequired = metadataIsLegacy;
+ const hasConflicts = plans.some((file) => file.state === 'conflict');
+ const hasChanges =
+ metadataMigrationRequired ||
+ metadataOutdated ||
+ plans.some((file) => file.action !== 'none' || file.state === 'conflict' || file.state === 'orphaned');
+
+ return {
+ files: plans,
+ metadataMigrationRequired,
+ metadataOutdated,
+ hasChanges,
+ hasConflicts,
+ };
+}
+
+function resultFromPlan(
+ mode: 'check' | 'update',
+ plan: ReconcilePlan,
+ options: ReconcileOptions,
+ applied: boolean,
+ metadataUpdated: boolean,
+): ReconcileResult {
+ const previousVersion =
+ options.metadata.kind === 'current'
+ ? options.metadata.metadata.provenance.version
+ : options.metadata.kind === 'legacy'
+ ? 'schema-5'
+ : 'unknown';
+ const desiredVersion = getCliPackageProvenance().version;
+ const byAction = (action: ReconcileAction): string[] =>
+ plan.files.filter((file) => file.action === action).map((file) => file.path);
+ const byState = (state: ReconcileState): string[] =>
+ plan.files.filter((file) => file.state === state).map((file) => file.path);
+
+ return {
+ mode,
+ applied,
+ metadataMigrationRequired: plan.metadataMigrationRequired,
+ metadataUpdated,
+ previousVersion,
+ desiredVersion,
+ created: byAction('create'),
+ updated: byAction('overwrite'),
+ merged: byAction('merge'),
+ removed: byAction('remove'),
+ preserved: plan.files
+ .filter((file) => file.state === 'preserved' || file.state === 'modified')
+ .map((file) => file.path),
+ unchanged: byState('clean'),
+ conflicts: byState('conflict'),
+ missing: byState('missing'),
+ outdated: byState('outdated'),
+ orphaned: byState('orphaned'),
+ plan,
+ };
+}
+
+export function checkSetup(options: Omit): ReconcileResult {
+ const reconcileOptions = { ...options, dryRun: true };
+ const plan = buildReconcilePlan(reconcileOptions);
+
+ return resultFromPlan('check', plan, reconcileOptions, false, false);
+}
+
+function writeFile(targetDir: string, filePath: string, content: string): void {
+ const destination = path.join(targetDir, filePath);
+
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
+ fs.writeFileSync(destination, content, 'utf8');
+}
+
+function buildUpdatedMetadata(options: ReconcileOptions, plan: ReconcilePlan) {
+ const desiredFiles = desiredSetupFiles(options.files);
+ const installedContent = new Map();
+ const planByPath = new Map(plan.files.map((filePlan) => [filePlan.path, filePlan]));
+
+ for (const file of desiredFiles) {
+ const filePlan = planByPath.get(file.path);
+
+ if (!filePlan) {
+ continue;
+ }
+
+ let finalContent: string;
+
+ if (filePlan.action === 'create' || filePlan.action === 'overwrite') {
+ finalContent = file.content;
+ } else if (filePlan.action === 'merge') {
+ finalContent = mergeFile(file.path, filePlan.currentContent ?? '', file.content);
+ } else {
+ finalContent = filePlan.currentContent ?? file.content;
+ }
+
+ installedContent.set(file.path, finalContent);
+ }
+
+ const metadata = createSetupMetadata(
+ {
+ assistants: options.assistants,
+ playwrightMcp: resolvePlaywrightMcpTargets(options.assistants, options.playwrightMcpInclude),
+ figmaMcp: resolveFigmaMcpTargets(options.assistants, options.figmaMcpInclude),
+ qaAiRulesEnabled: options.qaAiRulesInclude,
+ },
+ desiredFiles,
+ installedContent,
+ );
+
+ for (const filePlan of plan.files) {
+ if (filePlan.state === 'orphaned' && filePlan.action !== 'remove' && filePlan.previous && filePlan.currentContent) {
+ metadata.files[filePlan.path] = {
+ ...filePlan.previous,
+ contentHash: hashContent(filePlan.currentContent),
+ };
+ continue;
+ }
+
+ const record = metadata.files[filePlan.path];
+
+ if (!record) {
+ continue;
+ }
+
+ if (filePlan.action === 'merge') {
+ record.baseline = 'merged';
+ } else if (filePlan.state === 'preserved' || filePlan.state === 'modified') {
+ record.baseline = 'adopted';
+ }
+ }
+
+ return metadata;
+}
+
+export function updateSetup(options: ReconcileOptions): ReconcileResult {
+ const plan = buildReconcilePlan(options);
+
+ if (plan.hasConflicts) {
+ return resultFromPlan('update', plan, options, false, false);
+ }
+
+ if (!options.dryRun) {
+ for (const filePlan of plan.files) {
+ if (filePlan.action === 'create' || filePlan.action === 'overwrite') {
+ writeFile(options.targetDir, filePlan.path, filePlan.desiredContent ?? '');
+ } else if (filePlan.action === 'merge') {
+ writeFile(
+ options.targetDir,
+ filePlan.path,
+ mergeFile(filePlan.path, filePlan.currentContent ?? '', filePlan.desiredContent ?? ''),
+ );
+ } else if (filePlan.action === 'remove') {
+ fs.rmSync(path.join(options.targetDir, filePlan.path), { force: true });
+ }
+ }
+ }
+
+ const updatedMetadata = buildUpdatedMetadata(options, plan);
+ const serializedMetadata = serializeSetupMetadata(updatedMetadata);
+ const currentMetadata =
+ options.metadata.kind === 'current' ? serializeSetupMetadata(options.metadata.metadata) : undefined;
+ const metadataUpdated = serializedMetadata !== currentMetadata;
+
+ if (!options.dryRun && metadataUpdated) {
+ writeFile(options.targetDir, SETUP_METADATA_PATH, serializedMetadata);
+ }
+
+ return resultFromPlan('update', plan, options, !options.dryRun, metadataUpdated);
+}
diff --git a/src/setup-log.ts b/src/setup-log.ts
index 8c013ca..90769d8 100644
--- a/src/setup-log.ts
+++ b/src/setup-log.ts
@@ -46,9 +46,7 @@ export function setupLog(message: string, env: NodeJS.ProcessEnv = process.env):
console.warn(`${LOG_PREFIX} ${message}`);
}
-export function createSetupDebugLogger(
- env: NodeJS.ProcessEnv = process.env,
-): ((message: string) => void) | undefined {
+export function createSetupDebugLogger(env: NodeJS.ProcessEnv = process.env): ((message: string) => void) | undefined {
if (!isSetupDebugEnabled(env)) {
return undefined;
}
@@ -57,6 +55,30 @@ export function createSetupDebugLogger(
}
let cachedVersion: string | undefined;
+let cachedProvenance: CliPackageProvenance | undefined;
+
+export type CliPackageProvenance = {
+ package: string;
+ version: string;
+ releaseCommit: string;
+ templateRevision: string;
+};
+
+type PackageMetadata = {
+ name?: string;
+ version?: string;
+ gitHead?: string;
+};
+
+type ReleaseMetadata = {
+ releaseCommit?: string;
+};
+
+function readPackageMetadata(): PackageMetadata {
+ const packagePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
+
+ return JSON.parse(readFileSync(packagePath, 'utf8')) as PackageMetadata;
+}
/** Best-effort CLI version from package.json next to dist/. */
export function getCliPackageVersion(): string {
@@ -65,8 +87,7 @@ export function getCliPackageVersion(): string {
}
try {
- const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string };
+ const pkg = readPackageMetadata();
cachedVersion = pkg.version ?? 'unknown';
} catch {
@@ -75,3 +96,42 @@ export function getCliPackageVersion(): string {
return cachedVersion;
}
+
+/** Stable package and release identity recorded in generated setup metadata. */
+export function getCliPackageProvenance(): CliPackageProvenance {
+ if (cachedProvenance !== undefined) {
+ return cachedProvenance;
+ }
+
+ try {
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
+ const pkg = readPackageMetadata();
+ let release: ReleaseMetadata = {};
+
+ try {
+ release = JSON.parse(readFileSync(path.join(moduleDir, 'release-info.json'), 'utf8')) as ReleaseMetadata;
+ } catch {
+ // Source builds do not have release-info.json; package metadata remains a deterministic fallback.
+ }
+
+ const packageName = pkg.name ?? '@metricinsights/ca-ai-tools-setup';
+ const version = pkg.version ?? 'unknown';
+ const releaseCommit = release.releaseCommit ?? pkg.gitHead ?? 'unknown';
+
+ cachedProvenance = {
+ package: packageName,
+ version,
+ releaseCommit,
+ templateRevision: releaseCommit === 'unknown' ? `${packageName}@${version}` : releaseCommit,
+ };
+ } catch {
+ cachedProvenance = {
+ package: '@metricinsights/ca-ai-tools-setup',
+ version: 'unknown',
+ releaseCommit: 'unknown',
+ templateRevision: '@metricinsights/ca-ai-tools-setup@unknown',
+ };
+ }
+
+ return cachedProvenance;
+}
diff --git a/src/setup-metadata.ts b/src/setup-metadata.ts
new file mode 100644
index 0000000..8cd2bac
--- /dev/null
+++ b/src/setup-metadata.ts
@@ -0,0 +1,385 @@
+import { createHash } from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { ASSISTANTS, METADATA_VERSION, QA_AI_RULES_PACKAGE, RELEASE_TGZ_LATEST, type Assistant } from './constants.js';
+import { getCliPackageProvenance, type CliPackageProvenance } from './setup-log.js';
+import type { GeneratedFile } from './generators/types.js';
+
+export const SETUP_METADATA_PATH = '.assistant-setup/ca-ai-tools-setup.json';
+export const SETUP_STATUS_PATH = '.assistant-setup/SETUP_STATUS.md';
+
+export type FileOwnership = 'managed' | 'protected' | 'structured';
+export type FileBaseline = 'generated' | 'adopted' | 'merged';
+
+export type SetupFileRecord = {
+ ownership: FileOwnership;
+ contentHash: string;
+ sourceHash: string;
+ sourceVersion: string;
+ baseline: FileBaseline;
+};
+
+export type McpTargets = {
+ cursorFile: boolean;
+ projectRootFile: boolean;
+};
+
+export type SetupMetadata = {
+ version: typeof METADATA_VERSION;
+ assistants: Assistant[];
+ playwrightMcp: McpTargets;
+ figmaMcp: McpTargets;
+ devEnvironment: {
+ file: '.dev-environment.md';
+ generated: true;
+ };
+ pageWorkflowContext: {
+ file: '.assistant-setup/page-workflow-context.md';
+ generated: true;
+ };
+ linearCliReference: {
+ file: 'LINEAR_CLI.md';
+ generated: true;
+ };
+ qaAiRules: {
+ enabled: boolean;
+ package: typeof QA_AI_RULES_PACKAGE;
+ };
+ provenance: CliPackageProvenance;
+ files: Record;
+};
+
+export type LoadedSetupMetadata =
+ | { kind: 'missing' }
+ | { kind: 'invalid'; detail: string }
+ | { kind: 'legacy'; raw: Record }
+ | { kind: 'current'; metadata: SetupMetadata };
+
+export type MetadataConfiguration = {
+ assistants: Assistant[];
+ playwrightMcp: McpTargets;
+ figmaMcp: McpTargets;
+ qaAiRulesEnabled: boolean;
+};
+
+export function createSetupStatusContent(
+ configuration: MetadataConfiguration,
+ provenance = getCliPackageProvenance(),
+): string {
+ const marker = {
+ id: 'ca-ai-tools-setup',
+ schemaVersion: METADATA_VERSION,
+ package: provenance.package,
+ packageVersion: provenance.version,
+ templateRevision: provenance.templateRevision,
+ assistants: configuration.assistants,
+ };
+
+ return `
+# AI Assistant Setup Status
+
+This tracked file confirms that \`${provenance.package}\` generated the repository's AI assistant setup.
+Its presence does not guarantee that the installed templates are the latest available release.
+
+## Agent check
+
+- If this file is missing, report that the repository setup is absent or incomplete.
+- Read the marker above to identify the installed package and template revision.
+- Run this read-only check once before setup-sensitive work:
+
+\`\`\`bash
+npx --yes --package=${RELEASE_TGZ_LATEST} ca-ai-tools-setup check .
+\`\`\`
+
+- Exit code \`0\`: the tracked setup matches the selected package release.
+- Exit code \`2\`: the setup is stale, incomplete, or needs migration; report it and propose an update PR.
+- Exit code \`1\`: the check failed; report the error instead of treating the setup as current.
+- Never run \`update\` or \`--force\` without explicit developer approval.
+
+Do not edit this file manually; a setup or update run refreshes it deterministically.
+`;
+}
+
+const STRUCTURED_PATHS = new Set(['.cursor/mcp.json', '.mcp.json', '.claude/settings.json', 'AGENTS.md']);
+
+const PROTECTED_PATHS = new Set([
+ '.cursorrules',
+ '.cursorignore',
+ 'CLAUDE.md',
+ '.dev-environment.md',
+ '.assistant-setup/page-workflow-context.md',
+]);
+
+function isAssistant(value: unknown): value is Assistant {
+ return typeof value === 'string' && (ASSISTANTS as readonly string[]).includes(value);
+}
+
+function isMcpTargets(value: unknown): value is McpTargets {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const targets = value as Record;
+
+ return typeof targets.cursorFile === 'boolean' && typeof targets.projectRootFile === 'boolean';
+}
+
+function isFileOwnership(value: unknown): value is FileOwnership {
+ return value === 'managed' || value === 'protected' || value === 'structured';
+}
+
+function isFileBaseline(value: unknown): value is FileBaseline {
+ return value === 'generated' || value === 'adopted' || value === 'merged';
+}
+
+function parseFileRecords(value: unknown): Record | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null;
+ }
+
+ const records: Record = {};
+
+ for (const [filePath, rawRecord] of Object.entries(value as Record)) {
+ const normalizedPath = normalizeSetupPath(filePath);
+
+ if (!isSafeSetupPath(normalizedPath) || records[normalizedPath] !== undefined) {
+ return null;
+ }
+
+ if (!rawRecord || typeof rawRecord !== 'object' || Array.isArray(rawRecord)) {
+ return null;
+ }
+
+ const record = rawRecord as Record;
+
+ if (
+ !isFileOwnership(record.ownership) ||
+ typeof record.contentHash !== 'string' ||
+ typeof record.sourceHash !== 'string' ||
+ typeof record.sourceVersion !== 'string' ||
+ !isFileBaseline(record.baseline)
+ ) {
+ return null;
+ }
+
+ records[normalizedPath] = {
+ ownership: ownershipForPath(normalizedPath),
+ contentHash: record.contentHash,
+ sourceHash: record.sourceHash,
+ sourceVersion: record.sourceVersion,
+ baseline: record.baseline,
+ };
+ }
+
+ return sortFileRecords(records);
+}
+
+function parseCurrentMetadata(raw: Record): SetupMetadata | null {
+ if (raw.version !== METADATA_VERSION || !Array.isArray(raw.assistants)) {
+ return null;
+ }
+
+ if (
+ raw.assistants.length === 0 ||
+ !raw.assistants.every(isAssistant) ||
+ new Set(raw.assistants).size !== raw.assistants.length
+ ) {
+ return null;
+ }
+
+ const assistants = raw.assistants as Assistant[];
+ const provenance = raw.provenance;
+ const qaAiRules = raw.qaAiRules;
+ const fileRecords = parseFileRecords(raw.files);
+
+ if (
+ !isMcpTargets(raw.playwrightMcp) ||
+ !isMcpTargets(raw.figmaMcp) ||
+ !provenance ||
+ typeof provenance !== 'object' ||
+ typeof (provenance as Record).package !== 'string' ||
+ typeof (provenance as Record).version !== 'string' ||
+ typeof (provenance as Record).releaseCommit !== 'string' ||
+ typeof (provenance as Record).templateRevision !== 'string' ||
+ !qaAiRules ||
+ typeof qaAiRules !== 'object' ||
+ typeof (qaAiRules as Record).enabled !== 'boolean' ||
+ fileRecords === null
+ ) {
+ return null;
+ }
+
+ return {
+ version: METADATA_VERSION,
+ assistants,
+ playwrightMcp: raw.playwrightMcp,
+ figmaMcp: raw.figmaMcp,
+ devEnvironment: {
+ file: '.dev-environment.md',
+ generated: true,
+ },
+ pageWorkflowContext: {
+ file: '.assistant-setup/page-workflow-context.md',
+ generated: true,
+ },
+ linearCliReference: {
+ file: 'LINEAR_CLI.md',
+ generated: true,
+ },
+ qaAiRules: {
+ enabled: (qaAiRules as Record).enabled as boolean,
+ package: QA_AI_RULES_PACKAGE,
+ },
+ provenance: {
+ package: (provenance as Record).package as string,
+ version: (provenance as Record).version as string,
+ releaseCommit: (provenance as Record).releaseCommit as string,
+ templateRevision: (provenance as Record).templateRevision as string,
+ },
+ files: fileRecords,
+ };
+}
+
+export function normalizeSetupPath(filePath: string): string {
+ return filePath.replaceAll('\\', '/');
+}
+
+export function isSafeSetupPath(filePath: string): boolean {
+ const normalized = normalizeSetupPath(filePath);
+ const segments = normalized.split('/');
+
+ return (
+ normalized.length > 0 &&
+ !normalized.includes('\0') &&
+ !path.posix.isAbsolute(normalized) &&
+ !path.win32.isAbsolute(normalized) &&
+ segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') &&
+ path.posix.normalize(normalized) === normalized
+ );
+}
+
+export function hashContent(content: string): string {
+ const normalizedContent = content.replace(/\r\n?/gu, '\n');
+
+ return `sha256:${createHash('sha256').update(normalizedContent, 'utf8').digest('hex')}`;
+}
+
+export function ownershipForPath(filePath: string): FileOwnership {
+ const normalized = normalizeSetupPath(filePath);
+
+ if (STRUCTURED_PATHS.has(normalized)) {
+ return 'structured';
+ }
+
+ if (PROTECTED_PATHS.has(normalized)) {
+ return 'protected';
+ }
+
+ return 'managed';
+}
+
+export function sortFileRecords(records: Record): Record {
+ return Object.fromEntries(Object.entries(records).sort(([left], [right]) => left.localeCompare(right)));
+}
+
+export function createSetupMetadata(
+ configuration: MetadataConfiguration,
+ files: GeneratedFile[],
+ installedContent: ReadonlyMap = new Map(),
+ provenance = getCliPackageProvenance(),
+): SetupMetadata {
+ const records: Record = {};
+
+ for (const file of files) {
+ const normalizedPath = normalizeSetupPath(file.path);
+
+ if (normalizedPath === SETUP_METADATA_PATH) {
+ continue;
+ }
+
+ if (!isSafeSetupPath(normalizedPath)) {
+ throw new Error(`Generated setup path is unsafe: ${file.path}`);
+ }
+
+ const actualContent = installedContent.get(normalizedPath) ?? file.content;
+ const sourceHash = hashContent(file.content);
+ const contentHash = hashContent(actualContent);
+
+ records[normalizedPath] = {
+ ownership: ownershipForPath(normalizedPath),
+ contentHash,
+ sourceHash,
+ sourceVersion: provenance.version,
+ baseline: contentHash === sourceHash ? 'generated' : 'adopted',
+ };
+ }
+
+ return {
+ version: METADATA_VERSION,
+ assistants: configuration.assistants,
+ playwrightMcp: configuration.playwrightMcp,
+ figmaMcp: configuration.figmaMcp,
+ devEnvironment: {
+ file: '.dev-environment.md',
+ generated: true,
+ },
+ pageWorkflowContext: {
+ file: '.assistant-setup/page-workflow-context.md',
+ generated: true,
+ },
+ linearCliReference: {
+ file: 'LINEAR_CLI.md',
+ generated: true,
+ },
+ qaAiRules: {
+ enabled: configuration.qaAiRulesEnabled,
+ package: QA_AI_RULES_PACKAGE,
+ },
+ provenance,
+ files: sortFileRecords(records),
+ };
+}
+
+export function serializeSetupMetadata(metadata: SetupMetadata): string {
+ return `${JSON.stringify({ ...metadata, files: sortFileRecords(metadata.files) }, null, 2)}\n`;
+}
+
+export function loadSetupMetadata(targetDir: string): LoadedSetupMetadata {
+ const metadataPath = path.join(targetDir, SETUP_METADATA_PATH);
+
+ if (!fs.existsSync(metadataPath)) {
+ return { kind: 'missing' };
+ }
+
+ let raw: unknown;
+
+ try {
+ raw = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
+ } catch (error) {
+ return {
+ kind: 'invalid',
+ detail: error instanceof Error ? error.message : 'Metadata is not valid JSON.',
+ };
+ }
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ return { kind: 'invalid', detail: 'Metadata root must be a JSON object.' };
+ }
+
+ const record = raw as Record;
+
+ if (record.version === 5 || record.version === undefined) {
+ return { kind: 'legacy', raw: record };
+ }
+
+ const metadata = parseCurrentMetadata(record);
+
+ if (!metadata) {
+ return {
+ kind: 'invalid',
+ detail: `Unsupported or malformed metadata schema ${String(record.version ?? 'unknown')}.`,
+ };
+ }
+
+ return { kind: 'current', metadata };
+}
diff --git a/templates/AGENTS.md b/templates/AGENTS.md
index bf106e4..3b182d4 100644
--- a/templates/AGENTS.md
+++ b/templates/AGENTS.md
@@ -2,17 +2,24 @@
Markdown files under **`.claude/agents/`** define **specialized rules** Claude should follow when a task matches (e.g. Figma MCP implementation). **`CLAUDE.md`** instructs Claude to consult these agents; this file is a **human-readable index** so contributors know what exists and when to use it. **Cursor** can read **`AGENTS.md`** too for the same index (see **`.cursorrules`**).
+## Setup health
+
+Check **`.assistant-setup/SETUP_STATUS.md`** before setup-sensitive work. Its absence means
+**`ca-ai-tools-setup` is missing or incomplete**. Follow
+**`.cursor/rules/assistant-setup-health.mdc`** for the read-only freshness check, and never run
+`update` or `--force` without explicit developer approval.
+
## Registered agents
-| File | Purpose |
-| ---- | ------- |
-| `code-style.md` | Portal Page naming, SCSS modules, BEM, `app-context` / `app-provider`, `constants.ts`, `index.html` globals. Always-on for development. |
-| `frontend-architecture.md` | Component hierarchy, design system first, reuse, JS/TS formatting (tooling-first), TypeScript, minimal scope. Always-on for development. |
-| `commit-convention.md` | Git commit message format — Angular commit message convention (`type(scope): subject`, body, footer). Always-on whenever writing commit messages. |
-| `qa-tester.md` | Test cases, Playwright execution (CLI or MCP), bug documentation, and local `test-documentation/` layout. |
-| `ui-verifier.md` | Browser UI checks (Playwright CLI or MCP), visual verification, and screenshot evidence for Linear comments. |
-| `linear-reporter.md` | Publish QA results to Linear (comments, state, embedded screenshots). |
-| `figma-mcp.md` | Figma MCP: structure-first implementation, tokens, Code Connect, layout fidelity. _(Present only if this repo was bootstrapped with Figma MCP for Claude.)_ |
+| File | Purpose |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `code-style.md` | Portal Page naming, SCSS modules, BEM, `app-context` / `app-provider`, `constants.ts`, `index.html` globals. Always-on for development. |
+| `frontend-architecture.md` | Component hierarchy, design system first, reuse, JS/TS formatting (tooling-first), TypeScript, minimal scope. Always-on for development. |
+| `commit-convention.md` | Git commit message format — Angular commit message convention (`type(scope): subject`, body, footer). Always-on whenever writing commit messages. |
+| `qa-tester.md` | Test cases, Playwright execution (CLI or MCP), bug documentation, and local `test-documentation/` layout. |
+| `ui-verifier.md` | Browser UI checks (Playwright CLI or MCP), visual verification, and screenshot evidence for Linear comments. |
+| `linear-reporter.md` | Publish QA results to Linear (comments, state, embedded screenshots). |
+| `figma-mcp.md` | Figma MCP: structure-first implementation, tokens, Code Connect, layout fidelity. _(Present only if this repo was bootstrapped with Figma MCP for Claude.)_ |
Add a row when you introduce a new **`.claude/agents/.md`** file.
@@ -21,5 +28,5 @@ Add a row when you introduce a new **`.claude/agents/.md`** file.
This starter is produced by **`ca-ai-tools-setup`** as a **shared** repo-root file (not tied to a single assistant).
- **Customize** the table for your repo: fix descriptions, add agents, or remove rows for files you delete.
-- **Re-running the installer:** Without **`--force`**, existing **`AGENTS.md`** is **left unchanged**. To replace with the latest template from the tool, back up your file, run with **`--force`**, or **manually merge** updates (especially new rows for agents added by the bootstrapper).
+- **Re-running the installer:** Existing **`AGENTS.md`** content is preserved, including with **`--force`**. The installer only adds missing generated agent rows; review the resulting diff and update existing descriptions manually when needed.
- **After changing `.claude/agents/`** on disk, update this index so it stays accurate.
diff --git a/templates/assistant-setup/dev-environment.md b/templates/assistant-setup/dev-environment.md
index f4ca490..145dd35 100644
--- a/templates/assistant-setup/dev-environment.md
+++ b/templates/assistant-setup/dev-environment.md
@@ -1,7 +1,7 @@
# Developer Local Environment
-> Fill this file with your personal local setup details.
-> Keep it out of git: add `.dev-environment.md` to `.gitignore`.
+> Commit this file as shared repository guidance for supported local setups and platform-specific variations.
+> Do not store tokens, passwords, session values, or other per-developer secrets here.
## System and Shell Context
@@ -178,4 +178,4 @@ Metric Insights instance docs can diverge from [published API reference](https:/
## Notes
-Add any personal workflow notes, quick commands, and environment caveats.
+Add shared workflow notes, quick commands, supported-platform differences, and environment caveats.
diff --git a/templates/claude/CLAUDE.md b/templates/claude/CLAUDE.md
index 298e4dd..5bdaf13 100644
--- a/templates/claude/CLAUDE.md
+++ b/templates/claude/CLAUDE.md
@@ -7,6 +7,9 @@ as the `linear-cli` command reference.
## Scope
+- Check `.assistant-setup/SETUP_STATUS.md` before setup-sensitive work. If it is missing, report that
+ `ca-ai-tools-setup` is absent or incomplete. Follow `.cursor/rules/assistant-setup-health.mdc` for the
+ read-only freshness check; never run `update` or `--force` without explicit developer approval.
- Read `AGENTS.md` and `README.md` before non-trivial code changes.
- Follow **`.claude/agents/code-style.md`** (Portal Page naming, SCSS modules, BEM,
`app-context` / `app-provider`, `constants.ts`, `index.html` globals). Cursor mirror:
diff --git a/templates/cursor/rules/README.md b/templates/cursor/rules/README.md
index e26d4eb..0949b41 100644
--- a/templates/cursor/rules/README.md
+++ b/templates/cursor/rules/README.md
@@ -30,6 +30,7 @@ This directory contains Metric Insights guidance for using Linear CLI inside Cur
## Files
+- `assistant-setup-health.mdc` - always-on detection for missing or stale tracked assistant setup.
- `code-style.mdc` - Portal Page component naming, SCSS modules, BEM, app context, constants, index.html globals.
- `frontend-architecture.mdc` - component hierarchy, design system first, reuse, JS/TS formatting (tooling-first), TypeScript, minimal scope.
- `commit-convention.mdc` - Git commit message format (Angular commit message convention).
diff --git a/templates/cursor/rules/assistant-setup-health.mdc b/templates/cursor/rules/assistant-setup-health.mdc
new file mode 100644
index 0000000..d85f953
--- /dev/null
+++ b/templates/cursor/rules/assistant-setup-health.mdc
@@ -0,0 +1,21 @@
+---
+description: Detect missing or stale ca-ai-tools-setup configuration
+globs:
+alwaysApply: true
+---
+
+# AI assistant setup health
+
+Before non-trivial work that depends on repository rules, skills, agents, MCP, or setup conventions:
+
+1. Check for **`.assistant-setup/SETUP_STATUS.md`**.
+2. If it is missing, report that **`ca-ai-tools-setup` has not been installed or is incomplete** and propose
+ running the initial setup. Do not claim the repository AI configuration is current.
+3. If it exists, read its machine-readable marker and follow its **Agent check** instructions.
+4. Run the documented **read-only** `ca-ai-tools-setup check .` command once per session when setup freshness
+ matters.
+5. Exit code **`2`** means the tracked setup is stale, incomplete, or requires migration. Report that state and
+ propose a reviewable update PR.
+
+Never run `update` or `--force` without explicit developer approval. A failed check is an error to report,
+not proof that the setup is current.
diff --git a/templates/setup-claude-assistant.md b/templates/setup-claude-assistant.md
index 0d23ad2..aece720 100644
--- a/templates/setup-claude-assistant.md
+++ b/templates/setup-claude-assistant.md
@@ -85,9 +85,9 @@ The **`ca-ai-tools-setup`** installer writes **`AGENTS.md`** in the repository r
- **Bring them up to date:** Add repo-specific conventions to **`CLAUDE.md`** (tests, branching,
ownership). Keep **`AGENTS.md`** aligned with files under **`.claude/agents/`** (add/remove rows
in the table when agents change).
-- **Re-running setup:** A new installer run **does not overwrite** existing **`CLAUDE.md`**,
- **`.claude/settings.json`**, or **`AGENTS.md`** unless you pass **`--force`**—to preserve local edits.
- To refresh from the latest templates, merge manually or back up and run with **`--force`**.
+- **Re-running setup:** A new installer run does not overwrite existing **`CLAUDE.md`** or
+ **`.claude/settings.json`** unless you pass **`--force`**. Existing **`AGENTS.md`** is never
+ replaced, including with **`--force`**; the installer only merges missing generated agent rows.
- In **`CLAUDE.md`**, ensure Claude is told to use specialized agents from **`.claude/agents/*.md`**
when available (the starter already does); extend as needed.
- If **`.claude/agents/figma-mcp.md`** exists, ensure **`CLAUDE.md`** or **`AGENTS.md`** points at it
@@ -320,22 +320,20 @@ Invoke the CLI as **`npx playwright-cli ...`** (resolves the pinned local versio
### Step 3: Developer environment profile (finalize after setup)
-After installing tools, update **`.dev-environment.md`** with the complete local profile
+After installing tools, update the tracked **`.dev-environment.md`** with the shared repository profile
(generator metadata remains in **`.assistant-setup/ca-ai-tools-setup.json`**):
-- If the file is missing, create it from the generated template and fill in local details.
-- **Local app URL** (`http://localhost:` — default **3000**, otherwise next free port; record
- the actual port from `pp-dev.config`, the running server, or the browser).
+- If the file is missing, create it from the generated template and add it to the setup PR.
+- **Local app URL convention** (`http://localhost:` — default **3000**, otherwise next free port; record
+ the configured default and how developers discover overrides).
- **Authentication** section: **`MI_ACCESS_TOKEN`** status, validation notes (`/data/page/index/auth/info`), session vs token, and **Credentials** line — **usernames only** in **`.dev-environment.md`**; passwords belong in **`.mi-credentials.local.env`** (add **`/.mi-credentials.local.env`** to **`.gitignore`**).
- **API compatibility notes** for your Metric Insights instance (confirmed endpoint/field differences;
see Step 1.2).
-- **Last verification date** for this profile (when URL, auth, and API checks were last confirmed).
-- **OS, architecture, and shells** (PowerShell, cmd, bash, zsh, etc.) so future commands use the
- correct syntax.
-- Keep shell notes explicit (for example: "primary shell is PowerShell; avoid `&&` and Bash
- heredocs").
-- Add **`.dev-environment.md`** to **`.gitignore`** if not already present (this file is personal
- and should not be committed).
+- **Last verification date** for the shared profile (when URL, auth, and API checks were last confirmed).
+- **Supported OS, architectures, and shells** (PowerShell, cmd, bash, zsh, etc.) so future commands use the
+ correct syntax for each platform.
+- Keep platform notes explicit (for example: "PowerShell users should avoid `&&` and Bash heredocs").
+- Commit **`.dev-environment.md`**; keep machine-specific credentials and tokens in ignored local files.
### Step 4: Final verification
diff --git a/templates/setup-cursor-assistant.md b/templates/setup-cursor-assistant.md
index 66df66d..d0ddef9 100644
--- a/templates/setup-cursor-assistant.md
+++ b/templates/setup-cursor-assistant.md
@@ -308,22 +308,20 @@ Invoke the CLI as **`npx playwright-cli ...`** (resolves the pinned local versio
### Step 3: Developer environment profile (finalize after setup)
-After installing tools, update **`.dev-environment.md`** with the complete local profile
+After installing tools, update the tracked **`.dev-environment.md`** with the shared repository profile
(generator metadata remains in **`.assistant-setup/ca-ai-tools-setup.json`**):
-- If the file is missing, create it from the generated template and fill in local details.
-- **Local app URL** (`http://localhost:` — default **3000**, otherwise next free port; record
- the actual port from `pp-dev.config`, the running server, or the browser).
+- If the file is missing, create it from the generated template and add it to the setup PR.
+- **Local app URL convention** (`http://localhost:` — default **3000**, otherwise next free port; record
+ the configured default and how developers discover overrides).
- **Authentication** section: **`MI_ACCESS_TOKEN`** status, validation notes (`/data/page/index/auth/info`), session vs token, and **Credentials** line — **usernames only** in **`.dev-environment.md`**; passwords belong in **`.mi-credentials.local.env`** (add **`/.mi-credentials.local.env`** to **`.gitignore`**).
- **API compatibility notes** for your Metric Insights instance (confirmed endpoint/field differences;
see Step 1.2).
-- **Last verification date** for this profile (when URL, auth, and API checks were last confirmed).
-- **OS, architecture, and shells** (PowerShell, cmd, bash, zsh, etc.) so future commands use the
- correct syntax.
-- Keep shell notes explicit (for example: "primary shell is PowerShell; avoid `&&` and Bash
- heredocs").
-- Add **`.dev-environment.md`** to **`.gitignore`** if not already present (this file is personal
- and should not be committed).
+- **Last verification date** for the shared profile (when URL, auth, and API checks were last confirmed).
+- **Supported OS, architectures, and shells** (PowerShell, cmd, bash, zsh, etc.) so future commands use the
+ correct syntax for each platform.
+- Keep platform notes explicit (for example: "PowerShell users should avoid `&&` and Bash heredocs").
+- Commit **`.dev-environment.md`**; keep machine-specific credentials and tokens in ignored local files.
### Step 4: Final verification
diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts
new file mode 100644
index 0000000..436526a
--- /dev/null
+++ b/tests/cli-args.test.ts
@@ -0,0 +1,33 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { cliMode, firstNonEmptyTarget, parseCliArgs, validateCliArgs } from '../src/cli-args.js';
+
+test('cliMode recognizes check and update subcommands', () => {
+ assert.equal(cliMode(parseCliArgs(['check'])), 'check');
+ assert.equal(cliMode(parseCliArgs(['update'])), 'update');
+ assert.equal(cliMode(parseCliArgs([])), 'generate');
+ assert.equal(cliMode(parseCliArgs(['some-target'])), 'generate');
+});
+
+test('firstNonEmptyTarget skips a recognized subcommand', () => {
+ assert.equal(firstNonEmptyTarget(parseCliArgs(['check', '../repo'])), '../repo');
+ assert.equal(firstNonEmptyTarget(parseCliArgs(['update', '../repo'])), '../repo');
+ assert.equal(firstNonEmptyTarget(parseCliArgs(['../repo'])), '../repo');
+});
+
+test('explicit --target takes precedence over positional targets', () => {
+ assert.equal(firstNonEmptyTarget(parseCliArgs(['check', '../positional', '--target', '../flag'])), '../flag');
+});
+
+test('parseCliArgs supports version aliases', () => {
+ assert.equal(parseCliArgs(['--version']).version, true);
+ assert.equal(parseCliArgs(['-v']).version, true);
+});
+
+test('validateCliArgs rejects command typos with an additional target', () => {
+ assert.throws(
+ () => validateCliArgs(parseCliArgs(['chek', '../repo'])),
+ /Unknown command or too many positional arguments/,
+ );
+ assert.throws(() => validateCliArgs(parseCliArgs(['check', '../repo', 'extra'])), /Too many positional/);
+});
diff --git a/tests/cli.test.ts b/tests/cli.test.ts
index f153951..9196113 100644
--- a/tests/cli.test.ts
+++ b/tests/cli.test.ts
@@ -37,6 +37,7 @@ test('cli --dry-run does not write generated files', () => {
assert.equal(fs.existsSync(path.join(targetDir, 'setup-cursor-assistant.md')), false);
assert.equal(fs.existsSync(path.join(targetDir, '.dev-environment.md')), false);
assert.equal(fs.existsSync(path.join(targetDir, '.assistant-setup/page-workflow-context.md')), false);
+ assert.equal(fs.existsSync(path.join(targetDir, '.assistant-setup/SETUP_STATUS.md')), false);
assert.equal(fs.existsSync(path.join(targetDir, '.assistant-setup/ca-ai-tools-setup.json')), false);
assert.equal(fs.existsSync(path.join(targetDir, 'LINEAR_CLI.md')), false);
});
@@ -91,7 +92,7 @@ test('cli --force overwrites existing generated files', () => {
assert.equal(first.status, 0, `Initial run failed.\nSTDERR:\n${first.stderr}`);
const cursorRulesPath = path.join(targetDir, '.cursorrules');
-
+
fs.writeFileSync(cursorRulesPath, 'MANUAL TEST CONTENT\n', 'utf8');
const second = runCli(['--target', targetDir, '--assistants', 'cursor', '--yes']);
@@ -131,7 +132,7 @@ test('cli --yes skips existing .cursor/mcp.json (no merge prompt in non-interact
assert.match(result.stdout, /\.cursor\/mcp\.json/);
});
-test('cli --yes skips existing .claude/settings.json and AGENTS.md', () => {
+test('cli --yes skips existing settings and safely merges AGENTS.md', () => {
const targetDir = makeTempDir();
const settingsPath = path.join(targetDir, '.claude/settings.json');
const agentsPath = path.join(targetDir, 'AGENTS.md');
@@ -154,7 +155,8 @@ test('cli --yes skips existing .claude/settings.json and AGENTS.md', () => {
assert.equal(result.status, 0, `CLI exited with non-zero status.\nSTDERR:\n${result.stderr}`);
assert.equal(fs.readFileSync(settingsPath, 'utf8'), customSettings);
- assert.equal(fs.readFileSync(agentsPath, 'utf8'), customAgents);
+ assert.match(fs.readFileSync(agentsPath, 'utf8'), /Do not overwrite\./);
+ assert.match(fs.readFileSync(agentsPath, 'utf8'), /`code-style\.md`/);
assert.match(result.stdout, /\.claude\/settings\.json/);
assert.match(result.stdout, /AGENTS\.md/);
});
@@ -198,7 +200,7 @@ test('cli --yes skips existing .mcp.json and keeps configured token', () => {
assert.match(result.stdout, /\.mcp\.json/);
});
-test('cli --force overwrites existing mergeable files for claude flow', () => {
+test('cli --force overwrites settings but safely merges existing AGENTS.md', () => {
const targetDir = makeTempDir();
const settingsPath = path.join(targetDir, '.claude/settings.json');
const agentsPath = path.join(targetDir, 'AGENTS.md');
@@ -220,7 +222,9 @@ test('cli --force overwrites existing mergeable files for claude flow', () => {
assert.equal(result.status, 0, `CLI exited with non-zero status.\nSTDERR:\n${result.stderr}`);
assert.match(fs.readFileSync(settingsPath, 'utf8'), /claude-code-settings/);
assert.match(fs.readFileSync(settingsPath, 'utf8'), /mcp__playwright__/);
- assert.match(fs.readFileSync(agentsPath, 'utf8'), /# Claude Code — agent registry/);
+ assert.match(fs.readFileSync(agentsPath, 'utf8'), /# Manual AGENTS/);
+ assert.match(fs.readFileSync(agentsPath, 'utf8'), /Registered agents added by ca-ai-tools-setup/);
+ assert.match(fs.readFileSync(agentsPath, 'utf8'), /`code-style\.md`/);
});
test('cli exits non-zero for invalid assistants value', () => {
@@ -238,3 +242,113 @@ test('cli exits non-zero for invalid --mcp-figma value', () => {
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Invalid --mcp-figma value "maybe"/);
});
+
+test('cli --version prints the package version without starting setup', () => {
+ const { version } = JSON.parse(fs.readFileSync('package.json', 'utf8')) as { version: string };
+ const result = runCli(['--version']);
+
+ assert.equal(result.status, 0);
+ assert.equal(result.stdout, `${version}\n`);
+ assert.equal(result.stderr, '');
+});
+
+test('cli check returns zero for a synchronized tracked setup', () => {
+ const targetDir = makeTempDir();
+ const generated = runCli(['--target', targetDir, '--assistants', 'cursor', '--yes', '--mcp-playwright', 'no']);
+
+ assert.equal(generated.status, 0, generated.stderr);
+
+ const checked = runCli(['check', targetDir]);
+
+ assert.equal(checked.status, 0, checked.stderr);
+ assert.match(checked.stdout, /Setup check completed\./);
+ assert.match(checked.stdout, /Conflicts: 0/);
+});
+
+test('cli check returns two for a missing managed file without changing the repository', () => {
+ const targetDir = makeTempDir();
+
+ runCli(['--target', targetDir, '--assistants', 'cursor', '--yes', '--mcp-playwright', 'no']);
+
+ const missingPath = path.join(targetDir, '.cursor/rules/code-style.mdc');
+
+ fs.rmSync(missingPath);
+
+ const checked = runCli(['check', targetDir]);
+
+ assert.equal(checked.status, 2, checked.stderr);
+ assert.match(checked.stdout, /\.cursor\/rules\/code-style\.mdc \(create\)/);
+ assert.equal(fs.existsSync(missingPath), false);
+});
+
+test('cli update recreates a missing managed file and produces a PR summary', () => {
+ const targetDir = makeTempDir();
+
+ runCli(['--target', targetDir, '--assistants', 'cursor', '--yes', '--mcp-playwright', 'no']);
+
+ const missingPath = path.join(targetDir, '.cursor/rules/code-style.mdc');
+
+ fs.rmSync(missingPath);
+
+ const updated = runCli(['update', targetDir]);
+
+ assert.equal(updated.status, 0, updated.stderr);
+ assert.equal(fs.existsSync(missingPath), true);
+ assert.match(updated.stdout, /Setup update completed\./);
+ assert.match(updated.stdout, /PR summary:/);
+});
+
+test('cli update blocks all writes when a managed file conflicts', () => {
+ const targetDir = makeTempDir();
+
+ runCli(['--target', targetDir, '--assistants', 'cursor', '--yes', '--mcp-playwright', 'no']);
+
+ const conflictPath = path.join(targetDir, '.cursor/rules/code-style.mdc');
+ const missingPath = path.join(targetDir, '.cursor/rules/linear-cli.mdc');
+
+ fs.writeFileSync(conflictPath, 'Local managed edit.\n', 'utf8');
+ fs.rmSync(missingPath);
+
+ const updated = runCli(['update', targetDir]);
+
+ assert.equal(updated.status, 2, updated.stderr);
+ assert.match(updated.stdout, /Setup update blocked by conflicts\./);
+ assert.equal(fs.existsSync(missingPath), false);
+ assert.equal(fs.readFileSync(conflictPath, 'utf8'), 'Local managed edit.\n');
+});
+
+test('cli update --dry-run previews pending changes without writing and exits two', () => {
+ const targetDir = makeTempDir();
+
+ runCli(['--target', targetDir, '--assistants', 'cursor', '--yes', '--mcp-playwright', 'no']);
+
+ const missingPath = path.join(targetDir, '.cursor/rules/code-style.mdc');
+ const metadataPath = path.join(targetDir, '.assistant-setup/ca-ai-tools-setup.json');
+ const metadataBefore = fs.readFileSync(metadataPath, 'utf8');
+
+ fs.rmSync(missingPath);
+
+ const preview = runCli(['update', targetDir, '--dry-run']);
+
+ assert.equal(preview.status, 2, preview.stderr);
+ assert.match(preview.stdout, /Setup update preview completed\./);
+ assert.match(preview.stdout, /Planned changes:/);
+ assert.match(preview.stdout, /\.cursor\/rules\/code-style\.mdc \(create\)/);
+ assert.equal(fs.existsSync(missingPath), false);
+ assert.equal(fs.readFileSync(metadataPath, 'utf8'), metadataBefore);
+});
+
+test('cli update is deterministic when no generated content changed', () => {
+ const targetDir = makeTempDir();
+
+ runCli(['--target', targetDir, '--assistants', 'cursor', '--yes', '--mcp-playwright', 'no']);
+
+ const metadataPath = path.join(targetDir, '.assistant-setup/ca-ai-tools-setup.json');
+ const before = fs.readFileSync(metadataPath, 'utf8');
+ const updated = runCli(['update', targetDir]);
+ const after = fs.readFileSync(metadataPath, 'utf8');
+
+ assert.equal(updated.status, 0, updated.stderr);
+ assert.equal(after, before);
+ assert.doesNotMatch(after, /generatedAt/);
+});
diff --git a/tests/generator.test.ts b/tests/generator.test.ts
index b4df372..a9ef0f9 100644
--- a/tests/generator.test.ts
+++ b/tests/generator.test.ts
@@ -32,6 +32,7 @@ test('generateSetup creates files for selected assistants', () => {
assert.ok(result.created.includes('setup-cursor-assistant.md'));
assert.ok(fs.existsSync(path.join(dir, 'setup-cursor-assistant.md')));
+ assert.ok(fs.existsSync(path.join(dir, '.cursor/rules/assistant-setup-health.mdc')));
assert.ok(fs.existsSync(path.join(dir, '.cursor/rules/linear-cli.mdc')));
assert.ok(fs.existsSync(path.join(dir, '.cursor/rules/linear-task-gates.mdc')));
assert.ok(fs.existsSync(path.join(dir, '.cursor/rules/code-style.mdc')));
@@ -50,6 +51,11 @@ test('generateSetup creates files for selected assistants', () => {
assert.ok(fs.existsSync(path.join(dir, '.cursor/mcp.json')));
assert.ok(fs.existsSync(path.join(dir, '.dev-environment.md')));
assert.ok(fs.existsSync(path.join(dir, '.assistant-setup/page-workflow-context.md')));
+ assert.ok(fs.existsSync(path.join(dir, '.assistant-setup/SETUP_STATUS.md')));
+ assert.match(
+ fs.readFileSync(path.join(dir, '.assistant-setup/SETUP_STATUS.md'), 'utf8'),
+ /ca-ai-tools-setup-status.*"schemaVersion":6/,
+ );
assert.ok(result.created.includes('LINEAR_CLI.md'));
assert.ok(fs.existsSync(path.join(dir, 'LINEAR_CLI.md')));
assert.ok(fs.readFileSync(path.join(dir, 'LINEAR_CLI.md'), 'utf8').includes('Linear CLI Reference'));
@@ -82,7 +88,7 @@ test('generateSetup skips .cursorrules on second Cursor run unless force', () =>
assert.ok(second.skipped.includes('.cursorrules'));
assert.ok(second.skipped.includes('.cursor/skills/testing-with-linear/SKILL.md'));
- assert.ok(second.skipped.includes('AGENTS.md'));
+ assert.ok(second.merged.includes('AGENTS.md'));
const forced = generateSetup({
targetDir: dir,
@@ -94,7 +100,7 @@ test('generateSetup skips .cursorrules on second Cursor run unless force', () =>
assert.ok(forced.overwritten.includes('.cursorrules'));
assert.ok(forced.overwritten.includes('.cursor/skills/testing-with-linear/SKILL.md'));
- assert.ok(forced.overwritten.includes('AGENTS.md'));
+ assert.ok(forced.merged.includes('AGENTS.md'));
});
test('generateSetup omits .cursor/mcp.json when Playwright MCP is declined', () => {
@@ -151,7 +157,11 @@ test('generateSetup writes .mcp.json for Claude when Playwright MCP enabled', ()
const meta = JSON.parse(fs.readFileSync(path.join(dir, '.assistant-setup/ca-ai-tools-setup.json'), 'utf8'));
assert.deepEqual(meta.playwrightMcp, { cursorFile: false, projectRootFile: true });
- assert.equal(meta.version, 5);
+ assert.equal(meta.version, 6);
+ assert.equal(meta.provenance.package, '@metricinsights/ca-ai-tools-setup');
+ assert.equal(meta.provenance.version, '0.1.0');
+ assert.ok(meta.files['.claude/skills/ai-development/SKILL.md']);
+ assert.equal(meta.generatedAt, undefined);
assert.deepEqual(meta.qaAiRules, { enabled: false, package: '@metricinsights/qa-ai-rules' });
assert.deepEqual(meta.devEnvironment, {
file: '.dev-environment.md',
@@ -253,9 +263,9 @@ test('generateSetup always overwrites setup assistant files', () => {
assert.ok(second.overwritten.includes('setup-claude-assistant.md'));
assert.ok(second.skipped.includes('CLAUDE.md'));
assert.ok(second.skipped.includes('.claude/settings.json'));
- assert.ok(second.skipped.includes('AGENTS.md'));
+ assert.ok(second.merged.includes('AGENTS.md'));
assert.ok(second.skipped.includes('.dev-environment.md'));
- assert.ok(second.skipped.includes('.assistant-setup/ca-ai-tools-setup.json'));
+ assert.ok(second.overwritten.includes('.assistant-setup/ca-ai-tools-setup.json'));
assert.ok(second.skipped.includes('.assistant-setup/page-workflow-context.md'));
assert.ok(second.skipped.includes('LINEAR_CLI.md'));
assert.ok(second.skipped.includes('.claude/skills/ai-development/SKILL.md'));
@@ -271,10 +281,35 @@ test('generateSetup always overwrites setup assistant files', () => {
assert.ok(forced.overwritten.includes('setup-claude-assistant.md'));
assert.ok(forced.overwritten.includes('CLAUDE.md'));
assert.ok(forced.overwritten.includes('.claude/settings.json'));
- assert.ok(forced.overwritten.includes('AGENTS.md'));
+ assert.ok(forced.merged.includes('AGENTS.md'));
assert.ok(forced.overwritten.includes('.claude/skills/ai-development/SKILL.md'));
});
+test('generateSetup preserves existing AGENTS.md content and merges generated rows with --force', () => {
+ const dir = makeTempDir();
+ const agentsPath = path.join(dir, 'AGENTS.md');
+
+ fs.writeFileSync(
+ agentsPath,
+ '# Repository instructions\n\nKeep this custom guidance.\n\n| File | Purpose |\n| ---- | ------- |\n',
+ 'utf8',
+ );
+
+ const result = generateSetup({
+ targetDir: dir,
+ assistants: ['claude'],
+ force: true,
+ dryRun: false,
+ playwrightMcpInclude: false,
+ });
+ const merged = fs.readFileSync(agentsPath, 'utf8');
+
+ assert.ok(result.merged.includes('AGENTS.md'));
+ assert.match(merged, /Keep this custom guidance\./);
+ assert.match(merged, /`code-style\.md`/);
+ assert.doesNotMatch(merged, /^# Claude Code — agent registry/m);
+});
+
test('generateSetup writes both MCP files when Cursor and Claude selected and MCP enabled', () => {
const dir = makeTempDir();
@@ -337,10 +372,7 @@ test('generateSetup writes figma MCP only when requested', () => {
assert.equal(claudeMcp.mcpServers.playwright, undefined);
assert.equal(fs.existsSync(path.join(dir, '.claude/agents/figma-mcp.md')), true);
assert.equal(fs.existsSync(path.join(dir, '.claude/skills/figma-code-connect/SKILL.md')), true);
- assert.equal(
- fs.existsSync(path.join(dir, '.claude/skills/figma-code-connect/references/api.md')),
- true,
- );
+ assert.equal(fs.existsSync(path.join(dir, '.claude/skills/figma-code-connect/references/api.md')), true);
assert.equal(fs.existsSync(path.join(dir, '.cursor/rules/figma-mcp.mdc')), true);
assert.equal(fs.existsSync(path.join(dir, '.cursor/skills/figma-code-connect/SKILL.md')), true);
assert.deepEqual(meta.playwrightMcp, { cursorFile: false, projectRootFile: false });
diff --git a/tests/mcp-json-merge.test.ts b/tests/mcp-json-merge.test.ts
index 26825df..757e396 100644
--- a/tests/mcp-json-merge.test.ts
+++ b/tests/mcp-json-merge.test.ts
@@ -258,6 +258,8 @@ test('mergeAgentsMd does not duplicate existing rows', () => {
const matches = result.match(/`figma-mcp\.md`/g);
assert.equal(matches?.length, 1);
+ assert.match(result, /Figma MCP\./);
+ assert.doesNotMatch(result, /updated description/);
});
test('mergeAgentsMd returns existing unchanged when no new rows', () => {
@@ -266,6 +268,47 @@ test('mergeAgentsMd returns existing unchanged when no new rows', () => {
assert.equal(mergeAgentsMd(content, content), content);
});
+test('mergeAgentsMd preserves custom content and appends a complete registry section when no table exists', () => {
+ const existing = '# Repository instructions\n\nKeep this guidance.\n';
+ const incoming = [
+ '# Generated template',
+ '',
+ '| File | Purpose |',
+ '| ---- | ------- |',
+ '| `code-style.md` | Generated code style agent. |',
+ '',
+ ].join('\n');
+ const result = mergeAgentsMd(existing, incoming);
+
+ assert.match(result, /^# Repository instructions/m);
+ assert.match(result, /Keep this guidance\./);
+ assert.match(result, /## Registered agents added by ca-ai-tools-setup/);
+ assert.match(result, /`code-style\.md`/);
+ assert.doesNotMatch(result, /# Generated template/);
+});
+
+test('mergeAgentsMd inserts rows into an existing table that has no data rows', () => {
+ const existing = ['# Repository instructions', '', '| File | Purpose |', '| ---- | ------- |', ''].join('\n');
+ const incoming = [
+ '| File | Purpose |',
+ '| ---- | ------- |',
+ '| `code-style.md` | Generated code style agent. |',
+ '',
+ ].join('\n');
+ const result = mergeAgentsMd(existing, incoming);
+
+ assert.match(result, /^# Repository instructions/m);
+ assert.match(result, /\| File \| Purpose \|/);
+ assert.match(result, /`code-style\.md`/);
+ assert.doesNotMatch(result, /## Registered agents added by ca-ai-tools-setup/);
+});
+
+test('mergeAgentsMd uses the generated template when the existing file is empty', () => {
+ const incoming = '# Generated AGENTS\n\n| `code-style.md` | Code style. |\n';
+
+ assert.equal(mergeAgentsMd('', incoming), incoming);
+});
+
// mergeFile dispatch
test('mergeFile dispatches to mergeMcpJson for MCP paths', () => {
diff --git a/tests/reconcile.test.ts b/tests/reconcile.test.ts
new file mode 100644
index 0000000..e3ac019
--- /dev/null
+++ b/tests/reconcile.test.ts
@@ -0,0 +1,339 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { generateSetup, getGeneratedFiles } from '../src/generator.js';
+import { checkSetup, updateSetup, type ReconcileOptions } from '../src/reconcile.js';
+import { loadSetupMetadata, SETUP_METADATA_PATH } from '../src/setup-metadata.js';
+
+function makeTempDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ca-ai-tools-reconcile-'));
+}
+
+function createCursorSetup(targetDir: string): void {
+ generateSetup({
+ targetDir,
+ assistants: ['cursor'],
+ force: false,
+ dryRun: false,
+ playwrightMcpInclude: true,
+ });
+}
+
+function options(targetDir: string, overrides: Partial = {}): ReconcileOptions {
+ return {
+ targetDir,
+ assistants: ['cursor'],
+ playwrightMcpInclude: true,
+ figmaMcpInclude: false,
+ qaAiRulesInclude: false,
+ files: getGeneratedFiles(['cursor'], true, false, false),
+ metadata: loadSetupMetadata(targetDir),
+ force: false,
+ dryRun: false,
+ ...overrides,
+ };
+}
+
+test('checkSetup reports a clean generated setup', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const result = checkSetup(options(dir));
+
+ assert.equal(result.plan.hasChanges, false);
+ assert.equal(result.conflicts.length, 0);
+ assert.ok(result.unchanged.includes('.cursor/rules/code-style.mdc'));
+});
+
+test('updateSetup replaces an unchanged managed file when its source changes', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const files = getGeneratedFiles(['cursor'], true, false, false).map((file) =>
+ file.path === '.cursor/rules/code-style.mdc'
+ ? { ...file, content: `${file.content}\nUpdated managed rule.\n` }
+ : file,
+ );
+ const result = updateSetup(options(dir, { files }));
+
+ assert.equal(result.applied, true);
+ assert.ok(result.updated.includes('.cursor/rules/code-style.mdc'));
+ assert.match(fs.readFileSync(path.join(dir, '.cursor/rules/code-style.mdc'), 'utf8'), /Updated managed rule\./);
+});
+
+test('updateSetup blocks every write when a managed file has local changes', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const conflictingPath = path.join(dir, '.cursor/rules/code-style.mdc');
+ const otherPath = path.join(dir, 'LINEAR_CLI.md');
+ const originalOther = fs.readFileSync(otherPath, 'utf8');
+
+ fs.writeFileSync(conflictingPath, 'Repository-owned edit in a managed file.\n', 'utf8');
+
+ const files = getGeneratedFiles(['cursor'], true, false, false).map((file) =>
+ file.path === 'LINEAR_CLI.md' ? { ...file, content: `${file.content}\nNew release content.\n` } : file,
+ );
+ const result = updateSetup(options(dir, { files }));
+
+ assert.equal(result.applied, false);
+ assert.ok(result.conflicts.includes('.cursor/rules/code-style.mdc'));
+ assert.equal(fs.readFileSync(otherPath, 'utf8'), originalOther);
+});
+
+test('updateSetup requires force for managed content adopted during initial setup', () => {
+ const dir = makeTempDir();
+ const managedPath = path.join(dir, '.cursor/rules/code-style.mdc');
+
+ fs.mkdirSync(path.dirname(managedPath), { recursive: true });
+ fs.writeFileSync(managedPath, 'Pre-existing managed content.\n', 'utf8');
+ createCursorSetup(dir);
+
+ const metadata = loadSetupMetadata(dir);
+ const result = updateSetup(options(dir));
+
+ assert.equal(metadata.kind, 'current');
+
+ if (metadata.kind === 'current') {
+ assert.equal(metadata.metadata.files['.cursor/rules/code-style.mdc'].baseline, 'adopted');
+ }
+
+ assert.ok(result.conflicts.includes('.cursor/rules/code-style.mdc'));
+ assert.equal(fs.readFileSync(managedPath, 'utf8'), 'Pre-existing managed content.\n');
+});
+
+test('updateSetup force replaces a modified managed baseline', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const managedPath = path.join(dir, '.cursor/rules/code-style.mdc');
+
+ fs.writeFileSync(managedPath, 'Local edit.\n', 'utf8');
+
+ const result = updateSetup(options(dir, { force: true }));
+
+ assert.equal(result.applied, true);
+ assert.ok(result.updated.includes('.cursor/rules/code-style.mdc'));
+ assert.notEqual(fs.readFileSync(managedPath, 'utf8'), 'Local edit.\n');
+});
+
+test('updateSetup preserves and adopts repository-owned protected files', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const protectedPath = path.join(dir, '.cursorrules');
+
+ fs.writeFileSync(protectedPath, 'Custom repository rules.\n', 'utf8');
+
+ const result = updateSetup(options(dir));
+ const metadata = loadSetupMetadata(dir);
+
+ assert.equal(result.conflicts.length, 0);
+ assert.ok(result.preserved.includes('.cursorrules'));
+ assert.equal(fs.readFileSync(protectedPath, 'utf8'), 'Custom repository rules.\n');
+ assert.equal(metadata.kind, 'current');
+
+ if (metadata.kind === 'current') {
+ assert.equal(metadata.metadata.files['.cursorrules'].baseline, 'adopted');
+ }
+});
+
+test('updateSetup semantically merges an unchanged structured file', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const files = getGeneratedFiles(['cursor'], true, false, false).map((file) => {
+ if (file.path !== '.cursor/mcp.json') {
+ return file;
+ }
+
+ const doc = JSON.parse(file.content) as { mcpServers: Record };
+
+ doc.mcpServers.releaseServer = { command: 'node', args: ['release-server.js'] };
+
+ return { ...file, content: `${JSON.stringify(doc, null, 2)}\n` };
+ });
+ const result = updateSetup(options(dir, { files }));
+ const merged = JSON.parse(fs.readFileSync(path.join(dir, '.cursor/mcp.json'), 'utf8')) as {
+ mcpServers: Record;
+ };
+
+ assert.ok(result.merged.includes('.cursor/mcp.json'));
+ assert.ok(merged.mcpServers.playwright);
+ assert.ok(merged.mcpServers.releaseServer);
+ assert.equal(checkSetup(options(dir, { files })).plan.hasChanges, false);
+});
+
+test('updateSetup safely merges modified AGENTS.md even with force', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+
+ const agentsPath = path.join(dir, 'AGENTS.md');
+
+ fs.writeFileSync(agentsPath, '# Repository AGENTS\n\nKeep custom instructions.\n', 'utf8');
+
+ const checked = checkSetup(options(dir, { force: true }));
+ const updated = updateSetup(options(dir, { force: true }));
+ const merged = fs.readFileSync(agentsPath, 'utf8');
+ const metadata = loadSetupMetadata(dir);
+
+ assert.equal(checked.conflicts.length, 0);
+ assert.ok(checked.merged.includes('AGENTS.md'));
+ assert.ok(updated.merged.includes('AGENTS.md'));
+ assert.match(merged, /Keep custom instructions\./);
+ assert.match(merged, /Registered agents added by ca-ai-tools-setup/);
+ assert.match(merged, /`code-style\.md`/);
+
+ assert.equal(metadata.kind, 'current');
+
+ if (metadata.kind === 'current') {
+ assert.equal(metadata.metadata.files['AGENTS.md'].baseline, 'merged');
+ }
+});
+
+test('updateSetup migrates matching schema 5 metadata without rewriting tracked files', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const metadataPath = path.join(dir, SETUP_METADATA_PATH);
+
+ fs.writeFileSync(
+ metadataPath,
+ `${JSON.stringify({
+ version: 5,
+ assistants: ['cursor'],
+ playwrightMcp: { cursorFile: true, projectRootFile: false },
+ figmaMcp: { cursorFile: false, projectRootFile: false },
+ qaAiRules: { enabled: false },
+ })}\n`,
+ 'utf8',
+ );
+
+ const before = fs.readFileSync(path.join(dir, '.cursor/rules/code-style.mdc'), 'utf8');
+ const check = checkSetup(options(dir));
+ const updated = updateSetup(options(dir));
+ const metadata = loadSetupMetadata(dir);
+
+ assert.equal(check.metadataMigrationRequired, true);
+ assert.equal(check.plan.hasChanges, true);
+ assert.equal(updated.conflicts.length, 0);
+ assert.equal(fs.readFileSync(path.join(dir, '.cursor/rules/code-style.mdc'), 'utf8'), before);
+ assert.equal(metadata.kind, 'current');
+});
+
+test('schema 5 migration preserves unhashed legacy files unless force explicitly removes them', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+
+ const metadataPath = path.join(dir, SETUP_METADATA_PATH);
+ const legacyPath = path.join(dir, '.cursor/skills/ui-check/SKILL.md');
+
+ fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
+ fs.writeFileSync(legacyPath, 'Legacy generated content.\n', 'utf8');
+ fs.writeFileSync(
+ metadataPath,
+ '{"version":5,"assistants":["cursor"],"playwrightMcp":{"cursorFile":true,"projectRootFile":false}}\n',
+ 'utf8',
+ );
+
+ const blocked = updateSetup(options(dir));
+
+ assert.ok(blocked.conflicts.includes('.cursor/skills/ui-check/SKILL.md'));
+ assert.equal(fs.existsSync(legacyPath), true);
+
+ const forced = updateSetup(options(dir, { force: true }));
+
+ assert.ok(forced.removed.includes('.cursor/skills/ui-check/SKILL.md'));
+ assert.equal(fs.existsSync(legacyPath), false);
+});
+
+test('updateSetup recreates missing files and removes unchanged managed orphans', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const missingPath = '.cursor/rules/linear-cli.mdc';
+ const orphanPath = '.cursor/rules/code-style.mdc';
+
+ fs.rmSync(path.join(dir, missingPath));
+
+ const files = getGeneratedFiles(['cursor'], true, false, false).filter((file) => file.path !== orphanPath);
+ const result = updateSetup(options(dir, { files }));
+
+ assert.ok(result.created.includes(missingPath));
+ assert.ok(result.removed.includes(orphanPath));
+ assert.equal(fs.existsSync(path.join(dir, missingPath)), true);
+ assert.equal(fs.existsSync(path.join(dir, orphanPath)), false);
+});
+
+test('updateSetup dry-run reports changes without writing files or metadata', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+ const missingPath = '.cursor/rules/linear-cli.mdc';
+ const metadataPath = path.join(dir, SETUP_METADATA_PATH);
+ const metadataBefore = fs.readFileSync(metadataPath, 'utf8');
+
+ fs.rmSync(path.join(dir, missingPath));
+
+ const result = updateSetup(options(dir, { dryRun: true }));
+
+ assert.equal(result.applied, false);
+ assert.ok(result.created.includes(missingPath));
+ assert.equal(fs.existsSync(path.join(dir, missingPath)), false);
+ assert.equal(fs.readFileSync(metadataPath, 'utf8'), metadataBefore);
+});
+
+test('updateSetup force preserves protected or modified orphans while applying safe changes', () => {
+ const dir = makeTempDir();
+
+ createCursorSetup(dir);
+
+ const structuredPath = path.join(dir, '.cursor/mcp.json');
+ const missingPath = path.join(dir, '.cursor/rules/linear-cli.mdc');
+
+ fs.writeFileSync(structuredPath, '{"mcpServers":{"custom":{"command":"node"}}}\n', 'utf8');
+ fs.rmSync(missingPath);
+
+ const filesWithoutMcp = getGeneratedFiles(['cursor'], false, false, false);
+ const result = updateSetup(
+ options(dir, {
+ files: filesWithoutMcp,
+ playwrightMcpInclude: false,
+ force: true,
+ }),
+ );
+
+ assert.equal(result.applied, true);
+ assert.ok(result.orphaned.includes('.cursor/mcp.json'));
+ assert.equal(fs.existsSync(structuredPath), true);
+ assert.equal(fs.existsSync(missingPath), true);
+
+ const metadata = JSON.parse(fs.readFileSync(path.join(dir, SETUP_METADATA_PATH), 'utf8')) as {
+ files: Record;
+ };
+
+ assert.ok(metadata.files['.cursor/mcp.json']);
+
+ const rechecked = checkSetup(
+ options(dir, {
+ files: filesWithoutMcp,
+ playwrightMcpInclude: false,
+ }),
+ );
+
+ assert.ok(rechecked.orphaned.includes('.cursor/mcp.json'));
+});
+
+test('checkSetup rejects malformed metadata', () => {
+ const dir = makeTempDir();
+ const metadataPath = path.join(dir, SETUP_METADATA_PATH);
+
+ fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
+ fs.writeFileSync(metadataPath, '{"version":6}\n', 'utf8');
+
+ assert.throws(() => checkSetup(options(dir)), /Invalid setup metadata/);
+});
diff --git a/tests/setup-metadata.test.ts b/tests/setup-metadata.test.ts
new file mode 100644
index 0000000..32b81dd
--- /dev/null
+++ b/tests/setup-metadata.test.ts
@@ -0,0 +1,188 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import {
+ createSetupMetadata,
+ createSetupStatusContent,
+ hashContent,
+ loadSetupMetadata,
+ ownershipForPath,
+ serializeSetupMetadata,
+ SETUP_METADATA_PATH,
+} from '../src/setup-metadata.js';
+
+function makeTempDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ca-ai-tools-metadata-'));
+}
+
+test('hashContent returns a stable SHA-256 digest with portable line endings', () => {
+ assert.equal(hashContent('hello\n'), 'sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03');
+ assert.equal(hashContent('hello\n'), hashContent('hello\r\n'));
+});
+
+test('ownershipForPath separates managed, protected, and structured files', () => {
+ assert.equal(ownershipForPath('.cursor/rules/code-style.mdc'), 'managed');
+ assert.equal(ownershipForPath('.cursorrules'), 'protected');
+ assert.equal(ownershipForPath('.dev-environment.md'), 'protected');
+ assert.equal(ownershipForPath('.cursor/mcp.json'), 'structured');
+ assert.equal(ownershipForPath('AGENTS.md'), 'structured');
+});
+
+test('createSetupMetadata is deterministic and sorts file records', () => {
+ const provenance = {
+ package: '@metricinsights/ca-ai-tools-setup',
+ version: '1.2.3',
+ releaseCommit: 'abc123',
+ templateRevision: 'abc123',
+ };
+ const metadata = createSetupMetadata(
+ {
+ assistants: ['cursor'],
+ playwrightMcp: { cursorFile: true, projectRootFile: false },
+ figmaMcp: { cursorFile: false, projectRootFile: false },
+ qaAiRulesEnabled: false,
+ },
+ [
+ { path: 'z.md', content: 'z\n' },
+ { path: 'a.md', content: 'a\n' },
+ { path: SETUP_METADATA_PATH, content: 'ignored\n' },
+ ],
+ new Map([['z.md', 'custom z\n']]),
+ provenance,
+ );
+ const serialized = serializeSetupMetadata(metadata);
+ const parsed = JSON.parse(serialized) as Record;
+
+ assert.deepEqual(Object.keys(metadata.files), ['a.md', 'z.md']);
+ assert.equal(metadata.files['a.md'].baseline, 'generated');
+ assert.equal(metadata.files['z.md'].baseline, 'adopted');
+ assert.deepEqual(metadata.provenance, provenance);
+ assert.equal(parsed.generatedAt, undefined);
+ assert.equal(serialized, serializeSetupMetadata(metadata));
+});
+
+test('createSetupStatusContent exposes an agent-readable deterministic marker', () => {
+ const content = createSetupStatusContent(
+ {
+ assistants: ['cursor', 'claude'],
+ playwrightMcp: { cursorFile: true, projectRootFile: true },
+ figmaMcp: { cursorFile: false, projectRootFile: false },
+ qaAiRulesEnabled: false,
+ },
+ {
+ package: '@metricinsights/ca-ai-tools-setup',
+ version: '1.2.3',
+ releaseCommit: 'abc123',
+ templateRevision: 'abc123',
+ },
+ );
+
+ assert.match(content, /ca-ai-tools-setup-status/);
+ assert.match(content, /"packageVersion":"1\.2\.3"/);
+ assert.match(content, /"assistants":\["cursor","claude"\]/);
+ assert.match(content, /releases\/latest\/download\/ca-ai-tools-setup\.tgz/);
+ assert.match(content, /Exit code `2`/);
+ assert.match(content, /Never run `update` or `--force` without explicit developer approval/);
+ assert.equal(
+ content,
+ createSetupStatusContent(
+ {
+ assistants: ['cursor', 'claude'],
+ playwrightMcp: { cursorFile: true, projectRootFile: true },
+ figmaMcp: { cursorFile: false, projectRootFile: false },
+ qaAiRulesEnabled: false,
+ },
+ {
+ package: '@metricinsights/ca-ai-tools-setup',
+ version: '1.2.3',
+ releaseCommit: 'abc123',
+ templateRevision: 'abc123',
+ },
+ ),
+ );
+});
+
+test('loadSetupMetadata distinguishes schema 5, schema 6, and malformed metadata', () => {
+ const dir = makeTempDir();
+ const metadataPath = path.join(dir, SETUP_METADATA_PATH);
+
+ fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
+ fs.writeFileSync(metadataPath, '{"version":5,"assistants":["cursor"]}\n', 'utf8');
+ assert.equal(loadSetupMetadata(dir).kind, 'legacy');
+
+ fs.writeFileSync(metadataPath, '{"version":6}\n', 'utf8');
+ assert.equal(loadSetupMetadata(dir).kind, 'invalid');
+
+ fs.writeFileSync(metadataPath, '{not-json', 'utf8');
+ assert.equal(loadSetupMetadata(dir).kind, 'invalid');
+});
+
+test('loadSetupMetadata rejects unsafe file-record paths and ignores claimed ownership', () => {
+ const dir = makeTempDir();
+ const metadataPath = path.join(dir, SETUP_METADATA_PATH);
+ const baseMetadata = createSetupMetadata(
+ {
+ assistants: ['cursor'],
+ playwrightMcp: { cursorFile: false, projectRootFile: false },
+ figmaMcp: { cursorFile: false, projectRootFile: false },
+ qaAiRulesEnabled: false,
+ },
+ [{ path: 'AGENTS.md', content: '# Agents\n' }],
+ );
+
+ fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
+
+ const claimedManaged = structuredClone(baseMetadata);
+
+ claimedManaged.files['AGENTS.md'].ownership = 'managed';
+ fs.writeFileSync(metadataPath, serializeSetupMetadata(claimedManaged), 'utf8');
+
+ const loaded = loadSetupMetadata(dir);
+
+ assert.equal(loaded.kind, 'current');
+
+ if (loaded.kind === 'current') {
+ assert.equal(loaded.metadata.files['AGENTS.md'].ownership, 'structured');
+ }
+
+ const unsafe = structuredClone(baseMetadata);
+
+ unsafe.files['../outside.md'] = unsafe.files['AGENTS.md'];
+ fs.writeFileSync(metadataPath, serializeSetupMetadata(unsafe), 'utf8');
+
+ assert.equal(loadSetupMetadata(dir).kind, 'invalid');
+});
+
+test('loadSetupMetadata rejects unknown or duplicate assistant values', () => {
+ const dir = makeTempDir();
+ const metadataPath = path.join(dir, SETUP_METADATA_PATH);
+ const baseMetadata = createSetupMetadata(
+ {
+ assistants: ['cursor'],
+ playwrightMcp: { cursorFile: false, projectRootFile: false },
+ figmaMcp: { cursorFile: false, projectRootFile: false },
+ qaAiRulesEnabled: false,
+ },
+ [{ path: 'AGENTS.md', content: '# Agents\n' }],
+ );
+
+ fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
+
+ const unknownAssistant = {
+ ...baseMetadata,
+ assistants: ['cursor', 'invalid'],
+ };
+
+ fs.writeFileSync(metadataPath, `${JSON.stringify(unknownAssistant, null, 2)}\n`, 'utf8');
+ assert.equal(loadSetupMetadata(dir).kind, 'invalid');
+
+ const duplicateAssistants = {
+ ...baseMetadata,
+ assistants: ['cursor', 'cursor'],
+ };
+
+ fs.writeFileSync(metadataPath, `${JSON.stringify(duplicateAssistants, null, 2)}\n`, 'utf8');
+ assert.equal(loadSetupMetadata(dir).kind, 'invalid');
+});
diff --git a/tests/templates-contract.test.ts b/tests/templates-contract.test.ts
index 8c82d8b..2afec75 100644
--- a/tests/templates-contract.test.ts
+++ b/tests/templates-contract.test.ts
@@ -10,6 +10,33 @@ test('setup assistant templates keep MCP replacement marker', () => {
assert.match(claudeSetup, /\*\*PLAYWRIGHT_MCP_BLOCK\*\*/);
});
+test('setup health rule detects missing and stale tracked configuration', () => {
+ const healthRule = readTemplate('cursor/rules/assistant-setup-health.mdc');
+ const claudeInstructions = readTemplate('claude/CLAUDE.md');
+ const agents = readTemplate('AGENTS.md');
+
+ assert.match(healthRule, /alwaysApply: true/);
+ assert.match(healthRule, /\.assistant-setup\/SETUP_STATUS\.md/);
+ assert.match(healthRule, /Exit code \*\*`2`\*\*/);
+ assert.match(healthRule, /Never run `update` or `--force` without explicit developer approval/);
+ assert.match(claudeInstructions, /\.assistant-setup\/SETUP_STATUS\.md/);
+ assert.match(agents, /\.assistant-setup\/SETUP_STATUS\.md/);
+});
+
+test('developer environment guidance is tracked and keeps credentials local', () => {
+ const devEnvironment = readTemplate('assistant-setup/dev-environment.md');
+ const cursorSetup = readTemplate('setup-cursor-assistant.md');
+ const claudeSetup = readTemplate('setup-claude-assistant.md');
+
+ assert.match(devEnvironment, /Commit this file as shared repository guidance/);
+ assert.doesNotMatch(devEnvironment, /Keep it out of git/);
+
+ for (const setup of [cursorSetup, claudeSetup]) {
+ assert.match(setup, /Commit \*\*`.dev-environment.md`\*\*/);
+ assert.match(setup, /\.mi-credentials\.local\.env/);
+ }
+});
+
test('playwright-cli skill and workflow ship the CLI browser-automation alternative', () => {
const skill = readTemplate('skills/playwright-cli/SKILL.md');
const workflow = readTemplate('claude/workflows/playwright-cli.md');
@@ -58,6 +85,7 @@ test('AGENTS template lists core Claude agents', () => {
assert.match(agents, /`qa-tester\.md`/);
assert.match(agents, /`ui-verifier\.md`/);
assert.match(agents, /`linear-reporter\.md`/);
+ assert.match(agents, /content is preserved, including with \*\*`--force`\*\*/);
});
test('rules README documents deprecated ai-testing and ui-check stubs', () => {