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', () => {