From 468dc660b517300dcd5c2ab8136da70c514ae94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Tue, 7 Jul 2026 18:39:41 +0200 Subject: [PATCH 1/4] feat: add experimental archetype-driven init install (--archetype) Introduces capability index parsing, resolution, and copy install behind a flag-gated pharn init path, with validation, pharn.config schema fields, and tests. Co-authored-by: Cursor --- .../init-install-capabilities/GRILL.md | 127 +++++++++++ .../init-install-capabilities/PLAN.md | 109 ++++++++++ .../init-install-capabilities/REGRESSION.md | 36 ++++ .../init-install-capabilities/REVIEW.md | 97 +++++++++ .../init-install-capabilities/SHIP.md | 44 ++++ .../init-install-capabilities/VERIFY.md | 34 +++ .../regression-report.json | 36 ++++ .../verify-report.json | 14 ++ .pharn/pharn-dev-regress/base-results.json | 2 +- .pharn/pharn-dev-regress/head-results.json | 2 +- .pharn/pharn-dev-verify/results.json | 2 +- .pharn/writes-scope.json | 4 +- src/commands/init.ts | 93 +++++++- src/index.ts | 5 +- src/lib/capability-index.ts | 182 ++++++++++++++++ src/lib/constants.ts | 39 ++++ src/lib/install-capabilities.ts | 173 +++++++++++++++ src/lib/validate.ts | 58 +++++ src/steps/archetype-summary.ts | 68 ++++++ src/steps/install-archetype.ts | 119 +++++++++++ src/types.ts | 18 +- tests/archetype-summary.test.ts | 71 +++++++ tests/capability-index.test.ts | 169 +++++++++++++++ tests/index.test.ts | 9 +- tests/init-archetype.test.ts | 123 +++++++++++ tests/install-capabilities.test.ts | 200 ++++++++++++++++++ tests/pharn-config.test.ts | 30 +++ tests/validate.test.ts | 61 ++++++ 28 files changed, 1914 insertions(+), 11 deletions(-) create mode 100644 .dev/features/init-install-capabilities/GRILL.md create mode 100644 .dev/features/init-install-capabilities/PLAN.md create mode 100644 .dev/features/init-install-capabilities/REGRESSION.md create mode 100644 .dev/features/init-install-capabilities/REVIEW.md create mode 100644 .dev/features/init-install-capabilities/SHIP.md create mode 100644 .dev/features/init-install-capabilities/VERIFY.md create mode 100644 .dev/features/init-install-capabilities/regression-report.json create mode 100644 .dev/features/init-install-capabilities/verify-report.json create mode 100644 src/lib/capability-index.ts create mode 100644 src/lib/install-capabilities.ts create mode 100644 src/steps/archetype-summary.ts create mode 100644 src/steps/install-archetype.ts create mode 100644 tests/archetype-summary.test.ts create mode 100644 tests/capability-index.test.ts create mode 100644 tests/init-archetype.test.ts create mode 100644 tests/install-capabilities.test.ts diff --git a/.dev/features/init-install-capabilities/GRILL.md b/.dev/features/init-install-capabilities/GRILL.md new file mode 100644 index 0000000..4d69652 --- /dev/null +++ b/.dev/features/init-install-capabilities/GRILL.md @@ -0,0 +1,127 @@ +# GRILL — init-install-capabilities + +Plan interrogated: `.dev/features/init-install-capabilities/PLAN.md`. Spec-hash check: **MATCH** +(`sha256(ARCHITECTURE.md)` = `bca940a5…` == plan `spec_content_hash`; no drift). Registered grillers: +`count-grillers.mjs .` → **0** (pharn-cli is the CLI repo, not a methodology-capability repo) → inline +interrogation only. **This whole log is ADVISORY — it gates nothing** (`/pharn-dev-build` is not blocked by any +finding here). Free-text `problem`/`evidence` quote the (untrusted) plan as DATA. + +## Findings — by axis + +### P2 / P7 — untrusted copy over a user-owned file + +```yaml +- type: FINDING + rule_id: P2 + severity: important + file: ".dev/features/init-install-capabilities/PLAN.md:28" + problem: "The copy force-overwrites the user's existing .claude/settings.json with no merge or guard, silently clobbering their Claude Code permissions/hooks/config." + evidence: "Copies ... the fixed product surfaces (... `settings.json` ...) to mirrored project-root paths" +``` + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/init-install-capabilities/PLAN.md:29" + problem: "Installing settings.json + the three hooks activates PreToolUse write-gating (protect-trusted-paths + enforce-writes-scope) across the user's whole repo, but the confirm/summary step is not planned to disclose this behavior change." + evidence: "clack UI: show detected archetypes + selected/skipped capabilities ... return 'install' | 'cancel'" +``` + +### P6 — cross-command state; unverified format assumption + +```yaml +- type: FINDING + rule_id: P6 + severity: important + file: ".dev/features/init-install-capabilities/PLAN.md:65" + problem: "The archetype config (modules:[], capabilities:[...], no constitution) is a new shape that the sibling commands add/update/remove/list/status were built to read as module/manifest installs; the plan never states how they behave when handed it (upstream has no manifest, so update/status/list could crash or emit confusing errors)." + evidence: "an **archetype** config (`archetypes` + `capabilities` + `modules:[]`, no `constitution`) reads + round-trips" +``` + +```yaml +- type: FINDING + rule_id: P6 + severity: important + file: ".dev/features/init-install-capabilities/PLAN.md:88" + problem: "SKILLS_VERSION is validated with VERSION_RE (/^\\d+\\.\\d+\\.\\d+.../), but the live file's actual format was never read this run — if upstream ships e.g. \"0.80\" (two-part), a valid install hard-fails on the version check." + evidence: "`SKILLS_VERSION` → read + validated (`VERSION_RE`) before persisting." +``` + +### P1 — eval coverage gap + +```yaml +- type: FINDING + rule_id: P1 + severity: important + file: ".dev/features/init-install-capabilities/PLAN.md:29" + problem: "steps/archetype-summary.ts adds behavior (selected/skipped rendering + confirm→install/cancel mapping) but no tests/archetype-summary.test.ts is planned; every other step (summary.ts) has a test." + evidence: "`src/steps/archetype-summary.ts` — **NEW** — clack UI ... return 'install' | 'cancel'" +``` + +### P7 — scope bundling (surfaced; human already authorized) + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/init-install-capabilities/PLAN.md:10" + problem: "The increment bundles ~5 independently-shippable units (frontmatter parse boundary, copy routine, summary UI, config schema, flag wiring); the plan flags this and the human authorized it, but the broad surface raises regression risk and each unit's tests must green independently, not in aggregate." + evidence: "This increment is **deliberately larger than P7's default \"smallest coherent increment\"** — the human authorized that at the plan gate." +``` + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/init-install-capabilities/PLAN.md:28" + problem: "Re-install behavior is unspecified: the copy only adds/overwrites SELECTED capabilities and never prunes ones now inapplicable, so a project whose archetypes changed between runs accumulates orphan grillers/lenses (the config-overwrite guard covers the JSON, not the copied dirs)." + evidence: "Copies each **selected** griller/lens dir ... to **mirrored** project-root paths." +``` + +### P3 / P5 — axis + determinism edges + +```yaml +- type: FINDING + rule_id: P3 + severity: minor + file: ".dev/features/init-install-capabilities/PLAN.md:27" + problem: "capability-index.ts carries two plausibly-distinct axes — directory enumeration strategy AND the strict frontmatter field-reader; the plan defers the split, which is a judgment call review should watch if the field-reader grows or gets reused." + evidence: "enumerate ... read each `/.md`, extract **only** `name`/`role`/`applies` via a **strict field reader**" +``` + +```yaml +- type: FINDING + rule_id: P5 + severity: minor + file: ".dev/features/init-install-capabilities/PLAN.md:27" + problem: "The parser's handling of an empty `applies: []` is unspecified — it would pass the per-token enum check and become an always-skipped capability rather than a hard-fail; decide explicitly (reject as malformed vs accept-as-never) so the branch stays a deliberate membership test." + evidence: "each `applies` token ∈ `{universal,ssr,backend,spa,lib}` ... `universal` mixed with archetypes → hard-fail" +``` + +## Prose summary + +The plan is well-grounded and its guarantee/trust/determinism audits are unusually honest (it labels +the advisory "the methodology is safe" claim, inherits the fetch guards rather than re-claiming them, +and flags its own over-P7 scope). The interrogation surfaced **nine** concerns, none blocking the +floor: + +- The two **important** operational risks are **(a)** force-overwriting the user's `.claude/settings.json` + (a user-owned file) with no merge/guard, and **(b)** the archetype config being a new shape the + sibling commands (`update`/`status`/`list`/`add`/`remove`) were not built to read — both are + cross-cutting effects the plan's "only `init --archetype`" framing understates. +- One **important** correctness assumption is unverified live: `SKILLS_VERSION`'s format vs `VERSION_RE` + (a mismatch hard-fails a valid install) — cheap to confirm at build. +- One **important** P1 gap: the new summary step has no planned test. +- The remaining are **minor**: the honest-but-large bundle (regression risk), unspecified re-install + pruning, the capability-index two-axis deferral, the `applies: []` edge, and the undisclosed + write-gating-hook activation. + +Recommended (advisory) for `/pharn-dev-build`: guard/merge or at minimum warn before overwriting +`settings.json`; add the `archetype-summary` test; read the live `SKILLS_VERSION` and pick a validation +regex that matches it; and state (even if "out of scope, undefined") how the sibling commands treat an +archetype config so they fail gracefully rather than crash. + +ADVISORY VERDICT: 9 concerns raised (4 important-severity, 5 minor) — for the human to weigh before +/pharn-dev-build. Nothing here blocks the floor; `/pharn-dev-build`'s own gates (spec-hash, open-questions, +validate) are unaffected. diff --git a/.dev/features/init-install-capabilities/PLAN.md b/.dev/features/init-install-capabilities/PLAN.md new file mode 100644 index 0000000..eb8f9d7 --- /dev/null +++ b/.dev/features/init-install-capabilities/PLAN.md @@ -0,0 +1,109 @@ +# PLAN — init-install-capabilities (archetype-driven `pharn init` install, flag-gated) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 — sha256 of ARCHITECTURE.md, read this run +- increment: Wire the existing pure **detect** (`detect-archetype.ts`) + **resolve** (`resolve-capabilities.ts`) into a **working, flag-gated** `pharn init --archetype`: fetch pharn-oss, derive a validated capability index from capability frontmatter, resolve against detected archetypes, show selected/skipped + confirm, copy the product surfaces (excluding dev-only), and write `pharn.config.json`. +- layer(s): **product** (`pharn-cli` CLI, `src/`) — the installer, not a `pharn-*` methodology Capability. +- constitution_refs: [P0, P1, P2, P3, P5, P6, P7] + +## Decisions carried in (human-selected at this plan's HALT, this run) + +1. **Scope = the full working command** (human picked "Full working command" over the smaller "index-boundary only" / "read-only preview" slices). This increment is **deliberately larger than P7's default "smallest coherent increment"** — the human authorized that at the plan gate. Consequence: a broad diff; the grill stage should scrutinize the copy/exclusion invariants and the untrusted-frontmatter parse boundary hardest. +2. **Legacy init = keep both, flag-gated** (human picked "Keep both, flag-gated"). The manifest-based `runInit` (v1/v2 module wizard) — **dead against live pharn-oss**, see grounding — stays the **default**; the new archetype flow runs only under `pharn init --archetype`. No legacy file is retired this increment (P7 — old pins never break; nothing removed). + +## Grounding established this run (P6 — live reads, not memory; supersedes stale prior grounding) + +- **Live pharn-oss (`pharn-dev/pharn-oss@main`, GitHub tree read this run) ships NO `manifest.json` / `module.json` / index file** — only a root `SKILLS_VERSION`. So the legacy `fetchRemoteManifest`/`runInit` path cannot work against real upstream today (motivates the flag, decision #2). +- **Applicability IS machine-readable** — each capability's frontmatter carries an `applies:` field. Verified live: `a11y` → `["ssr","spa"]`, `security` → `["universal"]`, `n-plus-one` → `["backend","ssr"]`, `path-traversal` → `["backend","ssr"]`, `trust-fence`/`injection`/`architecture` → `["universal"]`. **This reverses the `capability-resolver` plan's grounding** ("no machine-readable applicability"), which is now stale — hence no separate upstream index increment is needed; the index is **derived from frontmatter** at the fetch boundary (this increment). +- **Product surface (copy targets), confirmed live:** grillers `pharn-pipeline/grillers//` (13); lenses `pharn-review//` (22); product commands `.claude/commands/pharn-*.md` — **9 live** (`pharn-build`, `pharn-grill`, `pharn-loop`, `pharn-plan`, `pharn-regress`, `pharn-review`, `pharn-ship`, `pharn-spec`, `pharn-verify`) — the brief said "7"; **live is 9** (noted, using live); hooks `.claude/hooks/*.cjs` (3: `protect-trusted-paths`, `enforce-writes-scope`, `set-writes-scope`, each with a `.test.cjs`); `.claude/settings.json`; trusted docs at root (`CONSTITUTION.md`, `ARCHITECTURE.md`, `THREAT-MODEL.md`, `LIMITS.md`); `pharn-contracts/*.md` (3); `.dev/floor/` (40 non-test `.mjs` + support `.json` + 41 `.test.mjs`). +- **Product commands invoke `.dev/floor/*.mjs` by project-root-relative path** (verified: `pharn-verify.md` → `validate.mjs`, `check-verify.mjs`, `check-build-complete.mjs`, `check-plan-spec-agree.mjs`, `check-structural.mjs`, `count-verifiers.mjs`; `pharn-build.md` → `validate.mjs`, `check-plan-spec-agree.mjs`). `.claude/settings.json` wires `protect-trusted-paths.cjs` + `enforce-writes-scope.cjs` as PreToolUse hooks. This **fixes the dest layout** (see Open questions #1) — it is forced by the commands' own path references, not a free choice. +- **Dev-only to exclude:** `pharn-dev-*.md` commands, `.dev/features/`, `.dev/memory-bank/`, and all `.test.*` (hook/floor tests). +- **Naming:** griller dir `a11y` ↔ frontmatter `name: a11y-griller` (suffix differs); lens dir `n-plus-one` ↔ `name: n-plus-one`. The **install/copy key is the DIR basename** (deterministic copy target); `role` (griller|lens) selects the source subtree. +- **Test harness (live):** `tests/*.test.ts` build fake fetched-repos on disk (`write()`/`scaffold*()` + `useTmpDir`), no network. The archetype install's copy + parse are tested this way; the network `fetchRepo()` is reused as-is (not re-tested). + +## Files + +**New (product logic + UI):** + +- `src/lib/capability-index.ts` — **NEW** — the **untrusted-frontmatter → typed `CapabilityIndex`** fetch boundary (the piece `resolve-capabilities.ts` explicitly defers). Given a fetched repo dir: enumerate `pharn-pipeline/grillers/*/` + `pharn-review/*/` (sorted → deterministic), read each `/.md`, extract **only** `name`/`role`/`applies` via a **strict field reader** (NOT a general YAML parser — P2), validate (`role ∈ {griller,lens}`, each `applies` token ∈ `{universal,ssr,backend,spa,lib}`, `["universal"]` → `'universal'`, `universal` mixed with archetypes → hard-fail), key each entry by dir basename, guard every read with `safeJoin`. Malformed/unknown → **hard-fail naming the offending capability** (P5), never silent-skip. One axis: *deriving the typed index from fetched frontmatter*. Layer product. +- `src/lib/install-capabilities.ts` — **NEW** — the copy routine: `(repoDir, projectRoot, Selection) → InstalledCapability[]`. Copies each **selected** griller/lens dir (by role → source subtree) + the **fixed** product surfaces (the 9 `pharn-*` commands, 3 hook `.cjs`, `settings.json`, 4 trusted docs, `pharn-contracts/`, `.dev/floor/` non-test) to **mirrored** project-root paths. **Excludes** dev-only (`pharn-dev-*`, `.dev/features/`, `.dev/memory-bank/`, `.test.*`). Every source name from the fetched tree is regex-validated + `..`-checked; every read/write is `safeJoin`-guarded (P2). Pre-flight asserts each selected source exists before any write (no partial installs, mirrors `assertSkillSourcesExist`). One axis: *the capability copy routine*. Layer product. +- `src/steps/archetype-summary.ts` — **NEW** — clack UI: show detected archetypes + selected/skipped capabilities (with reasons), return `'install' | 'cancel'`. Mirrors `steps/summary.ts`. One axis: *the archetype summary + confirm stage*. Layer product. +- `src/steps/install-archetype.ts` — **NEW** — the apply stage: overwrite-guard `pharn.config.json`, call `installCapabilities`, read + validate `SKILLS_VERSION` from the repo, `fetchCommitSha()`, assemble + `writePharnConfig`, outro. Mirrors `steps/install.ts`. Takes the already-fetched `repoDir` (network-free → testable). One axis: *the archetype apply/config stage*. Layer product. + +**Edits (additive; existing axes unchanged):** + +- `src/commands/init.ts` — **EDIT** — add `runInitArchetype()` and dispatch on the flag; legacy `runInit` unchanged as default. `runInitArchetype`: `showBanner` → `intro` → `runGitPrereq` (git is the **only** hard prereq — the flow is archetype-agnostic, so it does **not** require `next`) → `runFreshCheck` → `detectArchetypesFromProject(cwd)` (+ note) → `fetchRepo()` → `parseCapabilityIndex` → `resolveCapabilities` → `runArchetypeSummary` → on install `runInstallArchetype(repo.dir, cwd, archetypes, selection)` → `repo.cleanup()` in `finally`. `runInit` gains `opts?: { archetype?: boolean }`. Axis (owns the `init` verb) unchanged — P3. +- `src/index.ts` — **EDIT** — add `archetype` to the minimist `boolean` list; `case 'init'` → `runInit({ archetype: Boolean(argv.archetype) })`; add a one-line `--archetype` entry to USAGE (P4 — it's implemented, so documenting is required, not speculative). +- `src/lib/validate.ts` — **EDIT (additive)** — add `CAPABILITY_NAME_RE` (dir basenames, e.g. `a11y`, `copy-paste-drift`), and the `ROLE_VALUES` / `ARCHETYPE_VALUES` enum allowlists + a small `assertArchetype`/`assertRole` helper for the index parse. Keeps all security allowlists in one file (P2/P3). Axis (the validation allowlist home) unchanged. +- `src/lib/constants.ts` — **EDIT (additive)** — path constants: `GRILLERS_DIR = 'pharn-pipeline/grillers'`, `LENSES_DIR = 'pharn-review'`, `SKILLS_VERSION_FILE = 'SKILLS_VERSION'`, `TRUSTED_DOCS`, `CONTRACTS_DIR`, `FLOOR_DIR`, product-command prefix + dev-command exclusion. Axis (repo path constants) unchanged. +- `src/types.ts` — **EDIT (additive)** — add `InstalledCapability { name: string; role: 'griller' | 'lens' }`; extend `PharnConfig` with `archetypes?: Archetype[]` and `capabilities?: InstalledCapability[]`; **relax `constitution` to optional** (`constitution?: Constitution`) — verified additive against read sites at build (legacy configs that include it stay valid — P7). Axis (shared type vocabulary) unchanged. + +**Tests (P1 — every new behavior + security invariant; details under `## Evals to write`):** + +- `tests/capability-index.test.ts` — **NEW** +- `tests/install-capabilities.test.ts` — **NEW** +- `tests/init-archetype.test.ts` — **NEW** +- `tests/archetype-summary.test.ts` — **NEW** +- `tests/pharn-config.test.ts` — **EDIT** +- `tests/validate.test.ts` — **EDIT** +- `tests/index.test.ts` — **EDIT** + +**Nothing removed.** `commands/init.ts` legacy paths, `installer.ts`, `install-modules.ts`, `vendor-fetch.ts`, `manifest.ts`, all `steps/*` for the wizard flow are left **byte-unchanged** (P7). + +## Contracts satisfied + +- **`ARCHITECTURE.md §5`** (archetype + capability applicability) — realizes archetype→capability selection **at install time**, the consumer of the detected-archetype membership test. Cited, not restated (P4). +- **`ARCHITECTURE.md §3.1`** (Capability frontmatter) — the index parser reads `name`/`role`/`applies` from the frontmatter. Cited (P4). **NOTE (human-owned reconciliation, surfaced not edited):** §3.1's listed frontmatter fields do **not** include `applies` (live capabilities carry it), and §5 phrases detection as "membership over `package.json`" (detection also walks the file tree). Trusted docs are hook-protected / human-only — the agent will not edit them (P2). +- **`ARCHITECTURE.md §2`** (the floor) — the parse boundary reduces to enum/regex membership + path-containment; the copy reduces to a fixed source set + `safeJoin`. Cited (P4). +- **`THREAT-MODEL.md §2` surface B** (PHARN itself ingesting hostile context) — the frontmatter parse + copy is exactly this boundary; taint is contained by validation + `safeJoin` (see Trust audit). Cited (P4). + +## Evals to write (P1 — for product TS, the vitest suite IS the eval/spec) + +- `tests/capability-index.test.ts` — **NEW**: + - fake repo (grillers/lenses w/ frontmatter) → typed index keyed by dir; `applies: ["universal"]` → `'universal'`; archetype array preserved. + - `role` not in `{griller,lens}` → hard-fail naming the capability (P5). + - unknown `applies` token (e.g. `["mobile"]`) → hard-fail (P2 enum). + - `universal` mixed with archetypes (`["universal","ssr"]`) → hard-fail (P5). + - capability dir/name with `..` or path-escaping chars → rejected (`CAPABILITY_NAME_RE` + `safeJoin`). + - missing frontmatter / missing `applies` → hard-fail naming the file. + - deterministic order across repeated parses (sorted enumeration). +- `tests/install-capabilities.test.ts` — **NEW**: + - copies **selected** grillers/lenses only; an unselected sibling is **not** copied. + - product `pharn-*.md` commands copied; **`pharn-dev-*.md` NOT copied**; `.dev/features/` + `.dev/memory-bank/` **NOT copied**; `.test.*` **NOT copied**. + - hooks/settings/docs/contracts/floor land at **mirrored** project-root paths. + - a malicious source name (`../escape`) is rejected before any write (`safeJoin`); a missing selected source fails pre-flight with nothing written. +- `tests/init-archetype.test.ts` — **NEW (the brief's headline fixture e2e)** — fixture project `package.json {dependencies:{next}}` + fake repo → `detectArchetypesFromProject` = `[ssr]` → parse+resolve → `installCapabilities` + `runInstallArchetype(repoDir, …)`: assert **a backend-only lens is skipped**, ssr/universal capabilities installed, `pharn-dev-*` excluded, product commands present, and `pharn.config.json` has `archetypes:['ssr']` + `capabilities[]` (network-free: exercises the apply path with a local `repoDir`, not `fetchRepo`). +- `tests/archetype-summary.test.ts` — **NEW** (P1 completion, GRILL.md finding F3) — mock `@clack/prompts`; assert `runArchetypeSummary` renders detected archetypes + selected/skipped capabilities and returns `install`/`cancel` (mirrors `tests/summary.test.ts`). +- `tests/pharn-config.test.ts` — **EDIT** — a **legacy** config (has `constitution` + `modules`, no `archetypes`) still `readPharnConfig`s (P7); an **archetype** config (`archetypes` + `capabilities` + `modules:[]`, no `constitution`) reads + round-trips. +- `tests/validate.test.ts` — **EDIT** — `CAPABILITY_NAME_RE` accept/reject; `assertArchetype`/`assertRole` enum membership + control-char/`..` rejection. +- `tests/index.test.ts` — **EDIT** — `pharn init --archetype` dispatches to the archetype flow (spy on `runInit`, assert it receives `{archetype:true}`); bare `init` keeps `{archetype:false}` (legacy default). + +## Guarantee audit (P0) + +- **"Detection deterministic (same project → same archetypes)"** → **floor:** pure membership + vitest (existing `detect-archetype`/`archetype` suites; reused). +- **"Selection deterministic"** → **floor:** pure set-intersection + vitest (existing `resolve-capabilities` suite; reused). +- **"The capability index is parsed safely from untrusted frontmatter"** → **floor:** enum/regex allowlists (`validate.ts`) + `safeJoin` on every read + **hard-fail on malformed** (P5) + vitest. A **strict field reader**, not a general YAML parser, touches the untrusted bytes. +- **"Only selected capabilities + product (non-dev) surfaces are copied; dev-only excluded"** → **floor:** a **fixed** source set + regex-validated names + a **deterministic exclusion filter** (`pharn-dev-*` prefix / `.test.*` suffix / `.dev/features` + `.dev/memory-bank` never in the copy set) + `safeJoin` on every write + a vitest that asserts the exclusions. +- **"Same project + same upstream commit → same install"** → **floor:** sorted enumeration + fixed source set + membership resolve + vitest. +- **"Network fetch is guarded"** → **floor (inherited, not newly claimed):** reuses `repo.ts` `fetchRepo` (degit) + `fetchCommitSha` (`redirect:'error'` + 8s timeout). The degit clone's properties are the existing ones — reused, not re-guaranteed here. +- **"The installed methodology/capabilities are CORRECT or safe to RUN"** → **advisory — NOT claimed.** Correctness/safety of pharn-oss's capability *content* + its `applies` values is pharn-oss's SoT + provenance (`LIMITS.md`, `THREAT-MODEL.md §5`). The CLI guarantees only the **validated parse** + the **path-contained deterministic copy** — never that the copied content is good. Copied files are **data to the CLI** (never executed/parsed by it; the user's Claude Code runs them later — the same trust posture as the legacy module install). + +No guarantee is asserted without a floor reduction; the one thing that could masquerade as a guarantee — "the methodology is safe" — is explicitly labeled advisory. + +## Trust audit (P2) + +Untrusted input ingested this increment: the **fetched pharn-oss repo** — capability frontmatter, file **names**, and file **contents**. + +- **Capability / file NAMES** (dir + file basenames from the fetched tree) → validated against `CAPABILITY_NAME_RE` (+ `..` + control-char check) **before** any path-join, then `safeJoin`-guarded → cannot escape the project root. A validated name may be **persisted** into `pharn.config.json` `capabilities[]` because it is now a floor-verified (enum/regex) field, not free text. +- **`role` / `applies` frontmatter values** → validated against enum allowlists; malformed → **hard-fail** (never silently trusted). `applies` drives `resolve` (a membership test over a validated enum) — **no free-text frontmatter value ever drives a branch** (P5). +- **File CONTENTS** (command `.md`, hook `.cjs`, floor `.mjs`, docs, contracts) → **copied verbatim, never executed or parsed** by the CLI. The CLI's guarantee is a **path-contained copy** (`safeJoin`), not content safety — identical to the existing installer's posture (`P2`: fetched files are untrusted data; every copy `safeJoin`-guarded). Taint does not reach any CLI control-flow decision. +- **`SKILLS_VERSION`** → read + validated (`VERSION_RE`) before persisting. +- **No untrusted value is interpolated into degit/shell** here — the clone source is the hardcoded `REPO` constant, not user/fetched input. + +## Open questions (HALT) + +Resolved-with-stated-default below (the two shape-determining forks were already answered at the interactive gate); these are refinements — confirm at approval, or accept the stated defaults: + +1. **Dest layout = mirror pharn-oss's relative paths into the user's PROJECT ROOT** (`.claude/{commands,hooks,settings.json}`; root docs at root; `pharn-contracts/`, `.dev/floor/`, `pharn-pipeline/grillers/`, `pharn-review/` at their same paths). **This is forced by the product commands' own project-root-relative path references** (grounding) — treated as determined, not a free choice. Confirm. +2. **`.dev/floor/` copy scope = all non-`.test.mjs` files + support `.json`** (not just the subset a given command names) — floor `.mjs` import each other, so a referenced-only subset risks a missing transitive import. Default: copy all non-test floor files. +3. **Selected griller/lens dirs are copied whole, including their `evals/`** — the `evals/` are the capability's committed spec and match "copy `pharn-pipeline/grillers/`". Default: include `evals/`. +4. **`constitution` field** — the archetype flow installs pharn-oss's canonical `CONSTITUTION.md` verbatim (no variant selection), so the config **omits** `constitution`; the type field becomes optional (additive). Build will verify no read site dereferences it unconditionally; if one does, fall back to writing the nominal `'standard'`. diff --git a/.dev/features/init-install-capabilities/REGRESSION.md b/.dev/features/init-install-capabilities/REGRESSION.md new file mode 100644 index 0000000..666c69f --- /dev/null +++ b/.dev/features/init-install-capabilities/REGRESSION.md @@ -0,0 +1,36 @@ +# REGRESSION — init-install-capabilities + +- base: `43e16b37e62333edd06ff043ca242dd56a8dfc01` (HEAD — working-tree dogfood build) +- verdict source: `.dev/floor/check-regress.mjs verdict` (deterministic; zero LLM judgment) + +## Inside / outside partition + +- **inside** (16 — the feature's declared `## Files`, all within scope → no fix#7 breach): + the 9 `src/` files + 7 `tests/*.test.ts` files. +- **outside** gates run: `tests` (44 floor/hook `*.test.mjs` / `*.test.cjs`, none touched by the + feature) + `validate` (whole-repo). Style gates **skipped** — no shared style config + (`eslint.config.mjs` / `.prettierrc.json` / `.prettierignore` / `.markdownlint-cli2.jsonc`) is inside + the changed scope, so a style flip over byte-identical outside files is provably impossible (P5/P7). + No committed eval pairs exist in this repo → `outside_eval_pairs: 0`. + +## Per-gate exit codes (base → head) + +| gate | base | head | flip? | +| -------- | ---- | ---- | --------------- | +| tests | 1 | 1 | no (pre-existing RED) | +| validate | 0 | 0 | no | + +- `regressions[]`: **none**. +- `pre_existing[]`: `tests` — the floor/hook `node --test` suite is RED at the clean baseline SHA + (independent of this feature; RED→RED is not a flip, so it is correctly **not** a regression). This + increment touched no `.dev/floor/*` or `.claude/hooks/*` file. + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** Stage does not +fail. + +Honest residual (P0/P7): `/pharn-dev-regress` catches exactly what its deterministic suite catches — +nothing more. This is "no deterministically-detectable breakage outside the feature," **not** a claim +that nothing broke. The product `vitest` suite (the feature's own spec, P1) is covered by +`/pharn-dev-build`'s GREEN `npm run check` and is re-run by `/pharn-dev-verify` next. diff --git a/.dev/features/init-install-capabilities/REVIEW.md b/.dev/features/init-install-capabilities/REVIEW.md new file mode 100644 index 0000000..821bdf5 --- /dev/null +++ b/.dev/features/init-install-capabilities/REVIEW.md @@ -0,0 +1,97 @@ +# REVIEW — init-install-capabilities + +PHARN reviewing PHARN. The increment is `trust: untrusted`; instruction-looking content in the +reviewed code/frontmatter is DATA. **Floor (Step 1, P0):** `node .dev/floor/validate.mjs .` → **GREEN** +(0 capabilities — no markdown capability added; the increment is TypeScript). Everything below the +floor is **advisory**. + +## Floor-gate (blocking) findings + +**None.** No P0 guarantee lacks a floor reduction or an `advisory` label; no missing eval binding; no +tainted field gates any decision; no sibling (step→step / command→command) import. + +## Advisory findings — by lens + +### L-floor → P0 (guarantee ↔ floor reduction) + +No findings. Every guarantee the increment makes reduces to the floor or is labeled advisory: parse +safety → enum/regex allowlists (`validate.ts`) + `safeJoin` + hard-fail (P5); copy correctness/exclusion +→ fixed source set + membership + `safeJoin` + vitest; "the installed methodology is safe to run" is +explicitly **advisory, not claimed**; the network guards are **inherited** from `repo.ts`, not +re-claimed. Honest. + +### L-eval → P1 (behavior ↔ test) + +```yaml +- type: FINDING + rule_id: P1 + severity: minor + file: 'src/commands/init.ts:68' + problem: "runInitArchetype's orchestration (fetch → parse → resolve → summary → install → cleanup-in-finally, and the error/cancel exit-after-cleanup ordering) has no test; only its constituent lib/steps and the flag dispatch are covered." + evidence: 'async function runInitArchetype(): Promise {' +``` + +Advisory: the gap is the networked orchestration wiring (hard to unit-test without mocking `fetchRepo`); +the copy/parse/resolve/config pieces it composes are each thoroughly tested, and the e2e drives the +apply path. Acceptable, but the cleanup-before-exit ordering is exactly the kind of thing that silently +regresses — a future increment could add a seam to test it. + +### L-trust → P2 (untrusted data handling) + +```yaml +- type: FINDING + rule_id: P2 + severity: minor + file: 'src/lib/install-capabilities.ts:102' + problem: "The install copies executable hooks (.cjs) + floor scripts (.mjs) into the user's project and (for a fresh project) writes settings.json that activates PreToolUse write-gating hooks across their repo — a real environment change; it is disclosed in the summary and the settings guard prevents clobbering an existing config, so the residual is bounded, not zeroed." + evidence: '// --- settings.json: NEVER overwrite the user' +``` + +Advisory and **handled**: file contents are copied verbatim and never executed/parsed by the CLI (the +CLI guarantees only path-contained copy via `safeJoin`); the summary step discloses that hooks are +installed; an existing `settings.json` is preserved (grill F1 fix). No guaranteed decision rests on any +fetched free-text — `role`/`applies` are enum-validated, names regex-validated, before any use. No +injection-looking content in the reviewed capability frontmatter altered this review. + +### L-axis → P3 (one axis; no sibling imports) + +```yaml +- type: FINDING + rule_id: P3 + severity: minor + file: 'src/lib/capability-index.ts:34' + problem: "capability-index.ts carries two plausibly-separable axes — directory enumeration AND the strict frontmatter field-reader; the split is deferred (also noted in GRILL.md), fine for now but worth watching if the field-reader is reused or grows." + evidence: '// One axis (P3): deriving the typed index from fetched capability frontmatter.' +``` + +No sibling imports: the new steps import only `lib/` + types (verified — no `../steps/` import in either +new step); `init.ts` (command) imports steps + lib, the established pattern. + +## Standing cross-cutting concern (advisory — for the post-review gate) + +```yaml +- type: FINDING + rule_id: P6 + severity: important + file: 'src/steps/install-archetype.ts:75' + problem: "The archetype config shape (modules:[], capabilities[...], no constitution) is new; the sibling commands add/update/remove/list/status were built to read module/manifest installs and are undefined for it — and since live upstream has no manifest, `pharn update`/`status`/`list` against an archetype config would error rather than degrade gracefully." + evidence: 'modules: [],' +``` + +This is scoped-out by design (this increment is `init --archetype` only) but is a real integration edge +the human should weigh before merging: either accept it as a documented limitation or plan a follow-up +that makes the sibling commands archetype-aware (or fail with a clear message). Re-surfaced from +`GRILL.md`; it survived build unchanged because addressing it would expand scope (P7). + +## Proposed lesson (candidate — NOT written to canon here) + +None proposed. The one reusable pattern this increment exercised — *a fetched temp clone that must live +across an interactive prompt needs cleanup in `finally` with every `process.exit`/`cancelAndExit` after +it* — is **already** established in the codebase (`commands/status.ts`, `commands/remove.ts`), so it is +not a new recurring failure worth a `/pharn-dev-memory-promote` run (P7 — real, but already canon in practice). + +## Verdict + +**GREEN — no floor-gate (blocking) findings.** 4 advisory findings (1 important, 3 minor) for the human +to weigh at the post-review gate. Advisory ≠ guaranteed: this review certifies only the GREEN floor +(`validate.mjs`); the findings above rest on reviewer judgment and inform, they do not block. diff --git a/.dev/features/init-install-capabilities/SHIP.md b/.dev/features/init-install-capabilities/SHIP.md new file mode 100644 index 0000000..cb45ddf --- /dev/null +++ b/.dev/features/init-install-capabilities/SHIP.md @@ -0,0 +1,44 @@ +# SHIP — init-install-capabilities (gated /pharn-dev-ship roll-up) + +Advisory roll-up of one gated `/pharn-dev-ship` run. `/pharn-dev-ship` adds **no** floor primitive — every +verdict below belongs to a sub-stage; this file only records that the chain ran and its floor verdicts. + +## Stages run, in order + +`/pharn-dev-plan` → **[GATE 1: human approved as-written]** → `/pharn-dev-grill` → `/pharn-dev-build` → +`/pharn-dev-regress` → `/pharn-dev-verify` → `/pharn-dev-review` → **[GATE 2: this halt]**. + +The run ended at **GATE 2** (post-review human decision) — not at a RED-verdict STOP. Every structural +floor verdict came back GREEN/PASS, so the chain proceeded stage to stage. + +## Structural verdicts read (verbatim — the floor, each owned by its sub-stage) + +| stage | verdict source | verdict | +| ----------------- | ------------------------------------------------- | ------------------ | +| `/pharn-dev-build` | `node .dev/floor/validate.mjs .` exit + `npm run check` | **GREEN** (validate exit 0, vacuous — TS increment; `npm run check` exit 0, 452 tests) | +| `/pharn-dev-regress` | `regression-report.json` `.verdict` | **`no-regressions`** | +| `/pharn-dev-verify` | `verify-report.json` `.verdict` | **`PASS`** (6/6 gates exit 0) | + +- **Build** floor: `validate.mjs` GREEN (0 capabilities — the increment adds no markdown capability), and + the repo's real floor `npm run check` (format:check + lint + typecheck + test) exit 0. +- **Regress** (`.dev/features/init-install-capabilities/regression-report.json`): `regressions: []`; the + `tests` gate is `pre_existing` (the floor/hook `node --test` suite is RED at the clean baseline SHA + `43e16b3`, independent of this feature — RED→RED is not a flip). Base = HEAD (working-tree build). +- **Verify** (`.dev/features/init-install-capabilities/verify-report.json`): `PASS`; `failing_gates: []`; + `verifiers: { registered: 0 }` (floor gates only). + +## Advisory stages (gate nothing — pointers, not restated, P4) + +- **`/pharn-dev-grill`** → `.dev/features/init-install-capabilities/GRILL.md` — 9 concerns (4 important, 5 minor), + advisory. Three actionable in-scope ones were folded into the build: `settings.json` overwrite guard, + the `archetype-summary` P1 test, and verifying the live `SKILLS_VERSION` format (`1.0.0` — matches + `VERSION_RE`). +- **`/pharn-dev-review`** → `.dev/features/init-install-capabilities/REVIEW.md` — verdict **GREEN** (no floor-gate + blocking findings); 4 advisory findings (1 important: the archetype config shape is not understood by + the sibling `add`/`update`/`remove`/`list`/`status` commands; 3 minor). See the file — not restated here. + +## The standing decision is the human's + +The chain ran; the named floor verdicts are as shown — **this is NOT a judgment that the increment is +good or wise; that is the human's call at the post-review gate.** `/pharn-dev-ship` does not merge, push, +commit, or apply the `PHARN ✓ reviewed` seal. The changes remain an uncommitted working tree on `main`. diff --git a/.dev/features/init-install-capabilities/VERIFY.md b/.dev/features/init-install-capabilities/VERIFY.md new file mode 100644 index 0000000..ea434c0 --- /dev/null +++ b/.dev/features/init-install-capabilities/VERIFY.md @@ -0,0 +1,34 @@ +# VERIFY — init-install-capabilities + +- verdict source: `.dev/floor/check-verify.mjs` (deterministic exit-code threshold: PASS iff every gate 0) + +## FLOOR layer — deterministic gates (own the verdict) + +| gate | exit | meaning | +| ------------ | ---- | --------------------------------------------------- | +| test | 0 | `npm test` — the hermetic vitest suite (452 passed) | +| typecheck | 0 | `tsc --noEmit` (src + tests configs) | +| lint | 0 | `eslint src` | +| format:check | 0 | prettier (whole-repo) | +| lint:md | 0 | markdownlint (whole-repo) | +| validate | 0 | `.dev/floor/validate.mjs .` (GREEN — 0 capabilities)| + +**VERIFIED: floor gates PASS.** `failing_gates`: none. + +The gate set is the repo's full `npm run check` aggregate (test + lint + typecheck + format:check) plus +`lint:md` + `validate.mjs`. No committed eval pairs exist in this repo → no `structural:*` gate. + +## ADVISORY layer — verifiers + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0}` — **no verifiers registered — floor gates +only.** Step 2 is a no-op; the verdict is the floor gates alone. + +## Honest residual (P0/P7) + +Verified = the named gates passed; this is **NOT** a guarantee of correctness beyond what those gates +check — verifier concerns would be advisory help, not assurance (and none are registered). Specifically: +the deterministic suite exercises the parse/copy/resolve/config logic and the CLI flag dispatch, but the +**networked** path (`fetchRepo` degit clone + `fetchCommitSha`) is reused-as-is and not driven here, and +the correctness of pharn-oss's own capability content is out of scope (pharn-oss's SoT). The grill-stage +concerns (`GRILL.md`) — sibling-command behavior on an archetype config; re-install pruning — remain +advisory for the human at the post-review gate. diff --git a/.dev/features/init-install-capabilities/regression-report.json b/.dev/features/init-install-capabilities/regression-report.json new file mode 100644 index 0000000..a1c6f71 --- /dev/null +++ b/.dev/features/init-install-capabilities/regression-report.json @@ -0,0 +1,36 @@ +{ + "base": "43e16b37e62333edd06ff043ca242dd56a8dfc01", + "inside": [ + "src/lib/capability-index.ts", + "src/lib/install-capabilities.ts", + "src/steps/archetype-summary.ts", + "src/steps/install-archetype.ts", + "src/commands/init.ts", + "src/index.ts", + "src/lib/validate.ts", + "src/lib/constants.ts", + "src/types.ts", + "tests/capability-index.test.ts", + "tests/install-capabilities.test.ts", + "tests/init-archetype.test.ts", + "tests/archetype-summary.test.ts", + "tests/pharn-config.test.ts", + "tests/validate.test.ts", + "tests/index.test.ts" + ], + "outside_gates": { + "tests": { + "base": 1, + "head": 1 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [ + "tests" + ], + "verdict": "no-regressions" +} diff --git a/.dev/features/init-install-capabilities/verify-report.json b/.dev/features/init-install-capabilities/verify-report.json new file mode 100644 index 0000000..444a89a --- /dev/null +++ b/.dev/features/init-install-capabilities/verify-report.json @@ -0,0 +1,14 @@ +{ + "feature": "init-install-capabilities", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { "registered": 0, "findings": [] } +} diff --git a/.pharn/pharn-dev-regress/base-results.json b/.pharn/pharn-dev-regress/base-results.json index 9b3f2b3..c825c88 100644 --- a/.pharn/pharn-dev-regress/base-results.json +++ b/.pharn/pharn-dev-regress/base-results.json @@ -1 +1 @@ -{"tests":0,"validate":0} +{"tests":1,"validate":0} diff --git a/.pharn/pharn-dev-regress/head-results.json b/.pharn/pharn-dev-regress/head-results.json index 9b3f2b3..c825c88 100644 --- a/.pharn/pharn-dev-regress/head-results.json +++ b/.pharn/pharn-dev-regress/head-results.json @@ -1 +1 @@ -{"tests":0,"validate":0} +{"tests":1,"validate":0} diff --git a/.pharn/pharn-dev-verify/results.json b/.pharn/pharn-dev-verify/results.json index d76b920..68e9e33 100644 --- a/.pharn/pharn-dev-verify/results.json +++ b/.pharn/pharn-dev-verify/results.json @@ -1 +1 @@ -{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} +{"test":0,"validate":0,"lint":0,"typecheck":0,"format:check":0,"lint:md":0} diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 49271ee..8ada267 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/archetype-enum-align/SHIP.md" + ".dev/features/init-install-capabilities/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-07-07T13:54:49.216Z" + "set_at": "2026-07-07T16:37:30.021Z" } diff --git a/src/commands/init.ts b/src/commands/init.ts index 3963b8e..ef57e15 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,4 +1,5 @@ -import { intro, log, spinner } from '@clack/prompts'; +import { existsSync } from 'node:fs'; +import { confirm, intro, isCancel, log, note, spinner } from '@clack/prompts'; import { showBanner } from '../lib/banner.js'; import { cancelAndExit } from '../lib/confirm.js'; import { REPO_URL } from '../lib/constants.js'; @@ -12,6 +13,11 @@ import { collectInstalls, collectVendorSkills, } from '../lib/wizard.js'; +import { detectArchetypesFromProject } from '../lib/detect-archetype.js'; +import { parseCapabilityIndex } from '../lib/capability-index.js'; +import { resolveCapabilities } from '../lib/resolve-capabilities.js'; +import { fetchRepo, fetchCommitSha } from '../lib/repo.js'; +import { configPath, readPharnConfig } from '../lib/pharn-config.js'; import { runGitPrereq, assertPrerequisites } from '../steps/prereqs.js'; import { runFreshCheck } from '../steps/fresh-check.js'; import { runModeSelect } from '../steps/mode-select.js'; @@ -24,15 +30,27 @@ import { runMultiTenantSelect } from '../steps/multitenant-select.js'; import { runVendorConsent } from '../steps/vendor-consent.js'; import { runSummary } from '../steps/summary.js'; import { runInstall } from '../steps/install.js'; +import { runArchetypeSummary } from '../steps/archetype-summary.js'; +import { runInstallArchetype } from '../steps/install-archetype.js'; import type { Manifest, WizardConfig, WizardSpec } from '../types.js'; -export async function runInit(): Promise { +export async function runInit( + opts: { archetype?: boolean } = {}, +): Promise { showBanner(); intro('init wizard'); runGitPrereq(); await runFreshCheck(); + // Archetype-driven install (experimental, flag-gated). Framework-agnostic: it + // detects the project's archetype(s) and installs the applicable capabilities. + // The legacy module/wizard flow below stays the default. + if (opts.archetype) { + await runInitArchetype(); + return; + } + const manifest = await loadManifest(); if (manifest.schemaVersion === 2 && manifest.wizard) { @@ -42,6 +60,77 @@ export async function runInit(): Promise { await runInitLegacy(manifest); } +// schemaVersion-free archetype flow: detect archetypes from the project, fetch +// pharn-oss, derive + resolve the capability index, confirm, then copy the +// applicable capabilities + product surfaces. The fetched temp clone lives +// across the interactive summary, so cleanup runs in a finally and every +// process.exit / cancelAndExit happens AFTER it (Node skips finally on exit). +async function runInitArchetype(): Promise { + const cwd = process.cwd(); + const { archetypes } = detectArchetypesFromProject(cwd); + note(archetypes.join(', '), 'Detected archetypes'); + + const s = spinner(); + s.start(`Fetching PHARN from ${REPO_URL}`); + let repo: Awaited>; + try { + repo = await fetchRepo(); + } catch (err) { + s.stop('Failed to fetch PHARN'); + const message = err instanceof Error ? err.message : String(err); + log.error(`⚠ Could not reach ${REPO_URL}: ${message}`); + if (process.env.PHARN_DEBUG) console.error(err); + process.exit(1); + } + + s.stop(`PHARN fetched from ${REPO_URL}`); + + let outcome: 'installed' | 'cancelled' = 'cancelled'; + let failure: string | null = null; + try { + const index = parseCapabilityIndex(repo.dir); + const selection = resolveCapabilities(archetypes, index); + + const action = await runArchetypeSummary(archetypes, selection); + if (action === 'install' && (await confirmOverwriteIfExists(cwd))) { + const commit = await fetchCommitSha(); + await runInstallArchetype(repo.dir, cwd, archetypes, selection, commit); + outcome = 'installed'; + } + } catch (err) { + failure = err instanceof Error ? err.message : String(err); + if (process.env.PHARN_DEBUG) console.error(err); + } finally { + repo.cleanup(); + } + + if (failure) { + log.error(`⚠ ${failure}`); + if (!process.env.PHARN_DEBUG) { + log.info('Re-run with PHARN_DEBUG=1 for full error output.'); + } + process.exit(1); + } + if (outcome === 'cancelled') cancelAndExit(); +} + +// Confirm before clobbering an existing pharn.config.json. Returns true to +// proceed. No process.exit here — the caller handles cancel after cleanup. +async function confirmOverwriteIfExists(cwd: string): Promise { + if (!existsSync(configPath(cwd))) return true; + const existing = readPharnConfig(cwd); + if (existing) { + log.info( + `Existing pharn.config.json found (skillsVersion ${existing.skillsVersion ?? 'unknown'}).`, + ); + } + const ok = await confirm({ + message: 'Overwrite existing pharn.config.json?', + initialValue: false, + }); + return !isCancel(ok) && ok === true; +} + // schemaVersion 2: render the wizard from manifest.wizard, then install only the // selected per-tech skill subfolders. async function runInitV2( diff --git a/src/index.ts b/src/index.ts index 24690f8..04fe47d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ Commands: status Show version + local-drift status (read-only) Options: + --archetype init: detect archetypes + install applicable capabilities (experimental) -y, --yes Skip the remove confirmation prompt --strict Make status exit 1 on any outdated/modified/missing file --no-drift Skip the status byte-level drift check @@ -37,7 +38,7 @@ Options: export async function main(): Promise { const argv = minimist(process.argv.slice(2), { - boolean: ['help', 'version', 'json', 'yes', 'strict', 'drift'], + boolean: ['help', 'version', 'json', 'yes', 'strict', 'drift', 'archetype'], // `status` drifts by default; `--no-drift` flips it off. minimist defaults // bare booleans to false, so set the on-by-default here explicitly. default: { drift: true }, @@ -58,7 +59,7 @@ export async function main(): Promise { switch (cmd) { case 'init': - await runInit(); + await runInit({ archetype: Boolean(argv.archetype) }); return; case 'add': await runAdd(argv._[1]); diff --git a/src/lib/capability-index.ts b/src/lib/capability-index.ts new file mode 100644 index 0000000..55d38a4 --- /dev/null +++ b/src/lib/capability-index.ts @@ -0,0 +1,182 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { safeJoin } from './install-modules.js'; +import { GRILLERS_DIR, LENSES_DIR } from './constants.js'; +import { + assertAppliesToken, + assertNoDotDot, + assertRole, + assertSafeString, + CAPABILITY_NAME_RE, + ManifestValidationError, +} from './validate.js'; +import type { Archetype, CapabilityEntry, CapabilityIndex } from '../types.js'; + +// --------------------------------------------------------------------------- +// Capability index — the FETCH BOUNDARY (P2). This is the untrusted-frontmatter +// → typed CapabilityIndex boundary that resolve-capabilities.ts explicitly +// defers ("parsing + validating the untrusted index bytes is the fetch +// boundary's job, a later increment"). Given a fetched pharn-oss clone, it +// enumerates the griller + lens subtrees and derives one CapabilityEntry per +// capability from its markdown frontmatter — pharn-oss declares applicability in +// each capability's `applies:` field (there is no separate index file). +// +// Trust (P2): the fetched frontmatter is untrusted. We read ONLY the three +// declared fields (`name`/`role`/`applies`) via a STRICT field reader — never a +// general YAML parser over untrusted bytes — and validate every value against a +// fixed allowlist (validate.ts): dir names against CAPABILITY_NAME_RE (+ no +// `..`) before any path-join, `role` against {griller,lens}, each `applies` +// token against the archetype/`universal` enum. A malformed or unknown value +// HARD-FAILS naming the offending capability (P5) — never a silent skip, never a +// silent trust. Every filesystem read is safeJoin-guarded. No free-text +// frontmatter value escapes: the typed output carries only enum/regex-validated +// fields. +// +// One axis (P3): deriving the typed index from fetched capability frontmatter. +// The disk-reading shape is intentionally minimal (a strict frontmatter reader +// co-located here); the selection RULE over the produced index lives next door +// in resolve-capabilities.ts, and the archetype detection in detect-archetype.ts. +// --------------------------------------------------------------------------- + +// One capability subtree in the fetched repo and the role every capability under +// it carries. The subtree is authoritative for `role`; the frontmatter `role` is +// cross-checked against it (a file in the wrong subtree hard-fails). +const SUBTREES: { dir: string; role: 'griller' | 'lens' }[] = [ + { dir: GRILLERS_DIR, role: 'griller' }, + { dir: LENSES_DIR, role: 'lens' }, +]; + +/** + * Parse + validate the capability index from a fetched pharn-oss clone. + * Deterministic (P5): capabilities are enumerated in sorted directory order + * within each subtree, grillers before lenses, so the same clone always yields + * the same index. Throws (naming the offending capability) on any missing + * subtree, missing capability markdown, malformed frontmatter, or invalid + * `role`/`applies` value — no partial or silently-degraded index. + */ +export function parseCapabilityIndex(repoDir: string): CapabilityIndex { + const capabilities: CapabilityEntry[] = []; + + for (const subtree of SUBTREES) { + const subtreeDir = safeJoin(repoDir, subtree.dir); + if (!existsSync(subtreeDir)) { + throw new ManifestValidationError( + `Capability subtree "${subtree.dir}" is missing in the fetched repo.`, + ); + } + + const names = readdirSync(subtreeDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.isSymbolicLink()) + .map((e) => e.name) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + for (const name of names) { + // Validate the untrusted dir name BEFORE any path-join (P2). + assertSafeString(name, `capability "${name}"`, CAPABILITY_NAME_RE); + assertNoDotDot(name, `capability "${name}"`); + + const capFile = safeJoin(subtreeDir, `${name}/${name}.md`); + if (!existsSync(capFile)) { + throw new ManifestValidationError( + `Capability "${name}" in ${subtree.dir} is missing its markdown ${name}/${name}.md.`, + ); + } + + const frontmatter = extractFrontmatter( + readFileSync(capFile, 'utf8'), + name, + ); + // Cross-check the declared role against the authoritative subtree role. + const role = assertRole(readField(frontmatter, 'role', name), name); + if (role !== subtree.role) { + throw new ManifestValidationError( + `Capability "${name}" declares role "${role}" but lives under ${subtree.dir} (expected "${subtree.role}").`, + ); + } + const applies = parseApplies( + readField(frontmatter, 'applies', name), + name, + ); + + capabilities.push({ name, role, applies }); + } + } + + return { capabilities }; +} + +/** + * Extract the raw `---`-fenced frontmatter block from a capability markdown + * file. Only this block is parsed — a field-looking line in the prose body is + * never read. Hard-fails (naming the capability) when no frontmatter fence is + * present (P5). + */ +function extractFrontmatter(content: string, name: string): string { + const match = /^---\n([\s\S]*?)\n---/.exec(content); + if (!match) { + throw new ManifestValidationError( + `Capability "${name}" is missing a "---"-fenced frontmatter block.`, + ); + } + return match[1]!; +} + +/** + * Read a single required scalar field from the frontmatter block, returning its + * raw (quote-stripped) value. Hard-fails (naming the capability + field) when + * the field is absent (P5). A strict per-field regex — not a YAML parser. + */ +function readField(frontmatter: string, field: string, name: string): string { + const re = new RegExp(`^${field}:[ \\t]*(.+?)[ \\t]*$`, 'm'); + const match = re.exec(frontmatter); + if (!match) { + throw new ManifestValidationError( + `Capability "${name}" is missing the "${field}" frontmatter field.`, + ); + } + return stripQuotes(match[1]!); +} + +function stripQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +/** + * Parse + validate the `applies` frontmatter value into the resolver's shape: + * `["universal"]` → `'universal'` (always selected); a non-empty archetype array + * → `Archetype[]`. Hard-fails (P2/P5) on: not a bracketed array, an empty array, + * an unknown token, or `universal` mixed with archetypes (an ambiguous, + * malformed declaration). + */ +function parseApplies(raw: string, name: string): 'universal' | Archetype[] { + if (!/^\[.*\]$/.test(raw)) { + throw new ManifestValidationError( + `Capability "${name}" has a malformed "applies" value ${JSON.stringify(raw)} (expected a "[...]" array).`, + ); + } + const tokens = [...raw.matchAll(/["']([a-z0-9]+)["']/g)].map((m) => m[1]!); + if (tokens.length === 0) { + throw new ManifestValidationError( + `Capability "${name}" has an empty "applies" array — every capability must declare its applicability.`, + ); + } + const validated = tokens.map((t) => + assertAppliesToken(t, `capability "${name}"`), + ); + + const universal = validated.includes('universal'); + if (universal && validated.length > 1) { + throw new ManifestValidationError( + `Capability "${name}" mixes "universal" with archetypes in "applies" — use exactly ["universal"] or archetypes only.`, + ); + } + if (universal) return 'universal'; + // Every remaining token is an Archetype (universal was excluded above). + return validated as Archetype[]; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c35013a..5e6c0cd 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -21,3 +21,42 @@ export const CORE_MODULE = 'pharn-core'; // convention (e.g. `orm` → `pharn-skills-orm`). Every installable wizard option // is rooted at one of these so `add :` can address it. export const SKILL_MODULE_PREFIX = 'pharn-skills-'; + +// --------------------------------------------------------------------------- +// Archetype (capability) install (pharn init --archetype). Repo-relative source +// paths in the fetched pharn-oss clone. Each capability lives at +// `//.md`; the install mirrors these paths (and the .claude +// surfaces) into the user's project root so the copied product commands' own +// project-root-relative references (`.dev/floor/...`, `pharn-contracts/...`, +// `pharn-pipeline/grillers/...`) resolve after install (ARCHITECTURE.md §5/§6). +// --------------------------------------------------------------------------- + +// Griller capabilities (role: griller) → source subtree. +export const GRILLERS_DIR = 'pharn-pipeline/grillers'; +// Lens capabilities (role: lens) → source subtree. +export const LENSES_DIR = 'pharn-review'; +// Root version file pharn-oss ships instead of a manifest (the archetype flow +// reads this in place of manifest.skillsVersion). +export const SKILLS_VERSION_FILE = 'SKILLS_VERSION'; +// Trusted spec docs copied to the project root (write-protected there by the +// installed protect-trusted-paths.cjs hook). +export const TRUSTED_DOCS = [ + 'CONSTITUTION.md', + 'ARCHITECTURE.md', + 'THREAT-MODEL.md', + 'LIMITS.md', +]; +// Inter-layer schema contracts (whole dir). +export const CONTRACTS_DIR = 'pharn-contracts'; +// Deterministic floor checkers the product commands + hooks invoke at runtime. +// Copied whole EXCEPT test files (see install-capabilities.ts). NOT `.dev/` +// wholesale — `.dev/features` and `.dev/memory-bank` are dev-only and excluded. +export const FLOOR_DIR = '.dev/floor'; +// Claude Code surfaces. +export const CLAUDE_COMMANDS_DIR = '.claude/commands'; +export const CLAUDE_HOOKS_DIR = '.claude/hooks'; +export const CLAUDE_SETTINGS_FILE = '.claude/settings.json'; +// Product commands are `pharn-*.md`; the dev-loop commands `pharn-dev-*.md` are +// excluded from a product install. +export const PRODUCT_COMMAND_PREFIX = 'pharn-'; +export const DEV_COMMAND_PREFIX = 'pharn-dev-'; diff --git a/src/lib/install-capabilities.ts b/src/lib/install-capabilities.ts new file mode 100644 index 0000000..39a9c48 --- /dev/null +++ b/src/lib/install-capabilities.ts @@ -0,0 +1,173 @@ +import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { safeJoin } from './install-modules.js'; +import { + assertNoDotDot, + assertSafeString, + CAPABILITY_NAME_RE, + COPY_FILENAME_RE, + ManifestValidationError, +} from './validate.js'; +import { + CLAUDE_COMMANDS_DIR, + CLAUDE_HOOKS_DIR, + CLAUDE_SETTINGS_FILE, + CONTRACTS_DIR, + DEV_COMMAND_PREFIX, + FLOOR_DIR, + GRILLERS_DIR, + LENSES_DIR, + PRODUCT_COMMAND_PREFIX, + TRUSTED_DOCS, +} from './constants.js'; +import type { InstalledCapability, Selection } from '../types.js'; + +// --------------------------------------------------------------------------- +// Capability copy routine (archetype install). Copies the RESOLVED capabilities +// + the FIXED product surfaces from a fetched pharn-oss clone into the user's +// project, mirroring pharn-oss's relative paths so the copied product commands' +// own project-root-relative references resolve after install. +// +// Trust (P2): the fetched repo is untrusted. Every name read from the tree is +// validated against a fixed allowlist (validate.ts) BEFORE any path-join, and +// every read/write is safeJoin-guarded so nothing escapes its base dir. File +// CONTENTS are copied verbatim and never executed/parsed by the CLI (the user's +// Claude Code runs them later — the same posture as the module install). +// +// Dev-only exclusion is STRUCTURAL, not a scan: only these source subtrees are +// ever copied — selected grillers/lenses, `pharn-*` (non-`pharn-dev-*`) commands, +// `.cjs` hooks, settings.json, the trusted docs, pharn-contracts/, and +// `.dev/floor/` minus test files. `pharn-dev-*` commands, `.dev/features/`, +// `.dev/memory-bank/`, and `*.test.*` are NEVER in the copy set. +// +// One axis (P3): the capability copy routine. +// --------------------------------------------------------------------------- + +export interface InstallCapabilitiesResult { + // The capabilities actually copied (name + role), for pharn.config.json. + capabilities: InstalledCapability[]; + // True when the project already had .claude/settings.json — the install did + // NOT overwrite it (the user's Claude Code config is preserved). The caller + // surfaces this so the user knows the hooks may need wiring by hand. + settingsPreserved: boolean; +} + +const isTestFile = (p: string): boolean => /\.test\.(mjs|cjs)$/.test(p); + +/** + * Copy the resolved capabilities + the fixed product surfaces from `repoDir` + * into `projectRoot`. Pre-flights every selected capability source before any + * write (no partial installs). Returns the copied capability list + whether the + * user's existing settings.json was preserved. + */ +export function installCapabilities( + repoDir: string, + projectRoot: string, + selection: Selection, +): InstallCapabilitiesResult { + // --- pre-flight: resolve + validate every selected capability source ------- + const planned = selection.selected.map((cap) => { + assertSafeString(cap.name, `capability "${cap.name}"`, CAPABILITY_NAME_RE); + assertNoDotDot(cap.name, `capability "${cap.name}"`); + const subtree = cap.role === 'griller' ? GRILLERS_DIR : LENSES_DIR; + const from = safeJoin(repoDir, `${subtree}/${cap.name}`); + if (!existsSync(from)) { + throw new ManifestValidationError( + `Selected capability "${cap.name}" (${cap.role}) is missing at ${subtree}/${cap.name} in the fetched repo.`, + ); + } + return { name: cap.name, role: cap.role, subtree, from }; + }); + + // --- copy the selected capabilities (whole dir, incl. evals/) -------------- + const capabilities: InstalledCapability[] = []; + for (const cap of planned) { + const to = safeJoin(projectRoot, `${cap.subtree}/${cap.name}`); + cpSync(cap.from, to, { recursive: true, force: true }); + capabilities.push({ name: cap.name, role: cap.role }); + } + + // --- product commands: pharn-*.md, excluding pharn-dev-*.md ---------------- + copyFilteredDir(repoDir, projectRoot, CLAUDE_COMMANDS_DIR, (fileName) => { + if (!fileName.endsWith('.md')) return false; + if (fileName.startsWith(DEV_COMMAND_PREFIX)) return false; + return fileName.startsWith(PRODUCT_COMMAND_PREFIX); + }); + + // --- hooks: *.cjs, excluding *.test.cjs ------------------------------------ + copyFilteredDir(repoDir, projectRoot, CLAUDE_HOOKS_DIR, (fileName) => { + if (!fileName.endsWith('.cjs')) return false; + return !isTestFile(fileName); + }); + + // --- settings.json: NEVER overwrite the user's existing one (grill F1) ----- + const settingsFrom = safeJoin(repoDir, CLAUDE_SETTINGS_FILE); + const settingsTo = safeJoin(projectRoot, CLAUDE_SETTINGS_FILE); + const settingsPreserved = existsSync(settingsTo); + if (!settingsPreserved && existsSync(settingsFrom)) { + mkdirSync(safeJoin(projectRoot, '.claude'), { recursive: true }); + cpSync(settingsFrom, settingsTo, { force: true }); + } + + // --- trusted docs (root) --------------------------------------------------- + for (const doc of TRUSTED_DOCS) { + const from = safeJoin(repoDir, doc); + if (existsSync(from)) { + cpSync(from, safeJoin(projectRoot, doc), { force: true }); + } + } + + // --- pharn-contracts/ (whole dir) ------------------------------------------ + const contractsFrom = safeJoin(repoDir, CONTRACTS_DIR); + if (existsSync(contractsFrom)) { + cpSync(contractsFrom, safeJoin(projectRoot, CONTRACTS_DIR), { + recursive: true, + force: true, + }); + } + + // --- .dev/floor/ (checkers only — test files excluded) --------------------- + const floorFrom = safeJoin(repoDir, FLOOR_DIR); + if (existsSync(floorFrom)) { + cpSync(floorFrom, safeJoin(projectRoot, FLOOR_DIR), { + recursive: true, + force: true, + filter: (src) => !isTestFile(src), + }); + } + + return { capabilities, settingsPreserved }; +} + +/** + * Copy the files of one source dir whose basenames pass `keep` into the mirrored + * project-root dir. Each kept name is validated (COPY_FILENAME_RE + no `..`) + * before it is path-joined — a name that is a copy candidate but fails the + * allowlist hard-fails (P2), it is never silently skipped. Missing source dir is + * a no-op (nothing to copy). + */ +function copyFilteredDir( + repoDir: string, + projectRoot: string, + relDir: string, + keep: (fileName: string) => boolean, +): void { + const fromDir = safeJoin(repoDir, relDir); + if (!existsSync(fromDir)) return; + const entries = readdirSync(fromDir, { withFileTypes: true }); + let ensured = false; + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) continue; + if (!keep(entry.name)) continue; + assertSafeString(entry.name, `${relDir}/${entry.name}`, COPY_FILENAME_RE); + assertNoDotDot(entry.name, `${relDir}/${entry.name}`); + if (!ensured) { + mkdirSync(safeJoin(projectRoot, relDir), { recursive: true }); + ensured = true; + } + cpSync( + safeJoin(fromDir, entry.name), + safeJoin(projectRoot, `${relDir}/${entry.name}`), + { force: true }, + ); + } +} diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 9cef5ae..671288f 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -22,9 +22,67 @@ export const VENDOR_SOURCE_RE = /^[A-Za-z0-9@:/._#-]+$/; // strict + checked for '..' for defense in depth. export const PACKAGE_NAME_RE = /^(@[a-z0-9][a-z0-9-._]*\/)?[a-z0-9][a-z0-9-._]*$/; +// Capability directory basename (archetype install): lowercase alnum words joined +// by single hyphens, no leading/trailing/double hyphen (e.g. `a11y`, `n-plus-one`, +// `copy-paste-drift`). Tighter than WIZARD_VALUE_RE. Every capability name read +// from the fetched tree is validated with this before it is path-joined (P2). +export const CAPABILITY_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; +// A product command / hook filename copied from the fetched tree (validated +// before path-join). `pharn-plan.md`, `set-writes-scope.cjs`, etc. +export const COPY_FILENAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*\.(md|cjs|mjs|json)$/; +// Capability `role` frontmatter enum — the two installable kinds shipped today +// (ARCHITECTURE.md §3.1 role enum, narrowed consumer-side). +export const ROLE_VALUES = ['griller', 'lens'] as const; +// Capability `applies` frontmatter tokens: `universal` (→ always selected) or a +// project archetype (ssr|backend|spa|lib). Membership, not judgment (P5). +export const APPLIES_TOKEN_VALUES = [ + 'universal', + 'ssr', + 'backend', + 'spa', + 'lib', +] as const; // eslint-disable-next-line no-control-regex const CONTROL_CHARS_RE = /[\x00-\x1f\x7f-\x9f]/; +/** + * Assert a fetched capability `role` value is one of the installable kinds + * ({griller, lens}); hard-fail otherwise (P2/P5 — no silent trust of untrusted + * frontmatter). `label` names the offending capability in the error. + */ +export function assertRole( + value: unknown, + label: string, +): (typeof ROLE_VALUES)[number] { + if (typeof value !== 'string') { + throw new ManifestValidationError(`${label} role must be a string`); + } + const match = ROLE_VALUES.find((r) => r === value); + if (!match) { + throw new ManifestValidationError( + `${label} has invalid role ${JSON.stringify(value)} (expected one of ${ROLE_VALUES.join(', ')})`, + ); + } + return match; +} + +/** + * Assert a single fetched `applies` token is a known enum value + * ({universal, ssr, backend, spa, lib}); hard-fail otherwise (P2/P5). + */ +export function assertAppliesToken( + value: string, + label: string, +): (typeof APPLIES_TOKEN_VALUES)[number] { + const match = APPLIES_TOKEN_VALUES.find((t) => t === value); + if (!match) { + throw new ManifestValidationError( + `${label} has invalid applies value ${JSON.stringify(value)} (expected one of ${APPLIES_TOKEN_VALUES.join(', ')})`, + ); + } + return match; +} + export function assertSafeString( value: unknown, label: string, diff --git a/src/steps/archetype-summary.ts b/src/steps/archetype-summary.ts new file mode 100644 index 0000000..18c04d0 --- /dev/null +++ b/src/steps/archetype-summary.ts @@ -0,0 +1,68 @@ +import { isCancel, note, select } from '@clack/prompts'; +import { row } from '../lib/format.js'; +import type { Archetype, Selection } from '../types.js'; + +// The archetype-install summary + confirm stage (pharn init --archetype). Shows +// the deterministically-detected archetypes and the resolved capabilities +// (selected + skipped-with-reason), then asks whether to install. Mirrors +// steps/summary.ts. One axis (P3): the archetype summary + confirm stage. +// +// Returns 'cancel' (never process.exit) on both explicit cancel and a clack +// cancel, so the caller can clean up the fetched temp clone in a finally BEFORE +// exiting (Node skips finally on process.exit). + +export type ArchetypeSummaryAction = 'install' | 'cancel'; + +function describeMatched(matched: 'universal' | Archetype[]): string { + return matched === 'universal' ? 'universal' : matched.join(', '); +} + +export async function runArchetypeSummary( + archetypes: Archetype[], + selection: Selection, +): Promise { + const lines: string[] = [ + '────────────────────────────────────────', + ' PHARN Install Summary (archetype)', + '────────────────────────────────────────', + '', + row('Detected archetypes', archetypes.join(', ')), + '', + ` CAPABILITIES SELECTED (${selection.selected.length})`, + ...(selection.selected.length > 0 + ? selection.selected.map((c) => + row(`${c.name} (${c.role})`, describeMatched(c.matched)), + ) + : [' (none)']), + ]; + + if (selection.skipped.length > 0) { + lines.push( + '', + ` CAPABILITIES SKIPPED (${selection.skipped.length})`, + ...selection.skipped.map((c) => row(`${c.name} (${c.role})`, c.reason)), + ); + } + + lines.push( + '', + ' Installs PHARN product commands + write-gating hooks into .claude/', + ' (an existing .claude/settings.json is preserved, not overwritten).', + '', + '────────────────────────────────────────', + ); + + note(lines.join('\n')); + + const action = await select({ + message: 'Ready to install?', + initialValue: 'install' as ArchetypeSummaryAction, + options: [ + { value: 'install' as ArchetypeSummaryAction, label: 'Yes, install' }, + { value: 'cancel' as ArchetypeSummaryAction, label: 'Cancel' }, + ], + }); + + if (isCancel(action)) return 'cancel'; + return action; +} diff --git a/src/steps/install-archetype.ts b/src/steps/install-archetype.ts new file mode 100644 index 0000000..6b97555 --- /dev/null +++ b/src/steps/install-archetype.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { log, outro, spinner } from '@clack/prompts'; +import pc from 'picocolors'; +import { + DOCS_URL, + FIRST_FEATURE_COMMAND, + REPO_URL, + SKILLS_VERSION_FILE, +} from '../lib/constants.js'; +import { installCapabilities } from '../lib/install-capabilities.js'; +import { safeJoin } from '../lib/install-modules.js'; +import { writePharnConfig } from '../lib/pharn-config.js'; +import { + assertSafeString, + ManifestValidationError, + VERSION_RE, +} from '../lib/validate.js'; +import type { + Archetype, + InstalledCapability, + PharnConfig, + Selection, +} from '../types.js'; + +const require = createRequire(import.meta.url); +const pkg = require('../../package.json') as { version: string }; +const PHARN_VERSION = pkg.version; + +// The archetype-install apply stage (pharn init --archetype). Copies the +// resolved capabilities + product surfaces from the already-fetched `repoDir`, +// then writes pharn.config.json. Network-free (the commit SHA is passed in by +// the orchestrator) so it is exercised end-to-end by the fixture test. Mirrors +// steps/install.ts. One axis (P3): the archetype apply/config stage. +export async function runInstallArchetype( + repoDir: string, + cwd: string, + archetypes: Archetype[], + selection: Selection, + commit: string | null, +): Promise { + const startedAt = Date.now(); + + const s = spinner(); + s.start('Installing capabilities'); + let capabilities: InstalledCapability[]; + let settingsPreserved: boolean; + let skillsVersion: string; + try { + const result = installCapabilities(repoDir, cwd, selection); + capabilities = result.capabilities; + settingsPreserved = result.settingsPreserved; + skillsVersion = readSkillsVersion(repoDir); + } catch (err) { + // Stop the spinner and propagate — the orchestrator cleans up the fetched + // temp clone in its finally, then formats + exits (cleanup before exit). + s.stop('Failed to install capabilities'); + throw err; + } + s.stop('Capabilities installed'); + + if (settingsPreserved) { + log.warn( + 'Existing .claude/settings.json preserved — compare it against pharn-oss to wire the PHARN hooks if needed.', + ); + } + + const config: PharnConfig = { + pharnVersion: PHARN_VERSION, + skillsVersion, + repo: REPO_URL.replace(/^github\.com\//, ''), + commit, + // No constitution variant: the archetype install copies pharn-oss's canonical + // CONSTITUTION.md verbatim. No modules: capabilities are the install unit. + modules: [], + installedAt: new Date().toISOString(), + archetypes, + capabilities, + }; + await writePharnConfig(cwd, config); + + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + const check = pc.green('✔'); + const grillers = capabilities.filter((c) => c.role === 'griller').length; + const lenses = capabilities.filter((c) => c.role === 'lens').length; + outro( + [ + `${check} ${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'} installed → ${pc.dim(`(${grillers} griller${grillers === 1 ? '' : 's'}, ${lenses} lens${lenses === 1 ? '' : 'es'})`)}`, + `${check} PHARN commands + hooks + docs written → ${pc.dim('.claude/')}`, + `${check} pharn.config.json written ${pc.dim(`(skills v${skillsVersion}, archetypes: ${archetypes.join(', ')})`)}`, + `${pc.dim(`Done in ${elapsed}s`)}`, + '', + pc.bold('Next steps'), + ` ${pc.cyan('1.')} ${pc.bold('claude')} ${pc.dim('open Claude Code')}`, + ` ${pc.cyan('2.')} ${pc.bold(FIRST_FEATURE_COMMAND)} ${pc.dim('plan your first feature')}`, + '', + `${pc.bold('Docs')} ${pc.cyan(DOCS_URL)}`, + ].join('\n'), + ); +} + +/** + * Read + validate the fetched SKILLS_VERSION (untrusted, P2): trim, then check + * it is a semver string (VERSION_RE) before persisting. A missing file is an + * upstream packaging bug — surfaced, not defaulted. + */ +function readSkillsVersion(repoDir: string): string { + const svPath = safeJoin(repoDir, SKILLS_VERSION_FILE); + if (!existsSync(svPath)) { + throw new ManifestValidationError( + `${SKILLS_VERSION_FILE} is missing in the fetched repo.`, + ); + } + return assertSafeString( + readFileSync(svPath, 'utf8').trim(), + SKILLS_VERSION_FILE, + VERSION_RE, + ); +} diff --git a/src/types.ts b/src/types.ts index 1c9e75a..d6de5e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -156,12 +156,23 @@ export interface InstalledSkill { from: string; } +// Archetype install (pharn init --archetype): one selected capability copied into +// the project. `name` is the capability's directory basename (the copy target +// under pharn-pipeline/grillers/ or pharn-review/); `role` selects that subtree. +export interface InstalledCapability { + name: string; + role: 'griller' | 'lens'; +} + export interface PharnConfig { pharnVersion: string; skillsVersion: string; repo: string; commit: string | null; - constitution: Constitution; + // Legacy (module/wizard) installs record the chosen constitution variant. + // Optional: the archetype install (pharn init --archetype) copies pharn-oss's + // canonical CONSTITUTION.md verbatim (no variant selection), so it omits this. + constitution?: Constitution; // Whether the project is a multi-tenant SaaS. Written on every fresh install; // absent on legacy installs predating this flag (read as true → P2 kept). // When false, Principle 2 was stripped from CONSTITUTION.md at install. @@ -172,6 +183,11 @@ export interface PharnConfig { stackAnswers?: Record; installedSkills?: InstalledSkill[]; vendorSkills?: string[]; + // Archetype (capability) installs (pharn init --archetype). Additive; absent on + // legacy module installs. `modules` is [] for an archetype install; these record + // the detected archetypes and the capabilities copied for them. + archetypes?: Archetype[]; + capabilities?: InstalledCapability[]; } // --------------------------------------------------------------------------- diff --git a/tests/archetype-summary.test.ts b/tests/archetype-summary.test.ts new file mode 100644 index 0000000..0a1a8bf --- /dev/null +++ b/tests/archetype-summary.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CANCEL } from './helpers.js'; +import type { Archetype, Selection } from '../src/types.js'; + +vi.mock('@clack/prompts', () => ({ + isCancel: (v: unknown) => v === CANCEL, + note: vi.fn(), + select: vi.fn(), +})); + +const { runArchetypeSummary } = + await import('../src/steps/archetype-summary.js'); +const prompts = await import('@clack/prompts'); + +const archetypes: Archetype[] = ['ssr']; +const selection: Selection = { + selected: [ + { name: 'a11y', role: 'griller', matched: ['ssr'] }, + { name: 'security', role: 'griller', matched: 'universal' }, + ], + skipped: [ + { + name: 'migrations', + role: 'griller', + reason: 'applies to [backend]; detected [ssr]', + }, + ], +}; + +describe('runArchetypeSummary', () => { + it.each(['install', 'cancel'] as const)( + 'returns the %s action', + async (action) => { + vi.mocked(prompts.select).mockResolvedValue(action); + await expect(runArchetypeSummary(archetypes, selection)).resolves.toBe( + action, + ); + expect(prompts.note).toHaveBeenCalled(); + }, + ); + + it('returns cancel (never exits) when the prompt is cancelled', async () => { + vi.mocked(prompts.select).mockResolvedValue(CANCEL); + await expect(runArchetypeSummary(archetypes, selection)).resolves.toBe( + 'cancel', + ); + }); + + it('renders detected archetypes and selected/skipped capabilities', async () => { + vi.mocked(prompts.select).mockResolvedValue('install'); + await runArchetypeSummary(archetypes, selection); + const note = vi.mocked(prompts.note).mock.calls.at(-1)![0] as string; + expect(note).toContain('Detected archetypes'); + expect(note).toContain('ssr'); + expect(note).toContain('a11y (griller)'); + expect(note).toContain('security (griller)'); + expect(note).toContain('universal'); + expect(note).toContain('CAPABILITIES SKIPPED (1)'); + expect(note).toContain('migrations (griller)'); + }); + + it('omits the skipped block when nothing was skipped', async () => { + vi.mocked(prompts.select).mockResolvedValue('install'); + await runArchetypeSummary(archetypes, { + selected: selection.selected, + skipped: [], + }); + const note = vi.mocked(prompts.note).mock.calls.at(-1)![0] as string; + expect(note).not.toContain('CAPABILITIES SKIPPED'); + }); +}); diff --git a/tests/capability-index.test.ts b/tests/capability-index.test.ts new file mode 100644 index 0000000..c1ea699 --- /dev/null +++ b/tests/capability-index.test.ts @@ -0,0 +1,169 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { useTmpDir } from './helpers.js'; +import { parseCapabilityIndex } from '../src/lib/capability-index.js'; +import { ManifestValidationError } from '../src/lib/validate.js'; + +const GRILLERS = 'pharn-pipeline/grillers'; +const LENSES = 'pharn-review'; + +// A capability markdown with the three declared frontmatter fields. +function fm(role: string, applies: string): string { + return `---\nname: cap-name\nrole: ${role}\napplies: ${applies}\ncoupling: agnostic\n---\n\n# capability\n\nbody with a stray applies: ["ignored"] line in prose\n`; +} + +// Ensure both subtree roots exist (parseCapabilityIndex requires both), then let +// callers add capabilities. +function scaffold(repo: string): void { + mkdirSync(join(repo, GRILLERS), { recursive: true }); + mkdirSync(join(repo, LENSES), { recursive: true }); +} + +function writeCap( + repo: string, + subtree: string, + name: string, + body: string, +): void { + const dir = join(repo, subtree, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.md`), body); +} + +describe('parseCapabilityIndex', () => { + const tmp = useTmpDir(); + + it('derives a typed index from frontmatter (grillers then lenses, sorted)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'security', fm('griller', '["universal"]')); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '["ssr", "spa"]')); + writeCap(repo, LENSES, 'trust-fence', fm('lens', '["universal"]')); + writeCap(repo, LENSES, 'n-plus-one', fm('lens', '["backend", "ssr"]')); + + const index = parseCapabilityIndex(repo); + expect(index.capabilities).toEqual([ + { name: 'a11y', role: 'griller', applies: ['ssr', 'spa'] }, + { name: 'security', role: 'griller', applies: 'universal' }, + { name: 'n-plus-one', role: 'lens', applies: ['backend', 'ssr'] }, + { name: 'trust-fence', role: 'lens', applies: 'universal' }, + ]); + }); + + it('maps ["universal"] to the string, not an array (the resolver contract)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'security', fm('griller', '["universal"]')); + const [entry] = parseCapabilityIndex(repo).capabilities; + expect(entry!.applies).toBe('universal'); + }); + + it('is deterministic — identical result across repeated parses', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '["ssr", "spa"]')); + writeCap(repo, LENSES, 'n-plus-one', fm('lens', '["backend"]')); + expect(parseCapabilityIndex(repo)).toEqual(parseCapabilityIndex(repo)); + }); + + it('only reads the frontmatter block, never a field-looking prose line', () => { + const repo = tmp.path(); + scaffold(repo); + // fm() body contains a stray `applies: ["ignored"]` line in prose. + writeCap(repo, GRILLERS, 'a11y', fm('griller', '["ssr"]')); + const [entry] = parseCapabilityIndex(repo).capabilities; + expect(entry!.applies).toEqual(['ssr']); + }); + + it('hard-fails on an invalid role, naming the capability (P2/P5)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'weird', fm('auditor', '["universal"]')); + expect(() => parseCapabilityIndex(repo)).toThrow(ManifestValidationError); + expect(() => parseCapabilityIndex(repo)).toThrow(/weird/); + }); + + it('hard-fails when a role does not match its subtree', () => { + const repo = tmp.path(); + scaffold(repo); + // a lens frontmatter placed under the grillers subtree. + writeCap(repo, GRILLERS, 'misfiled', fm('lens', '["universal"]')); + expect(() => parseCapabilityIndex(repo)).toThrow(/misfiled/); + }); + + it('hard-fails on an unknown applies token (P2 enum)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '["mobile"]')); + expect(() => parseCapabilityIndex(repo)).toThrow(/mobile|invalid applies/); + }); + + it('hard-fails when universal is mixed with archetypes (P5)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '["universal", "ssr"]')); + expect(() => parseCapabilityIndex(repo)).toThrow(/mixes "universal"/); + }); + + it('hard-fails on an empty applies array', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '[]')); + expect(() => parseCapabilityIndex(repo)).toThrow(/empty "applies"/); + }); + + it('hard-fails on a non-array applies value', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', 'universal')); + expect(() => parseCapabilityIndex(repo)).toThrow(/malformed "applies"/); + }); + + it('hard-fails on a capability directory name outside the allowlist (P2)', () => { + const repo = tmp.path(); + scaffold(repo); + // Underscore is rejected by CAPABILITY_NAME_RE before any path-join. + writeCap(repo, GRILLERS, 'bad_name', fm('griller', '["universal"]')); + expect(() => parseCapabilityIndex(repo)).toThrow(ManifestValidationError); + }); + + it('hard-fails when the capability markdown is missing', () => { + const repo = tmp.path(); + scaffold(repo); + mkdirSync(join(repo, GRILLERS, 'empty'), { recursive: true }); + expect(() => parseCapabilityIndex(repo)).toThrow( + /empty.*missing its markdown/, + ); + }); + + it('hard-fails when frontmatter is absent', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', '# no frontmatter here\n'); + expect(() => parseCapabilityIndex(repo)).toThrow(/frontmatter/); + }); + + it('hard-fails when the applies field is missing', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', '---\nname: x\nrole: griller\n---\n# x\n'); + expect(() => parseCapabilityIndex(repo)).toThrow(/missing the "applies"/); + }); + + it('hard-fails when a required subtree directory is missing', () => { + const repo = tmp.path(); + mkdirSync(join(repo, GRILLERS), { recursive: true }); + // No pharn-review/ subtree at all. + expect(() => parseCapabilityIndex(repo)).toThrow(/pharn-review.*missing/); + }); + + it('accepts an empty subtree (no capabilities of that role)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'security', fm('griller', '["universal"]')); + // pharn-review exists but is empty. + const index = parseCapabilityIndex(repo); + expect(index.capabilities.map((c) => c.name)).toEqual(['security']); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index b9124c5..a0e6813 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -42,14 +42,21 @@ describe('main (argv dispatch)', () => { vi.restoreAllMocks(); }); - it('defaults to init when no command is given', async () => { + it('defaults to init (archetype:false) when no command is given', async () => { setArgv(); await main(); expect(runInit).toHaveBeenCalledTimes(1); + expect(runInit).toHaveBeenCalledWith({ archetype: false }); expect(runAdd).not.toHaveBeenCalled(); expect(runUpdate).not.toHaveBeenCalled(); }); + it('routes `init --archetype` to runInit with archetype:true', async () => { + setArgv('init', '--archetype'); + await main(); + expect(runInit).toHaveBeenCalledWith({ archetype: true }); + }); + it('routes `add ` to runAdd with the argument', async () => { setArgv('add', 'orm:prisma'); await main(); diff --git a/tests/init-archetype.test.ts b/tests/init-archetype.test.ts new file mode 100644 index 0000000..beec0eb --- /dev/null +++ b/tests/init-archetype.test.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { useTmpDir } from './helpers.js'; + +// runInstallArchetype uses clack for progress UI only — mock it so the fixture +// e2e exercises the copy + config-write apply path without a real terminal. +vi.mock('@clack/prompts', () => ({ + spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }), + outro: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const { detectArchetypesFromProject } = + await import('../src/lib/detect-archetype.js'); +const { parseCapabilityIndex } = await import('../src/lib/capability-index.js'); +const { resolveCapabilities } = + await import('../src/lib/resolve-capabilities.js'); +const { runInstallArchetype } = + await import('../src/steps/install-archetype.js'); +const { readPharnConfig } = await import('../src/lib/pharn-config.js'); + +function write(path: string, content = 'x'): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content); +} + +function cap(role: string, applies: string): string { + return `---\nname: c\nrole: ${role}\napplies: ${applies}\n---\n# c\n`; +} + +// A fake fetched pharn-oss clone: two grillers, two lenses (one backend-only, so +// it is skipped for an ssr project), the product + dev surfaces, and the root +// SKILLS_VERSION the archetype flow reads in place of a manifest. +function scaffoldRepo(repo: string): void { + write( + join(repo, 'pharn-pipeline/grillers/a11y/a11y.md'), + cap('griller', '["ssr", "spa"]'), + ); + write( + join(repo, 'pharn-pipeline/grillers/security/security.md'), + cap('griller', '["universal"]'), + ); + write( + join(repo, 'pharn-review/n-plus-one/n-plus-one.md'), + cap('lens', '["backend", "ssr"]'), + ); + write( + join(repo, 'pharn-review/path-traversal/path-traversal.md'), + cap('lens', '["backend"]'), + ); + write(join(repo, '.claude/commands/pharn-plan.md'), 'plan'); + write(join(repo, '.claude/commands/pharn-dev-plan.md'), 'DEV'); + write(join(repo, '.claude/hooks/enforce-writes-scope.cjs'), 'hook'); + write(join(repo, '.claude/settings.json'), '{"hooks":{}}'); + write(join(repo, 'CONSTITUTION.md'), 'C'); + write(join(repo, 'pharn-contracts/finding-shape.md'), 'fs'); + write(join(repo, '.dev/floor/validate.mjs'), 'floor'); + write(join(repo, '.dev/features/x/PLAN.md'), 'DEVPLAN'); + write(join(repo, 'SKILLS_VERSION'), '1.0.0\n'); +} + +describe('archetype install (fixture e2e)', () => { + const tmp = useTmpDir(); + + it('detect → resolve → install: correct capabilities, dev-only excluded, config written', async () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + scaffoldRepo(repo); + // Fixture project: a Next.js app → archetype ssr. + write( + join(proj, 'package.json'), + JSON.stringify({ dependencies: { next: '14.0.0' } }), + ); + + const { archetypes } = detectArchetypesFromProject(proj); + expect(archetypes).toEqual(['ssr']); + + const index = parseCapabilityIndex(repo); + const selection = resolveCapabilities(archetypes, index); + // ssr selects: a11y (ssr), security (universal), n-plus-one (ssr). Skips the + // backend-only path-traversal lens — the headline invariant. + expect(selection.selected.map((c) => c.name).sort()).toEqual([ + 'a11y', + 'n-plus-one', + 'security', + ]); + expect(selection.skipped.map((c) => c.name)).toEqual(['path-traversal']); + + await runInstallArchetype(repo, proj, archetypes, selection, 'sha123'); + + // Selected capabilities installed; the skipped backend-only lens is absent. + expect(existsSync(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'))).toBe( + true, + ); + expect( + existsSync(join(proj, 'pharn-review/n-plus-one/n-plus-one.md')), + ).toBe(true); + expect(existsSync(join(proj, 'pharn-review/path-traversal'))).toBe(false); + + // Product command present; dev-only command + feature tree excluded. + expect(existsSync(join(proj, '.claude/commands/pharn-plan.md'))).toBe(true); + expect(existsSync(join(proj, '.claude/commands/pharn-dev-plan.md'))).toBe( + false, + ); + expect(existsSync(join(proj, '.dev/floor/validate.mjs'))).toBe(true); + expect(existsSync(join(proj, '.dev/features'))).toBe(false); + + // pharn.config.json written with the archetype fields. + const config = readPharnConfig(proj); + expect(config).not.toBeNull(); + expect(config!.archetypes).toEqual(['ssr']); + expect(config!.skillsVersion).toBe('1.0.0'); + expect(config!.commit).toBe('sha123'); + expect(config!.modules).toEqual([]); + expect(config!.constitution).toBeUndefined(); + expect(config!.capabilities).toEqual([ + { name: 'a11y', role: 'griller' }, + { name: 'security', role: 'griller' }, + { name: 'n-plus-one', role: 'lens' }, + ]); + }); +}); diff --git a/tests/install-capabilities.test.ts b/tests/install-capabilities.test.ts new file mode 100644 index 0000000..ce03877 --- /dev/null +++ b/tests/install-capabilities.test.ts @@ -0,0 +1,200 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { useTmpDir } from './helpers.js'; +import { installCapabilities } from '../src/lib/install-capabilities.js'; +import { ManifestValidationError } from '../src/lib/validate.js'; +import type { Selection } from '../src/types.js'; + +function write(path: string, content = 'x'): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content); +} + +// A fake fetched pharn-oss clone with the full product + dev surface, so the +// copy's dev-only exclusions can be asserted. +function scaffoldRepo(repo: string): void { + // capabilities (grillers + lenses) + write(join(repo, 'pharn-pipeline/grillers/a11y/a11y.md'), 'a11y'); + write(join(repo, 'pharn-pipeline/grillers/a11y/evals/cases/c.md'), 'case'); + write(join(repo, 'pharn-pipeline/grillers/security/security.md'), 'sec'); + write(join(repo, 'pharn-review/n-plus-one/n-plus-one.md'), 'npo'); + write(join(repo, 'pharn-review/trust-fence/trust-fence.md'), 'tf'); + // product + dev commands + write(join(repo, '.claude/commands/pharn-plan.md'), 'plan'); + write(join(repo, '.claude/commands/pharn-ship.md'), 'ship'); + write(join(repo, '.claude/commands/pharn-dev-plan.md'), 'DEV'); + write(join(repo, '.claude/commands/README.md'), 'readme'); + // hooks (+ their tests, which are dev-only) + write(join(repo, '.claude/hooks/set-writes-scope.cjs'), 'hook'); + write(join(repo, '.claude/hooks/set-writes-scope.test.cjs'), 'HOOKTEST'); + // settings + write(join(repo, '.claude/settings.json'), '{"hooks":{}}'); + // trusted docs + write(join(repo, 'CONSTITUTION.md'), 'C'); + write(join(repo, 'ARCHITECTURE.md'), 'A'); + write(join(repo, 'THREAT-MODEL.md'), 'T'); + write(join(repo, 'LIMITS.md'), 'L'); + // contracts + write(join(repo, 'pharn-contracts/finding-shape.md'), 'fs'); + // floor (+ tests, dev-only) + dev-only trees + write(join(repo, '.dev/floor/validate.mjs'), 'floor'); + write(join(repo, '.dev/floor/validate.test.mjs'), 'FLOORTEST'); + write(join(repo, '.dev/features/some-feature/PLAN.md'), 'DEVPLAN'); + write(join(repo, '.dev/memory-bank/lessons-learned.md'), 'DEVMB'); +} + +function selection(): Selection { + return { + selected: [ + { name: 'a11y', role: 'griller', matched: ['ssr'] }, + { name: 'n-plus-one', role: 'lens', matched: ['ssr'] }, + ], + skipped: [ + { + name: 'security', + role: 'griller', + reason: 'not selected in this test', + }, + ], + }; +} + +describe('installCapabilities', () => { + const tmp = useTmpDir(); + + function run(): { + repo: string; + proj: string; + result: ReturnType; + } { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + const result = installCapabilities(repo, proj, selection()); + return { repo, proj, result }; + } + + it('copies the SELECTED capabilities (whole dir, incl. evals) and no others', () => { + const { proj } = run(); + expect(existsSync(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'))).toBe( + true, + ); + expect( + existsSync(join(proj, 'pharn-pipeline/grillers/a11y/evals/cases/c.md')), + ).toBe(true); + expect( + existsSync(join(proj, 'pharn-review/n-plus-one/n-plus-one.md')), + ).toBe(true); + // security griller was skipped → never copied. + expect(existsSync(join(proj, 'pharn-pipeline/grillers/security'))).toBe( + false, + ); + // trust-fence lens was not in the selection → never copied. + expect(existsSync(join(proj, 'pharn-review/trust-fence'))).toBe(false); + }); + + it('returns the copied capability list (name + role)', () => { + const { result } = run(); + expect(result.capabilities).toEqual([ + { name: 'a11y', role: 'griller' }, + { name: 'n-plus-one', role: 'lens' }, + ]); + }); + + it('copies product pharn-*.md commands but EXCLUDES pharn-dev-*.md and non-pharn files', () => { + const { proj } = run(); + expect(existsSync(join(proj, '.claude/commands/pharn-plan.md'))).toBe(true); + expect(existsSync(join(proj, '.claude/commands/pharn-ship.md'))).toBe(true); + expect(existsSync(join(proj, '.claude/commands/pharn-dev-plan.md'))).toBe( + false, + ); + expect(existsSync(join(proj, '.claude/commands/README.md'))).toBe(false); + }); + + it('copies .cjs hooks but EXCLUDES *.test.cjs', () => { + const { proj } = run(); + expect(existsSync(join(proj, '.claude/hooks/set-writes-scope.cjs'))).toBe( + true, + ); + expect( + existsSync(join(proj, '.claude/hooks/set-writes-scope.test.cjs')), + ).toBe(false); + }); + + it('copies the trusted docs and pharn-contracts', () => { + const { proj } = run(); + for (const doc of [ + 'CONSTITUTION.md', + 'ARCHITECTURE.md', + 'THREAT-MODEL.md', + 'LIMITS.md', + ]) { + expect(existsSync(join(proj, doc))).toBe(true); + } + expect(existsSync(join(proj, 'pharn-contracts/finding-shape.md'))).toBe( + true, + ); + }); + + it('copies .dev/floor checkers but EXCLUDES *.test.mjs, .dev/features and .dev/memory-bank', () => { + const { proj } = run(); + expect(existsSync(join(proj, '.dev/floor/validate.mjs'))).toBe(true); + expect(existsSync(join(proj, '.dev/floor/validate.test.mjs'))).toBe(false); + expect(existsSync(join(proj, '.dev/features'))).toBe(false); + expect(existsSync(join(proj, '.dev/memory-bank'))).toBe(false); + }); + + it('copies settings.json into a fresh project (settingsPreserved false)', () => { + const { proj, result } = run(); + expect(existsSync(join(proj, '.claude/settings.json'))).toBe(true); + expect(result.settingsPreserved).toBe(false); + }); + + it('PRESERVES an existing .claude/settings.json — never overwrites it (grill F1)', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + scaffoldRepo(repo); + write(join(proj, '.claude/settings.json'), '{"USER":"config"}'); + const result = installCapabilities(repo, proj, selection()); + expect(result.settingsPreserved).toBe(true); + expect(existsSync(join(proj, '.claude/settings.json'))).toBe(true); + // The user's content is intact. + expect(readFileSync(join(proj, '.claude/settings.json'), 'utf8')).toContain( + 'USER', + ); + }); + + it('rejects a selected capability whose name escapes the base (P2 safeJoin)', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + const evil: Selection = { + selected: [{ name: '../escape', role: 'griller', matched: ['ssr'] }], + skipped: [], + }; + expect(() => installCapabilities(repo, proj, evil)).toThrow( + ManifestValidationError, + ); + }); + + it('fails pre-flight (nothing written) when a selected source is missing', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + const missing: Selection = { + selected: [{ name: 'ghost', role: 'lens', matched: ['ssr'] }], + skipped: [], + }; + expect(() => installCapabilities(repo, proj, missing)).toThrow( + /ghost.*missing/, + ); + // Pre-flight ran before any copy: no product commands leaked in. + expect(existsSync(join(proj, '.claude/commands/pharn-plan.md'))).toBe( + false, + ); + }); +}); diff --git a/tests/pharn-config.test.ts b/tests/pharn-config.test.ts index 508ede1..e813d38 100644 --- a/tests/pharn-config.test.ts +++ b/tests/pharn-config.test.ts @@ -74,4 +74,34 @@ describe('pharn-config', () => { expect(read).toEqual(v2); expect(read?.stackAnswers?.payments).toBe('skip'); }); + + it('still reads a legacy config that predates the archetype fields (P7)', async () => { + // `sample` is a legacy module install (constitution + modules, no archetypes). + await writePharnConfig(tmp.path(), sample); + const read = readPharnConfig(tmp.path()); + expect(read?.constitution).toBe('standard'); + expect(read?.archetypes).toBeUndefined(); + expect(read?.capabilities).toBeUndefined(); + }); + + it('round-trips an archetype install config (no constitution, modules:[], capabilities)', async () => { + const archetypeConfig: PharnConfig = { + pharnVersion: '0.2.0', + skillsVersion: '1.0.0', + repo: 'pharn-dev/pharn-oss', + commit: 'sha123', + modules: [], + installedAt: '2026-07-07T00:00:00.000Z', + archetypes: ['ssr', 'backend'], + capabilities: [ + { name: 'a11y', role: 'griller' }, + { name: 'n-plus-one', role: 'lens' }, + ], + }; + await writePharnConfig(tmp.path(), archetypeConfig); + const read = readPharnConfig(tmp.path()); + expect(read).toEqual(archetypeConfig); + // No constitution variant is recorded for an archetype install. + expect(read?.constitution).toBeUndefined(); + }); }); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index b39a71b..4dab8c9 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -3,8 +3,12 @@ import { ManifestValidationError, MODULE_NAME_RE, INSTALL_PATH_RE, + CAPABILITY_NAME_RE, + COPY_FILENAME_RE, assertSafeString, assertNoDotDot, + assertRole, + assertAppliesToken, isPlainObject, } from '../src/lib/validate.js'; @@ -74,3 +78,60 @@ describe('isPlainObject', () => { expect(isPlainObject('x')).toBe(false); }); }); + +describe('CAPABILITY_NAME_RE', () => { + it('accepts lowercase hyphenated capability names', () => { + for (const n of ['a11y', 'security', 'n-plus-one', 'copy-paste-drift']) { + expect(CAPABILITY_NAME_RE.test(n)).toBe(true); + } + }); + + it('rejects uppercase, underscores, leading/trailing/double hyphens, dots', () => { + for (const n of ['Bad', 'bad_name', '-x', 'x-', 'a--b', 'a.b', '..']) { + expect(CAPABILITY_NAME_RE.test(n)).toBe(false); + } + }); +}); + +describe('COPY_FILENAME_RE', () => { + it('accepts product command / hook / floor filenames', () => { + for (const n of [ + 'pharn-plan.md', + 'set-writes-scope.cjs', + 'validate.mjs', + 'map.json', + ]) { + expect(COPY_FILENAME_RE.test(n)).toBe(true); + } + }); + + it('rejects traversal, unexpected extensions, and empty names', () => { + for (const n of ['../evil.md', 'x.sh', 'a..b.md', '.env']) { + expect(COPY_FILENAME_RE.test(n)).toBe(false); + } + }); +}); + +describe('assertRole', () => { + it('returns griller/lens unchanged', () => { + expect(assertRole('griller', 'x')).toBe('griller'); + expect(assertRole('lens', 'x')).toBe('lens'); + }); + + it('throws (naming the capability) on any other role', () => { + expect(() => assertRole('auditor', 'a11y')).toThrow(/a11y/); + expect(() => assertRole(42, 'a11y')).toThrow(ManifestValidationError); + }); +}); + +describe('assertAppliesToken', () => { + it('accepts universal and every archetype', () => { + for (const t of ['universal', 'ssr', 'backend', 'spa', 'lib'] as const) { + expect(assertAppliesToken(t, 'x')).toBe(t); + } + }); + + it('throws on an unknown token', () => { + expect(() => assertAppliesToken('mobile', 'a11y')).toThrow(/a11y/); + }); +}); From 6bcf2f86a9c900fd53c7baff49cfbf6922b9a589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Tue, 7 Jul 2026 19:07:14 +0200 Subject: [PATCH 2/4] feat: make pharn list archetype-aware for capability installs Branch list on isArchetypeConfig to render installed archetypes and capabilities offline without a manifest fetch, with human and --json output and tests. Co-authored-by: Cursor --- .dev/features/list-archetype-aware/PLAN.md | 90 ++++++++++++++++++++++ .pharn/writes-scope.json | 9 ++- src/commands/list.ts | 68 +++++++++++++++- src/lib/pharn-config.ts | 13 ++++ tests/list.test.ts | 54 ++++++++++++- tests/pharn-config.test.ts | 22 ++++++ 6 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 .dev/features/list-archetype-aware/PLAN.md diff --git a/.dev/features/list-archetype-aware/PLAN.md b/.dev/features/list-archetype-aware/PLAN.md new file mode 100644 index 0000000..de65eac --- /dev/null +++ b/.dev/features/list-archetype-aware/PLAN.md @@ -0,0 +1,90 @@ +# PLAN — list-archetype-aware (shared discriminator + archetype-aware `pharn list`) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 — sha256 of ARCHITECTURE.md, read this run +- increment: Add a shared `isArchetypeConfig` discriminator and make `pharn list` render an archetype install's capabilities/archetypes **offline** (no manifest fetch) instead of crashing — **slice 1 of 5** toward full sibling-command archetype parity. +- layer(s): **product** (`pharn-cli` CLI, `src/`). +- constitution_refs: [P3, P4, P5, P6, P7] + +## Context (this run, live reads) + +- The just-built (uncommitted) `pharn init --archetype` writes a config with `capabilities[]` + + `archetypes[]` + `modules: []` + no `constitution`. **Live upstream has no `manifest.json`**, so the + sibling commands crash: `list` (`src/commands/list.ts:37` → `loadManifest` → `fetchRemoteManifest`), + `status` (`:49`/`:80` fetch/`readManifest`), `update` (`:39` fetch) all die at the manifest read; and + `config.modules` is `[]` so even a mocked manifest yields an empty, misleading render. +- **Decomposition (proposed; slice 1 built here, 2–5 are follow-ups, confirm at HALT):** + 1. **`list-archetype-aware` (THIS)** — the shared `isArchetypeConfig` discriminator + `list` renders + installed capabilities/archetypes offline. Smallest, read-only, self-contained (no clone). + 2. `status-archetype-aware` — version (via `SKILLS_VERSION`, not manifest) + capability-aware drift + (`diffInstalled` already diffs skills; extend to capabilities). + 3. `update-archetype-aware` — re-fetch, re-resolve capabilities against current archetypes + frontmatter, + re-copy, rewrite config. + 4. `add-archetype-aware` — `add :` (or `add `) installs one capability. + 5. `remove-archetype-aware` — remove one installed capability dir + drop its config entry. + +## Files + +- `src/lib/pharn-config.ts` — **EDIT (additive)** — add `isArchetypeConfig(config: PharnConfig): boolean`, + a pure deterministic membership test (`Array.isArray(config.capabilities)` — the archetype install + always writes `capabilities`; a legacy module config never does). One axis (config read/shape helpers) + unchanged. Layer product. +- `src/commands/list.ts` — **EDIT** — right after `readPharnConfig` and **before** `loadManifest`, branch + on `isArchetypeConfig`: render an **archetype inventory** (installed grillers/lenses grouped by role + + detected `archetypes` + `skillsVersion`) built from the **config alone** (no manifest fetch, no clone — + cannot crash on the missing manifest). `--json` emits an archetype-shaped inventory carrying a + `mode: 'archetype'` discriminator; the human render shows the capabilities + a dim note that + cross-checking against upstream (available/updatable capabilities) arrives with the `status`/`update` + slices (P4 — labeled, not silently missing). The legacy (module) path is **byte-unchanged**. Axis (the + `list` verb) unchanged — P3. +- `tests/list.test.ts` — **EDIT** — archetype config → renders installed capabilities + archetypes, makes + **no** manifest fetch (assert `fetchRemoteManifest` not called / no crash), `--json` emits + `mode:'archetype'` with the capabilities; the legacy module path still renders as before (regression + guard). Layer product (vitest = the spec, P1). +- `tests/pharn-config.test.ts` — **EDIT** — `isArchetypeConfig`: true for a `capabilities`-bearing config, + false for a legacy module config and for a `capabilities`-absent config. + +Nothing else is touched. `status`/`update`/`add`/`remove` stay **byte-unchanged** this increment (they +still error on an archetype config until their own slices land — an honest, labeled limitation, P7, not a +silent regression: they were already non-functional against live upstream). + +## Contracts satisfied + +- **`ARCHITECTURE.md §5`** (archetype + capability model) — `list` becomes the first read surface over an + archetype install's recorded capabilities/archetypes. Cited, not restated (P4). +- **CLI-owned `pharn.config.json` schema** — `isArchetypeConfig` reads the additive `capabilities` field + this CLI owns (`ARCHITECTURE.md §4` ownership boundary; the shape was defined in `init-install-capabilities`). + +## Evals to write (P1 — vitest is the spec) + +- `isArchetypeConfig` → `{capabilities:[…]}` ⇒ true; legacy `{modules:[…], constitution}` ⇒ false; + `{modules:[]}` with no `capabilities` ⇒ false (empty modules alone is NOT the marker). +- `runList` archetype config ⇒ renders each installed capability (name + role) + `archetypes` + + `skillsVersion`; **`fetchRemoteManifest` is never called**; no throw. +- `runList --json` archetype config ⇒ a `mode:'archetype'` object listing the capabilities (stdout pure). +- `runList` legacy config ⇒ unchanged module/skill render (the fetch path still taken) — regression guard. + +## Guarantee audit (P0) + +- **"`list` no longer crashes on an archetype install"** → **floor: membership test (`isArchetypeConfig`) + routes before the fetch** + vitest asserting no fetch + no throw. Deterministic branch (P5). +- **"`isArchetypeConfig` is deterministic"** → **floor: pure `Array.isArray` membership + vitest.** +- **"the archetype `list` output is complete/authoritative vs upstream"** → **NOT claimed — advisory / + Coming soon.** Slice 1 is an **offline** inventory from the config; cross-checking installed vs upstream + (available/updatable) is explicitly deferred to the `status`/`update` slices and **labeled** in the + output (P4/P7). No guarantee is sold over the deferred part. + +## Trust audit (P2) + +No new untrusted ingestion: slice 1 reads only the **local** `pharn.config.json` (this CLI's own schema) +and renders it. It makes **no** network fetch and **no** clone on the archetype path, so no fetched +frontmatter/file is parsed here (that boundary stays in `capability-index.ts`/`install-capabilities.ts` +from the prior increment). `readPharnConfig`'s existing light shape-guard still applies. + +## Open questions (RESOLVED at GATE 1) + +1. **Decomposition + slice-1 scope** — RESOLVED (human approved via "continue"): slice 1 = the shared + discriminator + archetype-aware `list` only; `status`/`update`/`add`/`remove` are separate follow-up + increments. +2. **Archetype `list` "available" section** — RESOLVED (installed-only, offline): slice 1 shows installed + capabilities + archetypes from the config alone (no clone) + a Coming-soon note for the upstream + cross-check. diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 8ada267..6a71353 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,10 @@ { "scope": [ - ".dev/features/init-install-capabilities/SHIP.md" + "src/lib/pharn-config.ts", + "src/commands/list.ts", + "tests/list.test.ts", + "tests/pharn-config.test.ts" ], - "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-07-07T16:37:30.021Z" + "set_by": ".dev/features/list-archetype-aware/PLAN.md", + "set_at": "2026-07-07T17:02:29.000Z" } diff --git a/src/commands/list.ts b/src/commands/list.ts index df18289..d151f4f 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -3,8 +3,8 @@ import pc from 'picocolors'; import { categorizeModules, fetchRemoteManifest } from '../lib/manifest.js'; import { listSkillAddresses } from '../lib/wizard.js'; import { row, shortDescription } from '../lib/format.js'; -import { readPharnConfig } from '../lib/pharn-config.js'; -import type { Manifest, PharnConfig } from '../types.js'; +import { isArchetypeConfig, readPharnConfig } from '../lib/pharn-config.js'; +import type { Archetype, Manifest, PharnConfig } from '../types.js'; // A single, JSON-serializable snapshot of what's installed vs. available. Both // the human renderer and `--json` derive from this, so the two never disagree. @@ -21,6 +21,16 @@ interface ListInventory { }; } +// Archetype (capability) install inventory — the offline view for an +// `isArchetypeConfig` project (no modules/skills, no manifest). `mode` +// discriminates it from the module ListInventory in `--json` output. +interface ArchetypeInventory { + mode: 'archetype'; + skillsVersion: string; + archetypes: Archetype[]; + capabilities: { name: string; role: 'griller' | 'lens' }[]; +} + // Read-only: shows installed + available modules/skills. Never writes, never // clones — only the lightweight manifest fetch that add/update already do. export async function runList(opts: { json?: boolean } = {}): Promise { @@ -34,6 +44,21 @@ export async function runList(opts: { json?: boolean } = {}): Promise { process.exit(1); } + // Archetype (capability) install: render offline from the config — no manifest + // fetch (live pharn-oss has none) and no clone, so it cannot crash on the + // missing manifest. Cross-checking installed vs. upstream (available/updatable + // capabilities) arrives with the status/update archetype slices (labeled in the + // render, P4/P7). The legacy module path below is unchanged. + if (isArchetypeConfig(config)) { + const inventory = buildArchetypeInventory(config); + if (json) { + console.log(JSON.stringify(inventory, null, 2)); + return; + } + renderArchetypeHuman(inventory); + return; + } + const manifest = await loadManifest(json); const inventory = buildInventory(config, manifest); @@ -100,6 +125,45 @@ function buildInventory( }; } +function buildArchetypeInventory(config: PharnConfig): ArchetypeInventory { + return { + mode: 'archetype', + skillsVersion: config.skillsVersion, + archetypes: config.archetypes ?? [], + capabilities: (config.capabilities ?? []).map((c) => ({ + name: c.name, + role: c.role, + })), + }; +} + +function renderArchetypeHuman(inv: ArchetypeInventory): void { + const grillers = inv.capabilities.filter((c) => c.role === 'griller'); + const lenses = inv.capabilities.filter((c) => c.role === 'lens'); + const lines: string[] = [ + row('Skills version', `v${inv.skillsVersion}`), + row('Archetypes', inv.archetypes.join(', ') || '(none)'), + '', + ' CAPABILITIES', + ]; + if (inv.capabilities.length === 0) { + lines.push(' (none)'); + } else { + if (grillers.length > 0) { + lines.push(row(' grillers', grillers.map((c) => c.name).join(', '))); + } + if (lenses.length > 0) { + lines.push(row(' lenses', lenses.map((c) => c.name).join(', '))); + } + } + note(lines.join('\n'), 'INSTALLED (archetype)'); + outro( + pc.dim( + 'Read-only — nothing changed. Available/updatable capabilities: coming with `pharn status`/`update` archetype support.', + ), + ); +} + function renderHuman(inv: ListInventory, hasWizard: boolean): void { const skillsLine = row('Skills version', `v${inv.skillsVersion}`); const installed: string[] = [ diff --git a/src/lib/pharn-config.ts b/src/lib/pharn-config.ts index f638655..11eb5ee 100644 --- a/src/lib/pharn-config.ts +++ b/src/lib/pharn-config.ts @@ -43,3 +43,16 @@ export function toInstalledModules( ): InstalledModule[] { return modules.map(({ name, version }) => ({ name, version })); } + +/** + * Is this an archetype (capability) install vs. a legacy module install? + * Deterministic membership (P5): the archetype install (`pharn init --archetype`) + * always writes a `capabilities` array; a legacy module config never does — so + * the presence of `capabilities` is the marker. An empty `modules: []` alone is + * NOT the marker (a module install can legitimately resolve to few modules). + * Sibling commands branch on this to avoid the module/manifest path (which fails + * against live pharn-oss, having no manifest.json) for archetype installs. + */ +export function isArchetypeConfig(config: PharnConfig): boolean { + return Array.isArray(config.capabilities); +} diff --git a/tests/list.test.ts b/tests/list.test.ts index 191a706..1f5d84c 100644 --- a/tests/list.test.ts +++ b/tests/list.test.ts @@ -52,7 +52,11 @@ vi.mock('../src/lib/manifest.js', () => ({ })); const readPharnConfig = vi.fn(); -vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig })); +vi.mock('../src/lib/pharn-config.js', () => ({ + readPharnConfig, + // Real discriminator logic so the archetype branch is exercised faithfully. + isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), +})); const { runList } = await import('../src/commands/list.js'); const prompts = await import('@clack/prompts'); @@ -201,6 +205,31 @@ describe('runList', () => { expect(available).not.toContain('orm:'); }); + it('renders an archetype install offline, WITHOUT fetching the manifest', async () => { + readPharnConfig.mockReturnValue( + config([], { + skillsVersion: '1.0.0', + archetypes: ['ssr'], + capabilities: [ + { name: 'a11y', role: 'griller' }, + { name: 'security', role: 'griller' }, + { name: 'n-plus-one', role: 'lens' }, + ], + }), + ); + + await expect(runList()).resolves.toBeUndefined(); + + // The crash point (manifest fetch) is never reached for an archetype config. + expect(fetchRemoteManifest).not.toHaveBeenCalled(); + const installed = noteBody('INSTALLED (archetype)'); + expect(installed).toContain('ssr'); + expect(installed).toContain('a11y'); + expect(installed).toContain('security'); + expect(installed).toContain('n-plus-one'); + expect(installed).toContain('v1.0.0'); + }); + it('handles a schemaVersion 1 manifest with no skills section', async () => { const manifest: Manifest = { schemaVersion: 1, @@ -278,6 +307,29 @@ describe('runList --json', () => { expect(addrs).not.toContain('orm:prisma'); }); + it('emits an archetype-mode JSON object with no manifest fetch', async () => { + readPharnConfig.mockReturnValue( + config([], { + skillsVersion: '1.0.0', + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], + }), + ); + + await runList({ json: true }); + + expect(fetchRemoteManifest).not.toHaveBeenCalled(); + expect(prompts.note).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(logSpy.mock.calls[0]![0] as string); + expect(payload).toEqual({ + mode: 'archetype', + skillsVersion: '1.0.0', + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], + }); + }); + it('keeps stdout clean and exits(1) when there is no config', async () => { readPharnConfig.mockReturnValue(null); await expect(runList({ json: true })).rejects.toMatchObject( diff --git a/tests/pharn-config.test.ts b/tests/pharn-config.test.ts index e813d38..dad85e3 100644 --- a/tests/pharn-config.test.ts +++ b/tests/pharn-config.test.ts @@ -6,6 +6,7 @@ import { readPharnConfig, writePharnConfig, toInstalledModules, + isArchetypeConfig, } from '../src/lib/pharn-config.js'; import type { PharnConfig } from '../src/types.js'; @@ -105,3 +106,24 @@ describe('pharn-config', () => { expect(read?.constitution).toBeUndefined(); }); }); + +describe('isArchetypeConfig', () => { + it('is true for a capabilities-bearing config', () => { + expect( + isArchetypeConfig({ + ...sample, + modules: [], + capabilities: [{ name: 'a11y', role: 'griller' }], + }), + ).toBe(true); + }); + + it('is false for a legacy module config', () => { + expect(isArchetypeConfig(sample)).toBe(false); + }); + + it('is false when capabilities is absent, even with empty modules', () => { + // Empty modules alone is NOT the marker — only a `capabilities` array is. + expect(isArchetypeConfig({ ...sample, modules: [] })).toBe(false); + }); +}); From c1974894f624646665b7f55ec2d223610363fac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Tue, 7 Jul 2026 20:41:35 +0200 Subject: [PATCH 3/4] feat: make status/update/add/remove archetype-aware Slices 2-5 of sibling-command parity for archetype installs (pharn init --archetype). Each command now branches on isArchetypeConfig and operates on the capability-shaped config instead of crashing on the absent manifest: - status: version via SKILLS_VERSION + byte-level capability drift (diffInstalledCapabilities mirrors installCapabilities) - update: re-resolve recorded archetypes against the latest index, re-copy, rewrite config - add: `add ` / `:` installs one capability - remove: isolated-dir delete + drop the config entry (no clone) Shared plumbing extracted (DRY, no command->command imports): lib/skills-version.ts, lib/capability-address.ts, installCapabilityDirs, diffInstalledCapabilities. npm run check green (487 tests); 0 regressions outside the feature. Co-Authored-By: Claude Opus 4.8 --- .dev/features/add-archetype-aware/PLAN.md | 55 ++++++++ .dev/features/remove-archetype-aware/PLAN.md | 54 ++++++++ .dev/features/status-archetype-aware/PLAN.md | 59 +++++++++ .dev/features/update-archetype-aware/PLAN.md | 49 +++++++ .pharn/writes-scope.json | 13 +- src/commands/add.ts | 127 +++++++++++++++++++ src/commands/remove.ts | 100 ++++++++++++++- src/commands/status.ts | 100 ++++++++++++++- src/commands/update.ts | 121 +++++++++++++++++- src/lib/capability-address.ts | 28 ++++ src/lib/diff.ts | 86 ++++++++++++- src/lib/install-capabilities.ts | 37 ++++-- src/lib/skills-version.ts | 67 ++++++++++ src/steps/install-archetype.ts | 34 +---- tests/add.test.ts | 112 ++++++++++++++++ tests/capability-address.test.ts | 26 ++++ tests/diff.test.ts | 80 +++++++++++- tests/install-capabilities.test.ts | 43 ++++++- tests/remove.test.ts | 87 +++++++++++++ tests/skills-version.test.ts | 69 ++++++++++ tests/status.test.ts | 81 +++++++++++- tests/update.test.ts | 95 ++++++++++++++ 22 files changed, 1465 insertions(+), 58 deletions(-) create mode 100644 .dev/features/add-archetype-aware/PLAN.md create mode 100644 .dev/features/remove-archetype-aware/PLAN.md create mode 100644 .dev/features/status-archetype-aware/PLAN.md create mode 100644 .dev/features/update-archetype-aware/PLAN.md create mode 100644 src/lib/capability-address.ts create mode 100644 src/lib/skills-version.ts create mode 100644 tests/capability-address.test.ts create mode 100644 tests/skills-version.test.ts diff --git a/.dev/features/add-archetype-aware/PLAN.md b/.dev/features/add-archetype-aware/PLAN.md new file mode 100644 index 0000000..56df004 --- /dev/null +++ b/.dev/features/add-archetype-aware/PLAN.md @@ -0,0 +1,55 @@ +# PLAN — add-archetype-aware (slice 4/5: archetype-aware `pharn add`) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: `pharn add ` / `add :` installs one capability into an archetype project (a manual override of archetype auto-selection), instead of crashing on the absent manifest. +- layer(s): product (`src/`). +- constitution_refs: [P2, P3, P5, P6, P7] + +## Files + +- `src/lib/install-capabilities.ts` — **EDIT (DRY)** — extract + export `installCapabilityDirs(repoDir, + projectRoot, capabilities)`: the pre-flight + per-capability dir copy (validated name + safeJoin), WITHOUT + the fixed product surfaces. `installCapabilities` calls it for its capability step (behavior unchanged). + Gives `add`/`remove` a focused primitive. +- `src/commands/add.ts` — **EDIT** — branch on `isArchetypeConfig` **before** `loadManifest` → `runArchetypeAdd`: + require an arg (`` or `:`); `fetchRepo` → `parseCapabilityIndex` → resolve the arg to a + capability (`role:name` explicit, else search by name; ambiguous → ask for `role:name`; unknown → list valid + addresses); already-installed → no-op; else `installCapabilityDirs` the one cap → append to + `config.capabilities` (never touching `archetypes`) → rewrite config (skillsVersion via `readSkillsVersion`, + commit). Legacy module/skill paths unchanged. +- `tests/install-capabilities.test.ts` — **EDIT** — `installCapabilityDirs` copies only the named cap dir(s), + no product surfaces; rejects a `..` name (safeJoin). +- `tests/add.test.ts` — **EDIT** — archetype add: `add a11y` installs + appends config; unknown → exits with + the valid list; already-installed → no-op; legacy path unchanged (regression guard). + +## Contracts satisfied + +- **`ARCHITECTURE.md §5`** — `add` is the manual capability-install override for an archetype project. Cited (P4). + +## Evals to write (P1) + +- `installCapabilityDirs` ⇒ copies `pharn-review//…`, does NOT write `.claude/commands/*`; `..` name throws. +- archetype `add a11y` ⇒ `installCapabilityDirs` called with the resolved cap; config.capabilities appended; + config.archetypes untouched. +- archetype `add unknown` ⇒ exit 1 listing valid capabilities. +- archetype `add ` ⇒ no-op outro, no copy. +- legacy `add ` ⇒ unchanged. + +## Guarantee audit (P0) + +- **"add no longer crashes on an archetype install"** → floor: `isArchetypeConfig` membership routes before + the manifest path + vitest. +- **"the added capability is path-contained"** → floor: `CAPABILITY_NAME_RE` + `safeJoin` in + `installCapabilityDirs` + vitest (P2). +- **"resolution is deterministic"** → floor: exact `name`/`role:name` membership over the validated index; + ambiguous/unknown terminal fallback is **ask/list**, never a guess (P5). + +## Trust audit (P2) + +Reuses the `capability-index` parse boundary (validated index) + `installCapabilityDirs` (validated name + +safeJoin). The user-supplied `arg` is matched against index names (membership), never path-joined raw. + +## Open questions (RESOLVED — pre-authorized) + +- Addressing = `` (unique) or `:` (disambiguation); `add` is a manual override that appends + to `capabilities` and never edits `archetypes`. diff --git a/.dev/features/remove-archetype-aware/PLAN.md b/.dev/features/remove-archetype-aware/PLAN.md new file mode 100644 index 0000000..f2c1624 --- /dev/null +++ b/.dev/features/remove-archetype-aware/PLAN.md @@ -0,0 +1,54 @@ +# PLAN — remove-archetype-aware (slice 5/5: archetype-aware `pharn remove`) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: `pharn remove ` / `remove :` deletes one installed capability dir (no clone, no network) and drops it from config — mirroring the isolated skill-remove — instead of crashing on the absent manifest. +- layer(s): product (`src/`). +- constitution_refs: [P2, P3, P5, P6, P7] + +## Files + +- `src/lib/capability-address.ts` — **NEW** — `parseCapabilityArg(arg): { name; role?; error? }` — the shared + `` / `:` parser (P3/DRY; `add` + `remove` both use it, no command→command import). +- `src/commands/add.ts` — **EDIT (DRY)** — use `parseCapabilityArg` from the new lib; drop the private + `parseCapArg` (behavior unchanged). +- `src/commands/remove.ts` — **EDIT** — branch on `isArchetypeConfig` **before** the skill/module dispatch → + `removeCapability`: no-arg → picker over installed capabilities; resolve arg against `config.capabilities` + (ambiguous → ask for `role:name`; not installed → no-op listing installed); delete the isolated dir + (`pharn-pipeline/grillers//` or `pharn-review//`) via `safeJoin`+`rmSync`; drop its + `capabilities` entry (never touching `archetypes`/`modules`). No clone, no network — mirrors + `removeSkill`. Legacy module/skill paths unchanged. +- `tests/capability-address.test.ts` — **NEW** — `parseCapabilityArg` name / role:name / bad-role. +- `tests/remove.test.ts` — **EDIT** — archetype remove: deletes the dir + drops config; not-installed no-op; + ambiguous errors; `..`/escape rejected (safeJoin); legacy paths unchanged (regression guard). + +## Contracts satisfied + +- **`ARCHITECTURE.md §5`** — `remove` is the inverse of `add` for one capability; isolated-dir delete (no + merge, so no pruning subtlety). Cited (P4). + +## Evals to write (P1) + +- `parseCapabilityArg` ⇒ `a11y` → `{name:'a11y'}`; `lens:n-plus-one` → `{name,role:'lens'}`; `x:y` bad role → `{error}`. +- archetype `remove a11y` ⇒ deletes `pharn-pipeline/grillers/a11y/`, drops the config entry, leaves archetypes. +- `remove ` ⇒ no-op listing installed, no config write. +- ambiguous name (installed in both roles) ⇒ exit 1 asking for `role:name`. +- legacy `remove ` / `` ⇒ unchanged. + +## Guarantee audit (P0) + +- **"remove no longer crashes on an archetype install"** → floor: `isArchetypeConfig` membership routes + before the manifest path + vitest. +- **"the delete is path-contained"** → floor: `CAPABILITY_NAME_RE` + `safeJoin` before `rmSync` + vitest (P2). +- **"resolution is deterministic"** → floor: exact `name`/`role:name` membership over `config.capabilities`; + ambiguous/unknown terminal fallback is **ask/list**, never a guess (P5). + +## Trust audit (P2) + +Reads only the local config + filesystem (no network). The user `arg` is matched against +`config.capabilities` (membership) and the deleted path is derived from the validated stored `name` + role, +`safeJoin`-guarded — the raw arg is never path-joined. + +## Open questions (RESOLVED — pre-authorized) + +- Capability remove is confirmation-free (mirrors `removeSkill`; isolated + re-addable) and touches only + `capabilities`. diff --git a/.dev/features/status-archetype-aware/PLAN.md b/.dev/features/status-archetype-aware/PLAN.md new file mode 100644 index 0000000..a8e6ea7 --- /dev/null +++ b/.dev/features/status-archetype-aware/PLAN.md @@ -0,0 +1,59 @@ +# PLAN — status-archetype-aware (slice 2/5: archetype-aware `pharn status`) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: Make `pharn status` work on an archetype install — version via `SKILLS_VERSION` (not the absent manifest) and byte-level **capability drift** against `@main` — instead of crashing. +- layer(s): product (`src/`). +- constitution_refs: [P2, P3, P4, P5, P6, P7] + +## Files + +- `src/lib/skills-version.ts` — **NEW** — `readSkillsVersion(repoDir)` (read+validate the clone's + `SKILLS_VERSION`, `VERSION_RE`, P2) + `fetchRemoteSkillsVersion()` (lightweight raw fetch with the 3 + guards: `redirect:'error'` + 8s timeout + 256KB cap, mirroring `fetchRemoteManifest`). The single home + for reading the version pharn-oss ships in place of a manifest. Layer product. +- `src/lib/diff.ts` — **EDIT** — add `diffInstalledCapabilities({repoDir, projectRoot, capabilities})`: + mirror `install-capabilities.ts`'s expected file set (selected capability dirs + the fixed product + surfaces: non-dev `pharn-*` commands, non-test hooks, trusted docs, `pharn-contracts/`, non-test + `.dev/floor/`) and `sha256`-compare against the project. **Excludes `.claude/settings.json`** (user-owned + — preserved at install). Reuses `walkFiles`/`hash`/`toPosix`/`safeJoin`. Parallels the existing + `diffInstalled`↔`installModule` mirror (cited, P4). +- `src/commands/status.ts` — **EDIT** — branch on `isArchetypeConfig`: `--no-drift` → `fetchRemoteSkillsVersion` + version only; default → `fetchRepo` clone → `readSkillsVersion` (version) + `diffInstalledCapabilities` + (drift), reusing the existing `printDriftSection` + cleanup-before-exit ordering. Legacy path unchanged. +- `src/steps/install-archetype.ts` — **EDIT (DRY)** — import `readSkillsVersion` from `lib/skills-version.js`; + drop the private copy (avoids a copy-paste-drift with the new lib). +- `tests/skills-version.test.ts` — **NEW** — `readSkillsVersion` reads/validates/reject-malformed (fake repo). +- `tests/diff.test.ts` — **EDIT** — `diffInstalledCapabilities`: modified/missing/ok over a fake clone + + project; `settings.json` excluded; dev-only never expected. +- `tests/status.test.ts` — **EDIT** — archetype config → version via skills-version (no manifest fetch), + drift via capability diff; legacy path unchanged (regression guard). + +## Contracts satisfied + +- **`ARCHITECTURE.md §5`** — status is the read-only audit over an archetype install's recorded + capabilities + skillsVersion. Cited (P4). + +## Evals to write (P1) + +- `readSkillsVersion` `1.0.0\n` ⇒ `1.0.0`; missing ⇒ throws; non-semver ⇒ throws (P2). +- `diffInstalledCapabilities` ⇒ a modified capability file ∈ `modified`; a missing one ∈ `missing`; + identical ⇒ `okCount`; `settings.json` never appears; a `pharn-dev-*` command never expected. +- `runStatus` archetype `--no-drift` ⇒ version note, no manifest fetch; default ⇒ drift note; no crash. + +## Guarantee audit (P0) + +- **"status no longer crashes on an archetype install"** → floor: `isArchetypeConfig` membership routes + before the manifest path + vitest. +- **"drift is exact"** → floor: `sha256` byte-compare (`ARCHITECTURE.md §2` content-hash) over the + install-mirrored expected set + vitest. +- **"version currency"** → floor: string compare of validated `SKILLS_VERSION` values. +- Remote fetch guarded → floor: `redirect:'error'` + timeout + body-cap (mirrors `fetchRemoteManifest`). + +## Trust audit (P2) + +`SKILLS_VERSION` (clone + remote) is untrusted → trimmed + `VERSION_RE`-validated before use/persist. +Drift reads only file bytes for `sha256` (never interprets content) and guards every read with `safeJoin`. + +## Open questions (RESOLVED — pre-authorized "build all remaining slices") + +- Slice 2 scope = version + capability drift for `status`; `settings.json` excluded from drift (user-owned). diff --git a/.dev/features/update-archetype-aware/PLAN.md b/.dev/features/update-archetype-aware/PLAN.md new file mode 100644 index 0000000..602310f --- /dev/null +++ b/.dev/features/update-archetype-aware/PLAN.md @@ -0,0 +1,49 @@ +# PLAN — update-archetype-aware (slice 3/5: archetype-aware `pharn update`) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: Make `pharn update` refresh an archetype install to the latest upstream — re-resolve the recorded archetypes against the current capability index and re-copy — instead of crashing on the absent manifest. +- layer(s): product (`src/`). +- constitution_refs: [P2, P3, P5, P6, P7] + +## Files + +- `src/commands/update.ts` — **EDIT** — branch on `isArchetypeConfig` → `runArchetypeUpdate`: version check via + `fetchRemoteSkillsVersion` (up-to-date → outro, no clone); else `note` the version bump + `confirm`; on yes, + `fetchRepo` → `parseCapabilityIndex` → `resolveCapabilities(config.archetypes, index)` (re-resolve the + **recorded** archetypes, mirroring legacy update's "re-resolve recorded modules") → `installCapabilities` + (re-copy) → rewrite config (new `skillsVersion` via `readSkillsVersion`, `commit`, `capabilities`), cleanup + in `finally` with exits after. Legacy path unchanged. Reuses init/status plumbing entirely. +- `tests/update.test.ts` — **EDIT** — archetype config: up-to-date short-circuits (no clone); a version bump + re-resolves + re-copies + rewrites config; decline cancels; legacy path unchanged (regression guard). + +## Contracts satisfied + +- **`ARCHITECTURE.md §5`** — update refreshes the archetype install's capabilities to the latest upstream. + Cited (P4). + +## Evals to write (P1) + +- archetype config, `skillsVersion === latest` ⇒ "already up to date", `fetchRepo` NOT called. +- archetype config, version behind + confirm yes ⇒ `installCapabilities` called, config rewritten with the + new skillsVersion + re-resolved capabilities. +- decline ⇒ `cancelAndExit`, no copy. +- legacy config ⇒ unchanged module-update path (regression guard). + +## Guarantee audit (P0) + +- **"update no longer crashes on an archetype install"** → floor: `isArchetypeConfig` membership routes + before the manifest path + vitest. +- **"re-resolution is deterministic"** → floor: the pure `resolveCapabilities` membership over the validated + index + vitest. +- Remote fetch guarded → floor: `fetchRemoteSkillsVersion`/`fetchRepo` reuse the existing guards. + +## Trust audit (P2) + +Reuses the `capability-index` parse boundary (validated index) + `installCapabilities` (safeJoin-guarded copy) ++ `readSkillsVersion` (VERSION_RE) from prior slices — no new untrusted ingestion path. + +## Open questions (RESOLVED — pre-authorized "build all remaining slices") + +- Update re-resolves the **recorded** `config.archetypes` (not re-detected), mirroring legacy update's + re-resolve-recorded-modules; capabilities no longer selected are dropped from config (files left, as legacy + update leaves dropped-skill files — pruning is `remove`'s job, slice 5). diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 6a71353..0e46e83 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,10 +1,11 @@ { "scope": [ - "src/lib/pharn-config.ts", - "src/commands/list.ts", - "tests/list.test.ts", - "tests/pharn-config.test.ts" + "src/lib/capability-address.ts", + "src/commands/add.ts", + "src/commands/remove.ts", + "tests/capability-address.test.ts", + "tests/remove.test.ts" ], - "set_by": ".dev/features/list-archetype-aware/PLAN.md", - "set_at": "2026-07-07T17:02:29.000Z" + "set_by": ".dev/features/remove-archetype-aware/PLAN.md", + "set_at": "2026-07-07T18:33:38.729Z" } diff --git a/src/commands/add.ts b/src/commands/add.ts index 89d39f7..3804eef 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -17,9 +17,15 @@ import { resolveModules, } from '../lib/manifest.js'; import { findSkillOption, listSkillAddresses } from '../lib/wizard.js'; +import { parseCapabilityArg } from '../lib/capability-address.js'; +import { parseCapabilityIndex } from '../lib/capability-index.js'; +import { installCapabilityDirs } from '../lib/install-capabilities.js'; +import { fetchRepo, fetchCommitSha } from '../lib/repo.js'; +import { readSkillsVersion } from '../lib/skills-version.js'; import { assertPrerequisites } from '../steps/prereqs.js'; import { fetchAndInstall } from '../lib/installer.js'; import { + isArchetypeConfig, readPharnConfig, toInstalledModules, writePharnConfig, @@ -41,6 +47,13 @@ export async function runAdd(moduleArg: string | undefined): Promise { process.exit(1); } + // Archetype (capability) install: `add ` / `add :` installs + // one capability. Separate path (there is no manifest); legacy flow unchanged. + if (isArchetypeConfig(config)) { + await runArchetypeAdd(config, cwd, moduleArg); + return; + } + const manifest = await loadManifest(); // schemaVersion 2: `add :` installs one wizard skill. @@ -230,6 +243,120 @@ async function addSkill( ); } +// Install one capability into an archetype project (a manual override of +// archetype auto-selection). Appends to `capabilities`, never touches +// `archetypes`. The clone lives across no interactive prompt, but cleanup still +// runs in a finally with every process.exit after it. +async function runArchetypeAdd( + config: PharnConfig, + cwd: string, + arg: string | undefined, +): Promise { + if (arg === undefined) { + log.error( + 'Specify a capability, e.g. `pharn add a11y` or `pharn add lens:n-plus-one`.', + ); + process.exit(1); + } + const parsed = parseCapabilityArg(arg); + if (parsed.error) { + log.error(parsed.error); + process.exit(1); + } + + const s = spinner(); + s.start(`Fetching capabilities from ${REPO_URL}`); + let repo; + try { + repo = await fetchRepo(); + s.stop(`Capabilities fetched from ${REPO_URL}`); + } catch (err) { + s.stop('Failed to fetch capabilities'); + log.error(`⚠ ${err instanceof Error ? err.message : String(err)}`); + if (process.env.PHARN_DEBUG) console.error(err); + process.exit(1); + } + + // Assigned exactly once per path (try or catch), so cleanup runs in the finally + // and the exit/outro happens after it (Node skips finally on process.exit). + let result: AddResult; + try { + result = await resolveArchetypeAdd(repo.dir, config, cwd, parsed, arg); + } catch (err) { + if (process.env.PHARN_DEBUG) console.error(err); + result = { + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }; + } finally { + repo.cleanup(); + } + + if (result.kind === 'error') { + log.error(`⚠ ${result.message}`); + process.exit(1); + } + if (result.kind === 'noop') { + outro(`${result.name} is already installed.`); + return; + } + outro( + `${pc.green('✔')} Added ${result.name} ${pc.dim(`(skills v${result.version})`)}`, + ); +} + +type AddResult = + | { kind: 'added'; name: string; version: string } + | { kind: 'noop'; name: string } + | { kind: 'error'; message: string }; + +// Resolve the arg against the fetched index and, if it uniquely names a not-yet- +// installed capability, copy it + append to config. Pure of process.exit — the +// caller owns cleanup + exit (this returns a typed outcome instead). +async function resolveArchetypeAdd( + repoDir: string, + config: PharnConfig, + cwd: string, + parsed: { name: string; role?: 'griller' | 'lens' }, + arg: string, +): Promise { + const index = parseCapabilityIndex(repoDir); + const matches = index.capabilities.filter( + (c) => + c.name === parsed.name && + (parsed.role === undefined || c.role === parsed.role), + ); + if (matches.length === 0) { + const valid = index.capabilities.map((c) => `${c.role}:${c.name}`).sort(); + return { + kind: 'error', + message: `Unknown capability "${arg}". Valid capabilities:\n ${valid.join('\n ')}`, + }; + } + if (matches.length > 1) { + return { + kind: 'error', + message: `"${parsed.name}" is ambiguous — use ${matches.map((m) => `${m.role}:${m.name}`).join(' or ')}.`, + }; + } + const cap = matches[0]!; + const existing = config.capabilities ?? []; + if (existing.some((c) => c.name === cap.name && c.role === cap.role)) { + return { kind: 'noop', name: cap.name }; + } + installCapabilityDirs(repoDir, cwd, [{ name: cap.name, role: cap.role }]); + const version = readSkillsVersion(repoDir); + const commit = await fetchCommitSha(); + await writePharnConfig(cwd, { + ...config, + skillsVersion: version, + commit, + capabilities: [...existing, { name: cap.name, role: cap.role }], + installedAt: new Date().toISOString(), + }); + return { kind: 'added', name: cap.name, version }; +} + async function loadManifest(): Promise { const s = spinner(); s.start('Fetching module catalog'); diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 4f4c81a..6dbedce 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -13,6 +13,8 @@ import pc from 'picocolors'; import { cancelAndExit } from '../lib/confirm.js'; import { CORE_MODULE, + GRILLERS_DIR, + LENSES_DIR, REPO_URL, SKILL_MODULE_PREFIX, } from '../lib/constants.js'; @@ -22,7 +24,13 @@ import { resolveModules, } from '../lib/manifest.js'; import { fetchRepo, type FetchedRepo } from '../lib/repo.js'; -import { readPharnConfig, writePharnConfig } from '../lib/pharn-config.js'; +import { parseCapabilityArg } from '../lib/capability-address.js'; +import { safeJoin } from '../lib/install-modules.js'; +import { + isArchetypeConfig, + readPharnConfig, + writePharnConfig, +} from '../lib/pharn-config.js'; import { ManifestValidationError } from '../lib/validate.js'; import type { InstalledSkill, ModuleManifest, PharnConfig } from '../types.js'; @@ -43,6 +51,13 @@ export async function runRemove( } const claudeDir = resolve(cwd, '.claude'); + // Archetype (capability) install: remove one capability (no clone, no network). + // Comes before the skill/module dispatch — an archetype config has neither. + if (isArchetypeConfig(config)) { + await removeCapability(cwd, config, arg); + return; + } + // A `category:skill` address (has a colon) removes one wizard skill; a plain // name removes a whole module; no arg opens the interactive picker. if (arg !== undefined && arg.includes(':')) { @@ -117,6 +132,89 @@ async function removeSkill( ); } +// --------------------------------------------------------------------------- +// §A2 — remove a capability (archetype install; no clone, no network) +// --------------------------------------------------------------------------- + +// Installed capabilities are fully derivable from config.capabilities + the +// filesystem, so this never clones. Each capability is an isolated dir, so +// removal is a precise recursive delete (siblings never touched) — mirrors +// removeSkill. Touches only `capabilities` (never archetypes / modules). +async function removeCapability( + cwd: string, + config: PharnConfig, + arg: string | undefined, +): Promise { + const installed = config.capabilities ?? []; + + // No arg → pick from the installed capabilities (or nothing to remove). + let address = arg; + if (address === undefined) { + if (installed.length === 0) { + outro('No capabilities are installed.'); + return; + } + const choice = await select({ + message: 'Which capability do you want to remove?', + options: installed.map((c) => ({ + value: `${c.role}:${c.name}`, + label: `${c.name} (${c.role})`, + })), + }); + if (isCancel(choice)) cancelAndExit(); + address = choice as string; + } + + const parsed = parseCapabilityArg(address); + if (parsed.error) { + log.error(parsed.error); + process.exit(1); + } + const matches = installed.filter( + (c) => + c.name === parsed.name && + (parsed.role === undefined || c.role === parsed.role), + ); + + // Not installed → benign no-op (no config write). Without a manifest we list + // the installed capabilities (the only removable things) as the valid values. + if (matches.length === 0) { + const valid = installed.map((c) => `${c.role}:${c.name}`).sort(); + const hint = valid.length + ? ` Installed: ${valid.join(', ')}.` + : ' No capabilities are installed.'; + log.warn(`"${address}" is not an installed capability.${hint}`); + outro('Nothing was removed.'); + return; + } + if (matches.length > 1) { + log.error( + `"${parsed.name}" is ambiguous — use ${matches.map((m) => `${m.role}:${m.name}`).join(' or ')}.`, + ); + process.exit(1); + } + + const target = matches[0]!; + const subtree = target.role === 'griller' ? GRILLERS_DIR : LENSES_DIR; + const dir = safeJoin(cwd, `${subtree}/${target.name}`); + let note = ''; + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } else { + note = pc.dim(' (its files were already gone)'); + } + + await writePharnConfig(cwd, { + ...config, + capabilities: installed.filter( + (c) => !(c.name === target.name && c.role === target.role), + ), + installedAt: new Date().toISOString(), + }); + + outro(`${pc.green('✔')} Removed ${target.name} (${target.role})${note}`); +} + // --------------------------------------------------------------------------- // §B — remove a module (clones once) // --------------------------------------------------------------------------- diff --git a/src/commands/status.ts b/src/commands/status.ts index 6fae95a..e3f38fa 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -8,9 +8,13 @@ import { resolveModules, } from '../lib/manifest.js'; import { fetchRepo } from '../lib/repo.js'; -import { diffInstalled } from '../lib/diff.js'; +import { diffInstalled, diffInstalledCapabilities } from '../lib/diff.js'; import { row } from '../lib/format.js'; -import { readPharnConfig } from '../lib/pharn-config.js'; +import { isArchetypeConfig, readPharnConfig } from '../lib/pharn-config.js'; +import { + fetchRemoteSkillsVersion, + readSkillsVersion, +} from '../lib/skills-version.js'; import type { Manifest, PharnConfig } from '../types.js'; const REF = `${REPO}@${REPO_BRANCH}`; @@ -40,6 +44,13 @@ export async function runStatus( } const claudeDir = resolve(cwd, '.claude'); + // Archetype (capability) install: version via SKILLS_VERSION (there is no + // manifest) + capability drift. Separate path so the legacy audit is unchanged. + if (isArchetypeConfig(config)) { + await runArchetypeStatus(config, { strict, drift }, cwd); + return; + } + // Version-only: no clone, just the manifest fetch list/update already use. if (!drift) { const s = spinner(); @@ -109,6 +120,91 @@ export async function runStatus( outro(pc.dim('Read-only — nothing changed.')); } +// Archetype install audit: version via SKILLS_VERSION + capability drift. Mirrors +// the module path's structure (cleanup before exit; --strict gate) but reads the +// version from SKILLS_VERSION and diffs the copied capabilities/product surfaces. +async function runArchetypeStatus( + config: PharnConfig, + opts: { strict: boolean; drift: boolean }, + cwd: string, +): Promise { + const { strict, drift } = opts; + + if (!drift) { + const s = spinner(); + s.start('Checking for updates'); + let latest: string; + try { + latest = await fetchRemoteSkillsVersion(); + s.stop(`Latest skills v${latest}`); + } catch (err) { + s.stop('Failed to check for updates'); + reportError(err); + process.exit(1); + } + const outdated = printArchetypeVersion(config, latest); + if (strict && outdated) process.exit(1); + outro(pc.dim('Read-only — nothing changed (drift check skipped).')); + return; + } + + const s = spinner(); + s.start(`Comparing against ${REF}`); + let repo; + try { + repo = await fetchRepo(); + s.stop(`Compared against ${REF}`); + } catch (err) { + s.stop(`Failed to reach ${REPO}`); + reportError(err); + process.exit(1); + } + + let exitCode = 0; + try { + const outdated = printArchetypeVersion(config, readSkillsVersion(repo.dir)); + const result = diffInstalledCapabilities({ + repoDir: repo.dir, + projectRoot: cwd, + capabilities: config.capabilities ?? [], + }); + printDriftSection(result); + if ( + strict && + (outdated || result.modified.length || result.missing.length) + ) { + exitCode = 1; + } + } catch (err) { + reportError(err); + exitCode = 1; + } finally { + repo.cleanup(); + } + + if (exitCode) process.exit(exitCode); + outro(pc.dim('Read-only — nothing changed.')); +} + +// VERSION note for an archetype install: skillsVersion currency + a summary of +// the detected archetypes and installed capability count. Returns outdated. +function printArchetypeVersion(config: PharnConfig, latest: string): boolean { + const outdated = config.skillsVersion !== latest; + const skillsLine = outdated + ? `${row('Skills version', `v${config.skillsVersion} → v${latest}`)} ${pc.dim('(update available, run `pharn update`)')}` + : `${row('Skills version', `v${config.skillsVersion}`)} ${pc.dim('(up to date)')}`; + note( + [ + skillsLine, + '', + row('Archetypes', (config.archetypes ?? []).join(', ') || '(none)'), + row('Capabilities', String((config.capabilities ?? []).length)), + ].join('\n'), + 'VERSION', + ); + return outdated; +} + // VERSION note: skillsVersion + any per-module version bumps. Returns whether // the install is behind upstream (drives the --strict gate). Mirrors the diff // computation in `pharn update`. diff --git a/src/commands/update.ts b/src/commands/update.ts index ca900d2..26120a7 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -14,13 +14,26 @@ import { REPO_URL } from '../lib/constants.js'; import { fetchRemoteManifest } from '../lib/manifest.js'; import { listSkillAddresses } from '../lib/wizard.js'; import { fetchAndInstall } from '../lib/installer.js'; +import { parseCapabilityIndex } from '../lib/capability-index.js'; +import { resolveCapabilities } from '../lib/resolve-capabilities.js'; +import { installCapabilities } from '../lib/install-capabilities.js'; +import { fetchRepo, fetchCommitSha } from '../lib/repo.js'; +import { + fetchRemoteSkillsVersion, + readSkillsVersion, +} from '../lib/skills-version.js'; import { row } from '../lib/format.js'; import { + isArchetypeConfig, readPharnConfig, toInstalledModules, writePharnConfig, } from '../lib/pharn-config.js'; -import type { InstalledSkill } from '../types.js'; +import type { + InstalledCapability, + InstalledSkill, + PharnConfig, +} from '../types.js'; export async function runUpdate(): Promise { intro('pharn update'); @@ -32,6 +45,13 @@ export async function runUpdate(): Promise { process.exit(1); } + // Archetype (capability) install: refresh via SKILLS_VERSION + re-resolved + // capabilities (there is no manifest). Separate path; legacy flow unchanged. + if (isArchetypeConfig(config)) { + await runArchetypeUpdate(config, cwd); + return; + } + const s = spinner(); s.start('Checking for updates'); let manifest; @@ -145,3 +165,102 @@ export async function runUpdate(): Promise { `${pc.green('✔')} Updated to skills v${result.skillsVersion}. ${pc.dim('CONSTITUTION.md left untouched (edit it by hand).')}`, ); } + +// Archetype install refresh: check SKILLS_VERSION, then (on a bump + confirm) +// re-resolve the RECORDED archetypes against the latest capability index and +// re-copy — mirroring legacy update's "re-resolve recorded modules". The clone +// lives across no interactive prompt (confirm is before it), but cleanup still +// runs in a finally with every process.exit after it. +async function runArchetypeUpdate( + config: PharnConfig, + cwd: string, +): Promise { + const s = spinner(); + s.start('Checking for updates'); + let latest: string; + try { + latest = await fetchRemoteSkillsVersion(); + s.stop(`Latest skills v${latest}`); + } catch (err) { + s.stop('Failed to check for updates'); + log.error(`⚠ ${err instanceof Error ? err.message : String(err)}`); + if (process.env.PHARN_DEBUG) console.error(err); + process.exit(1); + } + + if (config.skillsVersion === latest) { + outro(`Already up to date (skills v${config.skillsVersion}).`); + return; + } + + note( + [ + row('Skills version', `v${config.skillsVersion} → v${latest}`), + '', + row('Archetypes', (config.archetypes ?? []).join(', ') || '(none)'), + '', + pc.dim( + ' Re-resolves your archetypes against the latest capabilities and re-copies them.', + ), + pc.dim(' https://github.com/pharn-dev/pharn-oss/blob/main/CHANGELOG.md'), + ].join('\n'), + ); + const ok = await confirm({ + message: 'Re-fetch capabilities at the latest version?', + initialValue: true, + }); + if (isCancel(ok) || ok !== true) cancelAndExit(); + + const s2 = spinner(); + s2.start(`Updating from ${REPO_URL}`); + let repo; + try { + repo = await fetchRepo(); + } catch (err) { + s2.stop('Update failed'); + log.error(`⚠ ${err instanceof Error ? err.message : String(err)}`); + if (process.env.PHARN_DEBUG) console.error(err); + process.exit(1); + } + + let installedVersion: string | null = null; + let capCount = 0; + let failure: string | null = null; + try { + const index = parseCapabilityIndex(repo.dir); + const selection = resolveCapabilities(config.archetypes ?? [], index); + installCapabilities(repo.dir, cwd, selection); + const capabilities: InstalledCapability[] = selection.selected.map((c) => ({ + name: c.name, + role: c.role, + })); + installedVersion = readSkillsVersion(repo.dir); + capCount = capabilities.length; + const commit = await fetchCommitSha(); + await writePharnConfig(cwd, { + ...config, + skillsVersion: installedVersion, + commit, + capabilities, + installedAt: new Date().toISOString(), + }); + s2.stop('Capabilities updated'); + } catch (err) { + s2.stop('Update failed'); + failure = err instanceof Error ? err.message : String(err); + if (process.env.PHARN_DEBUG) console.error(err); + } finally { + repo.cleanup(); + } + + if (failure) { + log.error(`⚠ ${failure}`); + if (!process.env.PHARN_DEBUG) { + log.info('Re-run with PHARN_DEBUG=1 for full error output.'); + } + process.exit(1); + } + outro( + `${pc.green('✔')} Updated to skills v${installedVersion} (${capCount} capabilit${capCount === 1 ? 'y' : 'ies'}). ${pc.dim('CONSTITUTION.md left untouched.')}`, + ); +} diff --git a/src/lib/capability-address.ts b/src/lib/capability-address.ts new file mode 100644 index 0000000..1eadce3 --- /dev/null +++ b/src/lib/capability-address.ts @@ -0,0 +1,28 @@ +// Capability address parsing — the shared `` / `:` form used by +// `pharn add` and `pharn remove` for an archetype install. One axis (P3): parsing +// a capability address; shared here so the two commands never diverge and neither +// imports the other. Deterministic (P5): an unknown role before the colon is a +// reported error, never a guess. + +export interface CapabilityAddress { + // The capability name (the part after `role:`, or the whole arg). + name: string; + // The role filter, when the arg was `:`. + role?: 'griller' | 'lens'; + // Set when the text before the colon was not a valid role. + error?: string; +} + +export function parseCapabilityArg(arg: string): CapabilityAddress { + if (!arg.includes(':')) return { name: arg }; + const idx = arg.indexOf(':'); + const role = arg.slice(0, idx); + const name = arg.slice(idx + 1); + if (role !== 'griller' && role !== 'lens') { + return { + name, + error: `Unknown role "${role}". Use griller: or lens:.`, + }; + } + return { name, role }; +} diff --git a/src/lib/diff.ts b/src/lib/diff.ts index b0c9a7a..6f5c6a0 100644 --- a/src/lib/diff.ts +++ b/src/lib/diff.ts @@ -3,7 +3,18 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { basename, join, resolve, sep } from 'node:path'; import { safeJoin } from './install-modules.js'; import { readModuleManifest } from './manifest.js'; -import type { InstalledSkill } from '../types.js'; +import { + CLAUDE_COMMANDS_DIR, + CLAUDE_HOOKS_DIR, + CONTRACTS_DIR, + DEV_COMMAND_PREFIX, + FLOOR_DIR, + GRILLERS_DIR, + LENSES_DIR, + PRODUCT_COMMAND_PREFIX, + TRUSTED_DOCS, +} from './constants.js'; +import type { InstalledCapability, InstalledSkill } from '../types.js'; export interface InstallDiff { // .claude-relative paths present on disk but whose bytes differ from upstream. @@ -76,11 +87,82 @@ export function diffInstalled(params: { } } + return compareExpected(expected, claudeDir); +} + +/** + * Read-only comparison of an archetype install's PHARN-owned files against a + * fetched clone. Derives the expected set by mirroring installCapabilities + * (lib/install-capabilities.ts) — the selected capability dirs + the fixed + * product surfaces — then byte-compares each against `projectRoot`. Parallels the + * diffInstalled↔installModule mirror. `.claude/settings.json` is user-owned + * (preserved at install) and excluded; the copied-verbatim trusted docs, hooks, + * contracts, and floor checkers ARE compared. Every read is safeJoin-guarded. + */ +export function diffInstalledCapabilities(params: { + repoDir: string; + projectRoot: string; + capabilities: InstalledCapability[]; +}): InstallDiff { + const { repoDir, projectRoot, capabilities } = params; + const expected = new Map(); + const add = (rel: string, repoPath: string): void => { + expected.set(toPosix(rel), repoPath); + }; + // Enumerate one source dir's files (optionally filtered by relative name) into + // the expected map at the mirrored path. + const addDir = (relDir: string, keep?: (rel: string) => boolean): void => { + const from = safeJoin(repoDir, relDir); + if (!existsSync(from) || !statSync(from).isDirectory()) return; + for (const rel of walkFiles(from)) { + if (keep && !keep(rel)) continue; + add(join(relDir, rel), resolve(from, rel)); + } + }; + + // Selected capabilities (whole dir, incl. evals). + for (const cap of capabilities) { + const subtree = cap.role === 'griller' ? GRILLERS_DIR : LENSES_DIR; + addDir(`${subtree}/${cap.name}`); + } + // Product commands: top-level non-dev pharn-*.md. + addDir( + CLAUDE_COMMANDS_DIR, + (rel) => + !rel.includes('/') && + rel.endsWith('.md') && + rel.startsWith(PRODUCT_COMMAND_PREFIX) && + !rel.startsWith(DEV_COMMAND_PREFIX), + ); + // Hooks: top-level *.cjs, excluding *.test.cjs. + addDir( + CLAUDE_HOOKS_DIR, + (rel) => + !rel.includes('/') && rel.endsWith('.cjs') && !rel.endsWith('.test.cjs'), + ); + // Trusted docs (root files). + for (const doc of TRUSTED_DOCS) { + const from = safeJoin(repoDir, doc); + if (existsSync(from)) add(doc, from); + } + // Contracts (whole dir) + floor checkers (test files excluded). + addDir(CONTRACTS_DIR); + addDir(FLOOR_DIR, (rel) => !/\.test\.(mjs|cjs)$/.test(rel)); + + return compareExpected(expected, projectRoot); +} + +// Byte-compare each expected file against `baseDir`, partitioning into +// modified / missing / ok. Shared by both diff functions (safeJoin-guarded). +function compareExpected( + expected: Map, + baseDir: string, +): InstallDiff { const modified: string[] = []; const missing: string[] = []; let okCount = 0; for (const [rel, repoPath] of expected) { - const diskPath = safeJoin(claudeDir, rel); + const diskPath = safeJoin(baseDir, rel); if (!existsSync(diskPath)) { missing.push(rel); } else if (hash(repoPath) === hash(diskPath)) { diff --git a/src/lib/install-capabilities.ts b/src/lib/install-capabilities.ts index 39a9c48..d240077 100644 --- a/src/lib/install-capabilities.ts +++ b/src/lib/install-capabilities.ts @@ -59,32 +59,51 @@ const isTestFile = (p: string): boolean => /\.test\.(mjs|cjs)$/.test(p); * write (no partial installs). Returns the copied capability list + whether the * user's existing settings.json was preserved. */ -export function installCapabilities( +/** + * Copy just the given capability dirs (whole dir, incl. evals/) into the mirrored + * project-root paths — WITHOUT the fixed product surfaces. Pre-flights every + * source (validated name + safeJoin + existence) before any write, so a bad name + * or missing source fails with nothing written. The focused primitive `add` and + * the archetype install share; `remove` mirrors its path derivation. + */ +export function installCapabilityDirs( repoDir: string, projectRoot: string, - selection: Selection, -): InstallCapabilitiesResult { - // --- pre-flight: resolve + validate every selected capability source ------- - const planned = selection.selected.map((cap) => { + capabilities: InstalledCapability[], +): InstalledCapability[] { + const planned = capabilities.map((cap) => { assertSafeString(cap.name, `capability "${cap.name}"`, CAPABILITY_NAME_RE); assertNoDotDot(cap.name, `capability "${cap.name}"`); const subtree = cap.role === 'griller' ? GRILLERS_DIR : LENSES_DIR; const from = safeJoin(repoDir, `${subtree}/${cap.name}`); if (!existsSync(from)) { throw new ManifestValidationError( - `Selected capability "${cap.name}" (${cap.role}) is missing at ${subtree}/${cap.name} in the fetched repo.`, + `Capability "${cap.name}" (${cap.role}) is missing at ${subtree}/${cap.name} in the fetched repo.`, ); } return { name: cap.name, role: cap.role, subtree, from }; }); - // --- copy the selected capabilities (whole dir, incl. evals/) -------------- - const capabilities: InstalledCapability[] = []; + const installed: InstalledCapability[] = []; for (const cap of planned) { const to = safeJoin(projectRoot, `${cap.subtree}/${cap.name}`); cpSync(cap.from, to, { recursive: true, force: true }); - capabilities.push({ name: cap.name, role: cap.role }); + installed.push({ name: cap.name, role: cap.role }); } + return installed; +} + +export function installCapabilities( + repoDir: string, + projectRoot: string, + selection: Selection, +): InstallCapabilitiesResult { + // Copy the selected capability dirs (pre-flighted; no partial installs). + const capabilities = installCapabilityDirs( + repoDir, + projectRoot, + selection.selected, + ); // --- product commands: pharn-*.md, excluding pharn-dev-*.md ---------------- copyFilteredDir(repoDir, projectRoot, CLAUDE_COMMANDS_DIR, (fileName) => { diff --git a/src/lib/skills-version.ts b/src/lib/skills-version.ts new file mode 100644 index 0000000..2ef46f1 --- /dev/null +++ b/src/lib/skills-version.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { REPO, REPO_BRANCH, SKILLS_VERSION_FILE } from './constants.js'; +import { safeJoin } from './install-modules.js'; +import { + assertSafeString, + ManifestValidationError, + VERSION_RE, +} from './validate.js'; + +// SKILLS_VERSION reading — pharn-oss ships a root `SKILLS_VERSION` file in place +// of a manifest.json, so the archetype flow (init/status/update) reads the +// version from here. Untrusted (P2): every value is trimmed and VERSION_RE- +// validated before use/persist. One axis (P3): obtaining the skills version. + +const RAW = 'https://raw.githubusercontent.com'; +const FETCH_TIMEOUT_MS = 8000; +const MAX_BODY_BYTES = 256 * 1024; + +/** + * Read + validate `SKILLS_VERSION` from a fetched clone. Missing file is an + * upstream packaging bug — surfaced, not defaulted. + */ +export function readSkillsVersion(repoDir: string): string { + const path = safeJoin(repoDir, SKILLS_VERSION_FILE); + if (!existsSync(path)) { + throw new ManifestValidationError( + `${SKILLS_VERSION_FILE} is missing in the fetched repo.`, + ); + } + return assertSafeString( + readFileSync(path, 'utf8').trim(), + SKILLS_VERSION_FILE, + VERSION_RE, + ); +} + +/** + * Fetch the latest `SKILLS_VERSION` from `@main` without cloning — the + * lightweight currency check `status --no-drift` uses. Mirrors + * `fetchRemoteManifest`'s three network guards (redirect:'error', an 8s timeout, + * a 256KB body cap) and validates the result (P2). + */ +export async function fetchRemoteSkillsVersion(): Promise { + const url = `${RAW}/${REPO}/${REPO_BRANCH}/${SKILLS_VERSION_FILE}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(url, { redirect: 'error', signal: controller.signal }); + } finally { + clearTimeout(timer); + } + if (!res.ok) { + throw new Error(`SKILLS_VERSION fetch failed (${res.status}) from ${url}`); + } + const declared = Number(res.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { + throw new Error(`SKILLS_VERSION too large (${declared} bytes) from ${url}`); + } + const text = await res.text(); + if (text.length > MAX_BODY_BYTES) { + throw new Error( + `SKILLS_VERSION too large (${text.length} bytes) from ${url}`, + ); + } + return assertSafeString(text.trim(), SKILLS_VERSION_FILE, VERSION_RE); +} diff --git a/src/steps/install-archetype.ts b/src/steps/install-archetype.ts index 6b97555..f95a21b 100644 --- a/src/steps/install-archetype.ts +++ b/src/steps/install-archetype.ts @@ -1,21 +1,10 @@ -import { existsSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { log, outro, spinner } from '@clack/prompts'; import pc from 'picocolors'; -import { - DOCS_URL, - FIRST_FEATURE_COMMAND, - REPO_URL, - SKILLS_VERSION_FILE, -} from '../lib/constants.js'; +import { DOCS_URL, FIRST_FEATURE_COMMAND, REPO_URL } from '../lib/constants.js'; import { installCapabilities } from '../lib/install-capabilities.js'; -import { safeJoin } from '../lib/install-modules.js'; import { writePharnConfig } from '../lib/pharn-config.js'; -import { - assertSafeString, - ManifestValidationError, - VERSION_RE, -} from '../lib/validate.js'; +import { readSkillsVersion } from '../lib/skills-version.js'; import type { Archetype, InstalledCapability, @@ -98,22 +87,3 @@ export async function runInstallArchetype( ].join('\n'), ); } - -/** - * Read + validate the fetched SKILLS_VERSION (untrusted, P2): trim, then check - * it is a semver string (VERSION_RE) before persisting. A missing file is an - * upstream packaging bug — surfaced, not defaulted. - */ -function readSkillsVersion(repoDir: string): string { - const svPath = safeJoin(repoDir, SKILLS_VERSION_FILE); - if (!existsSync(svPath)) { - throw new ManifestValidationError( - `${SKILLS_VERSION_FILE} is missing in the fetched repo.`, - ); - } - return assertSafeString( - readFileSync(svPath, 'utf8').trim(), - SKILLS_VERSION_FILE, - VERSION_RE, - ); -} diff --git a/tests/add.test.ts b/tests/add.test.ts index c2dcef3..8f12262 100644 --- a/tests/add.test.ts +++ b/tests/add.test.ts @@ -65,11 +65,27 @@ vi.mock('../src/lib/manifest.js', () => ({ const fetchAndInstall = vi.fn(); vi.mock('../src/lib/installer.js', () => ({ fetchAndInstall })); +const fetchRepo = vi.fn(); +const fetchCommitSha = vi.fn(); +vi.mock('../src/lib/repo.js', () => ({ fetchRepo, fetchCommitSha })); + +const parseCapabilityIndex = vi.fn(); +vi.mock('../src/lib/capability-index.js', () => ({ parseCapabilityIndex })); + +const installCapabilityDirs = vi.fn(); +vi.mock('../src/lib/install-capabilities.js', () => ({ + installCapabilityDirs, +})); + +const readSkillsVersion = vi.fn(); +vi.mock('../src/lib/skills-version.js', () => ({ readSkillsVersion })); + const readPharnConfig = vi.fn(); const writePharnConfig = vi.fn(); vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig, writePharnConfig, + isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), toInstalledModules: (m: { name: string; version: string }[]) => m.map(({ name, version }) => ({ name, version })), })); @@ -312,3 +328,99 @@ describe('runAdd category:skill', () => { ); }); }); + +describe('runAdd (archetype)', () => { + stubProcessExit(); + beforeEach(() => vi.spyOn(process, 'cwd').mockReturnValue('/proj')); + afterEach(() => vi.clearAllMocks()); + + const archConfig = ( + caps: { name: string; role: 'griller' | 'lens' }[] = [ + { name: 'security', role: 'griller' }, + ], + ): PharnConfig => ({ + pharnVersion: '0.2.0', + skillsVersion: '1.0.0', + repo: 'pharn-dev/pharn-oss', + commit: 'old', + modules: [], + installedAt: '2026-07-07T00:00:00.000Z', + archetypes: ['ssr'], + capabilities: caps, + }); + + const index = { + capabilities: [ + { name: 'a11y', role: 'griller', applies: ['ssr', 'spa'] }, + { name: 'security', role: 'griller', applies: 'universal' }, + { name: 'n-plus-one', role: 'lens', applies: ['backend', 'ssr'] }, + ], + }; + + function mockClone(): ReturnType { + const cleanup = vi.fn(); + fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); + parseCapabilityIndex.mockReturnValue(index); + readSkillsVersion.mockReturnValue('1.0.0'); + fetchCommitSha.mockResolvedValue('sha'); + return cleanup; + } + + it('installs a capability by name and appends it (archetypes untouched)', async () => { + readPharnConfig.mockReturnValue(archConfig()); + const cleanup = mockClone(); + + await runAdd('a11y'); + + expect(installCapabilityDirs).toHaveBeenCalledWith('/repo', '/proj', [ + { name: 'a11y', role: 'griller' }, + ]); + const [, written] = writePharnConfig.mock.calls[0]!; + expect((written as PharnConfig).capabilities).toEqual([ + { name: 'security', role: 'griller' }, + { name: 'a11y', role: 'griller' }, + ]); + expect((written as PharnConfig).archetypes).toEqual(['ssr']); + expect(cleanup).toHaveBeenCalled(); + }); + + it('resolves role:name addressing', async () => { + readPharnConfig.mockReturnValue(archConfig()); + mockClone(); + + await runAdd('lens:n-plus-one'); + + expect(installCapabilityDirs).toHaveBeenCalledWith('/repo', '/proj', [ + { name: 'n-plus-one', role: 'lens' }, + ]); + }); + + it('is a no-op when the capability is already installed', async () => { + readPharnConfig.mockReturnValue( + archConfig([{ name: 'a11y', role: 'griller' }]), + ); + const cleanup = mockClone(); + + await runAdd('a11y'); + + expect(installCapabilityDirs).not.toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalledWith('a11y is already installed.'); + expect(cleanup).toHaveBeenCalled(); + }); + + it('exits(1) listing valid capabilities for an unknown name (cleans up)', async () => { + readPharnConfig.mockReturnValue(archConfig()); + const cleanup = mockClone(); + + await expect(runAdd('bogus')).rejects.toMatchObject(new ProcessExit(1)); + + expect(installCapabilityDirs).not.toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalled(); + }); + + it('exits(1) with no arg, before any fetch', async () => { + readPharnConfig.mockReturnValue(archConfig()); + await expect(runAdd(undefined)).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRepo).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/capability-address.test.ts b/tests/capability-address.test.ts new file mode 100644 index 0000000..86b1eee --- /dev/null +++ b/tests/capability-address.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { parseCapabilityArg } from '../src/lib/capability-address.js'; + +describe('parseCapabilityArg', () => { + it('parses a bare name (no role filter)', () => { + expect(parseCapabilityArg('a11y')).toEqual({ name: 'a11y' }); + }); + + it('parses role:name for each role', () => { + expect(parseCapabilityArg('lens:n-plus-one')).toEqual({ + name: 'n-plus-one', + role: 'lens', + }); + expect(parseCapabilityArg('griller:a11y')).toEqual({ + name: 'a11y', + role: 'griller', + }); + }); + + it('reports an unknown role before the colon (P5 — not a guess)', () => { + const r = parseCapabilityArg('auditor:x'); + expect(r.name).toBe('x'); + expect(r.role).toBeUndefined(); + expect(r.error).toMatch(/Unknown role/); + }); +}); diff --git a/tests/diff.test.ts b/tests/diff.test.ts index 46ca1b1..a29571b 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -2,8 +2,9 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { useTmpDir } from './helpers.js'; -import { diffInstalled } from '../src/lib/diff.js'; +import { diffInstalled, diffInstalledCapabilities } from '../src/lib/diff.js'; import { ManifestValidationError } from '../src/lib/validate.js'; +import type { InstalledCapability } from '../src/types.js'; function write(path: string, content = 'x'): void { mkdirSync(join(path, '..'), { recursive: true }); @@ -225,3 +226,80 @@ describe('diffInstalled', () => { expect(snapshot(claudeDir)).toEqual(claudeBefore); }); }); + +describe('diffInstalledCapabilities', () => { + const tmp = useTmpDir(); + + const caps: InstalledCapability[] = [ + { name: 'a11y', role: 'griller' }, + { name: 'n-plus-one', role: 'lens' }, + ]; + + function scaffoldClone(repo: string): void { + write(join(repo, 'pharn-pipeline/grillers/a11y/a11y.md'), 'A'); + write(join(repo, 'pharn-review/n-plus-one/n-plus-one.md'), 'N'); + write(join(repo, '.claude/commands/pharn-plan.md'), 'plan'); + write(join(repo, '.claude/commands/pharn-dev-plan.md'), 'DEV'); + write(join(repo, '.claude/hooks/enforce.cjs'), 'hook'); + write(join(repo, '.claude/hooks/enforce.test.cjs'), 'HOOKTEST'); + write(join(repo, '.claude/settings.json'), '{"a":1}'); + write(join(repo, 'CONSTITUTION.md'), 'C'); + write(join(repo, 'pharn-contracts/finding-shape.md'), 'fs'); + write(join(repo, '.dev/floor/validate.mjs'), 'floor'); + write(join(repo, '.dev/floor/validate.test.mjs'), 'FLOORTEST'); + } + + // The exact PHARN-owned set installCapabilities would have written (settings + // excluded — user-owned; dev-only + test files never copied). + function scaffoldMatchingProject(proj: string): void { + write(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'), 'A'); + write(join(proj, 'pharn-review/n-plus-one/n-plus-one.md'), 'N'); + write(join(proj, '.claude/commands/pharn-plan.md'), 'plan'); + write(join(proj, '.claude/hooks/enforce.cjs'), 'hook'); + write(join(proj, 'CONSTITUTION.md'), 'C'); + write(join(proj, 'pharn-contracts/finding-shape.md'), 'fs'); + write(join(proj, '.dev/floor/validate.mjs'), 'floor'); + } + + it('reports all ok when the project matches (settings.json + dev-only excluded)', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + scaffoldClone(repo); + scaffoldMatchingProject(proj); + // A different settings.json must be ignored (user-owned). + write(join(proj, '.claude/settings.json'), '{"user":true}'); + + const r = diffInstalledCapabilities({ + repoDir: repo, + projectRoot: proj, + capabilities: caps, + }); + expect(r.missing).toEqual([]); + expect(r.modified).toEqual([]); + // a11y, n-plus-one, pharn-plan, enforce.cjs, CONSTITUTION, contract, floor. + expect(r.okCount).toBe(7); + }); + + it('flags a modified + a missing file; never expects settings.json/dev-only/test files', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + scaffoldClone(repo); + scaffoldMatchingProject(proj); + // Modify one capability file, remove another expected file. + write(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'), 'A-EDITED'); + write(join(proj, 'pharn-review/n-plus-one/n-plus-one.md'), 'N'); // keep + write(join(proj, 'CONSTITUTION.md'), 'C-EDITED'); // modified doc + + const r = diffInstalledCapabilities({ + repoDir: repo, + projectRoot: proj, + capabilities: caps, + }); + expect(r.modified).toContain('pharn-pipeline/grillers/a11y/a11y.md'); + expect(r.modified).toContain('CONSTITUTION.md'); + const paths = [...r.modified, ...r.missing]; + expect(paths).not.toContain('.claude/settings.json'); + expect(paths.some((p) => p.includes('pharn-dev-'))).toBe(false); + expect(paths.some((p) => p.includes('.test.'))).toBe(false); + }); +}); diff --git a/tests/install-capabilities.test.ts b/tests/install-capabilities.test.ts index ce03877..2b0af67 100644 --- a/tests/install-capabilities.test.ts +++ b/tests/install-capabilities.test.ts @@ -2,9 +2,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { useTmpDir } from './helpers.js'; -import { installCapabilities } from '../src/lib/install-capabilities.js'; +import { + installCapabilities, + installCapabilityDirs, +} from '../src/lib/install-capabilities.js'; import { ManifestValidationError } from '../src/lib/validate.js'; -import type { Selection } from '../src/types.js'; +import type { InstalledCapability, Selection } from '../src/types.js'; function write(path: string, content = 'x'): void { mkdirSync(join(path, '..'), { recursive: true }); @@ -198,3 +201,39 @@ describe('installCapabilities', () => { ); }); }); + +describe('installCapabilityDirs', () => { + const tmp = useTmpDir(); + + it('copies only the named capability dirs — no product surfaces', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + const caps: InstalledCapability[] = [{ name: 'a11y', role: 'griller' }]; + + const result = installCapabilityDirs(repo, proj, caps); + + expect(result).toEqual([{ name: 'a11y', role: 'griller' }]); + expect(existsSync(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'))).toBe( + true, + ); + // The focused primitive copies NO product surfaces. + expect(existsSync(join(proj, '.claude/commands/pharn-plan.md'))).toBe( + false, + ); + expect(existsSync(join(proj, 'CONSTITUTION.md'))).toBe(false); + }); + + it('rejects a name that escapes the base (P2 safeJoin)', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + expect(() => + installCapabilityDirs(repo, proj, [ + { name: '../escape', role: 'griller' }, + ]), + ).toThrow(ManifestValidationError); + }); +}); diff --git a/tests/remove.test.ts b/tests/remove.test.ts index 70876b8..4347196 100644 --- a/tests/remove.test.ts +++ b/tests/remove.test.ts @@ -25,6 +25,7 @@ const writePharnConfig = vi.fn(); vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig, writePharnConfig, + isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), toInstalledModules: (m: { name: string; version: string }[]) => m.map(({ name, version }) => ({ name, version })), })); @@ -555,3 +556,89 @@ describe('runRemove', () => { expect(writePharnConfig).not.toHaveBeenCalled(); }); }); + +// §A2 — capability removal (archetype install; no clone, no network) ---------- +describe('runRemove (archetype)', () => { + stubProcessExit(); + beforeEach(() => { + proj = join(tmp.path(), 'proj'); + vi.spyOn(process, 'cwd').mockReturnValue(proj); + }); + afterEach(() => vi.clearAllMocks()); + + const archConfig = ( + caps: { name: string; role: 'griller' | 'lens' }[], + ): PharnConfig => + config([], { modules: [], archetypes: ['ssr'], capabilities: caps }); + + it('deletes the capability dir and drops it (siblings untouched, no clone)', async () => { + readPharnConfig.mockReturnValue( + archConfig([ + { name: 'a11y', role: 'griller' }, + { name: 'n-plus-one', role: 'lens' }, + ]), + ); + write(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'), 'A'); + write(join(proj, 'pharn-review/n-plus-one/n-plus-one.md'), 'N'); + + await runRemove('a11y'); + + expect(existsSync(join(proj, 'pharn-pipeline/grillers/a11y'))).toBe(false); + expect( + existsSync(join(proj, 'pharn-review/n-plus-one/n-plus-one.md')), + ).toBe(true); + expect(lastWritten().capabilities).toEqual([ + { name: 'n-plus-one', role: 'lens' }, + ]); + expect(fetchRepo).not.toHaveBeenCalled(); + }); + + it('resolves role:name and leaves archetypes untouched', async () => { + readPharnConfig.mockReturnValue( + archConfig([{ name: 'n-plus-one', role: 'lens' }]), + ); + write(join(proj, 'pharn-review/n-plus-one/n-plus-one.md'), 'N'); + + await runRemove('lens:n-plus-one'); + + expect(existsSync(join(proj, 'pharn-review/n-plus-one'))).toBe(false); + expect(lastWritten().archetypes).toEqual(['ssr']); + expect(lastWritten().capabilities).toEqual([]); + }); + + it('is a no-op for a capability that is not installed', async () => { + readPharnConfig.mockReturnValue( + archConfig([{ name: 'a11y', role: 'griller' }]), + ); + + await runRemove('ghost'); + + expect(prompts.outro).toHaveBeenCalledWith('Nothing was removed.'); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); + + it('exits(1) on a name installed in both roles (ambiguous)', async () => { + readPharnConfig.mockReturnValue( + archConfig([ + { name: 'dup', role: 'griller' }, + { name: 'dup', role: 'lens' }, + ]), + ); + + await expect(runRemove('dup')).rejects.toMatchObject(new ProcessExit(1)); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); + + it('no-arg picker selects from installed capabilities', async () => { + readPharnConfig.mockReturnValue( + archConfig([{ name: 'a11y', role: 'griller' }]), + ); + write(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'), 'A'); + vi.mocked(prompts.select).mockResolvedValue('griller:a11y'); + + await runRemove(undefined); + + expect(existsSync(join(proj, 'pharn-pipeline/grillers/a11y'))).toBe(false); + expect(lastWritten().capabilities).toEqual([]); + }); +}); diff --git a/tests/skills-version.test.ts b/tests/skills-version.test.ts new file mode 100644 index 0000000..624724e --- /dev/null +++ b/tests/skills-version.test.ts @@ -0,0 +1,69 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useTmpDir } from './helpers.js'; +import { + fetchRemoteSkillsVersion, + readSkillsVersion, +} from '../src/lib/skills-version.js'; +import { ManifestValidationError } from '../src/lib/validate.js'; + +function fakeResponse(body: string, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => String(body.length) }, + text: async () => body, + } as unknown as Response; +} + +describe('readSkillsVersion', () => { + const tmp = useTmpDir(); + + it('reads and trims a valid SKILLS_VERSION', () => { + writeFileSync(join(tmp.path(), 'SKILLS_VERSION'), '1.0.0\n'); + expect(readSkillsVersion(tmp.path())).toBe('1.0.0'); + }); + + it('throws when the file is missing', () => { + expect(() => readSkillsVersion(tmp.path())).toThrow( + ManifestValidationError, + ); + }); + + it('throws on a non-semver value (P2 validation)', () => { + writeFileSync(join(tmp.path(), 'SKILLS_VERSION'), 'not-a-version'); + expect(() => readSkillsVersion(tmp.path())).toThrow(/invalid format/); + }); +}); + +describe('fetchRemoteSkillsVersion', () => { + afterEach(() => vi.restoreAllMocks()); + + it('fetches and validates the remote version', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(fakeResponse('1.2.3\n')); + await expect(fetchRemoteSkillsVersion()).resolves.toBe('1.2.3'); + }); + + it('uses redirect:error and an abort signal (network guards)', async () => { + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(fakeResponse('1.0.0')); + await fetchRemoteSkillsVersion(); + const opts = spy.mock.calls[0]![1]!; + expect(opts.redirect).toBe('error'); + expect(opts.signal).toBeDefined(); + }); + + it('throws on a non-ok response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(fakeResponse('nope', 404)); + await expect(fetchRemoteSkillsVersion()).rejects.toThrow(/fetch failed/); + }); + + it('throws on an invalid remote value', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(fakeResponse('garbage!!')); + await expect(fetchRemoteSkillsVersion()).rejects.toThrow( + ManifestValidationError, + ); + }); +}); diff --git a/tests/status.test.ts b/tests/status.test.ts index 2b1b0ab..3ab8f53 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -23,10 +23,24 @@ const fetchRepo = vi.fn(); vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); const diffInstalled = vi.fn(); -vi.mock('../src/lib/diff.js', () => ({ diffInstalled })); +const diffInstalledCapabilities = vi.fn(); +vi.mock('../src/lib/diff.js', () => ({ + diffInstalled, + diffInstalledCapabilities, +})); const readPharnConfig = vi.fn(); -vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig })); +vi.mock('../src/lib/pharn-config.js', () => ({ + readPharnConfig, + isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), +})); + +const fetchRemoteSkillsVersion = vi.fn(); +const readSkillsVersion = vi.fn(); +vi.mock('../src/lib/skills-version.js', () => ({ + fetchRemoteSkillsVersion, + readSkillsVersion, +})); const { runStatus } = await import('../src/commands/status.js'); const prompts = await import('@clack/prompts'); @@ -188,3 +202,66 @@ describe('runStatus', () => { expect(fetchRepo).not.toHaveBeenCalled(); }); }); + +describe('runStatus (archetype)', () => { + stubProcessExit(); + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/proj'); + }); + afterEach(() => vi.clearAllMocks()); + + const archConfig = (): PharnConfig => + config({ + skillsVersion: '1.0.0', + modules: [], + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], + }); + + it('--no-drift: version via SKILLS_VERSION fetch, no manifest fetch, no clone', async () => { + readPharnConfig.mockReturnValue(archConfig()); + fetchRemoteSkillsVersion.mockResolvedValue('1.0.0'); + + await runStatus({ drift: false }); + + expect(fetchRemoteSkillsVersion).toHaveBeenCalled(); + expect(fetchRemoteManifest).not.toHaveBeenCalled(); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(noteBody('VERSION')).toContain('ssr'); + }); + + it('default: clones, reads SKILLS_VERSION, diffs capabilities, cleans up', async () => { + readPharnConfig.mockReturnValue(archConfig()); + const cleanup = vi.fn(); + fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); + readSkillsVersion.mockReturnValue('1.0.0'); + diffInstalledCapabilities.mockReturnValue(CLEAN); + + await runStatus({}); + + expect(readSkillsVersion).toHaveBeenCalledWith('/repo'); + expect(diffInstalledCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ repoDir: '/repo', projectRoot: '/proj' }), + ); + // The legacy module-diff path is not taken for an archetype config. + expect(diffInstalled).not.toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalled(); + }); + + it('--strict exits 1 on capability drift, cleaning up first', async () => { + readPharnConfig.mockReturnValue(archConfig()); + const cleanup = vi.fn(); + fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); + readSkillsVersion.mockReturnValue('1.0.0'); + diffInstalledCapabilities.mockReturnValue({ + modified: ['pharn-pipeline/grillers/a11y/a11y.md'], + missing: [], + okCount: 3, + }); + + await expect(runStatus({ strict: true })).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(cleanup).toHaveBeenCalled(); + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index a262c93..06e1967 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -19,11 +19,32 @@ vi.mock('../src/lib/manifest.js', () => ({ fetchRemoteManifest })); const fetchAndInstall = vi.fn(); vi.mock('../src/lib/installer.js', () => ({ fetchAndInstall })); +const fetchRepo = vi.fn(); +const fetchCommitSha = vi.fn(); +vi.mock('../src/lib/repo.js', () => ({ fetchRepo, fetchCommitSha })); + +const fetchRemoteSkillsVersion = vi.fn(); +const readSkillsVersion = vi.fn(); +vi.mock('../src/lib/skills-version.js', () => ({ + fetchRemoteSkillsVersion, + readSkillsVersion, +})); + +const parseCapabilityIndex = vi.fn(); +vi.mock('../src/lib/capability-index.js', () => ({ parseCapabilityIndex })); + +const resolveCapabilities = vi.fn(); +vi.mock('../src/lib/resolve-capabilities.js', () => ({ resolveCapabilities })); + +const installCapabilities = vi.fn(); +vi.mock('../src/lib/install-capabilities.js', () => ({ installCapabilities })); + const readPharnConfig = vi.fn(); const writePharnConfig = vi.fn(); vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig, writePharnConfig, + isArchetypeConfig: (c: PharnConfig) => Array.isArray(c.capabilities), toInstalledModules: (m: { name: string; version: string }[]) => m.map(({ name, version }) => ({ name, version })), })); @@ -143,3 +164,77 @@ describe('runUpdate', () => { ]); }); }); + +describe('runUpdate (archetype)', () => { + stubProcessExit(); + beforeEach(() => vi.spyOn(process, 'cwd').mockReturnValue('/proj')); + afterEach(() => vi.clearAllMocks()); + + const archConfig: PharnConfig = { + ...config, + skillsVersion: '1.0.0', + modules: [], + archetypes: ['ssr'], + capabilities: [{ name: 'a11y', role: 'griller' }], + }; + + it('reports already up to date without cloning', async () => { + readPharnConfig.mockReturnValue(archConfig); + fetchRemoteSkillsVersion.mockResolvedValue('1.0.0'); + + await runUpdate(); + + expect(prompts.outro).toHaveBeenCalledWith( + 'Already up to date (skills v1.0.0).', + ); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(fetchRemoteManifest).not.toHaveBeenCalled(); + }); + + it('re-resolves + re-copies capabilities after confirmation', async () => { + readPharnConfig.mockReturnValue(archConfig); + fetchRemoteSkillsVersion.mockResolvedValue('1.1.0'); + vi.mocked(prompts.confirm).mockResolvedValue(true); + const cleanup = vi.fn(); + fetchRepo.mockResolvedValue({ dir: '/repo', cleanup }); + parseCapabilityIndex.mockReturnValue({ capabilities: [] }); + resolveCapabilities.mockReturnValue({ + selected: [ + { name: 'a11y', role: 'griller', matched: ['ssr'] }, + { name: 'security', role: 'griller', matched: 'universal' }, + ], + skipped: [], + }); + readSkillsVersion.mockReturnValue('1.1.0'); + fetchCommitSha.mockResolvedValue('sha'); + + await runUpdate(); + + // Re-resolves the RECORDED archetypes against the fresh index. + expect(resolveCapabilities).toHaveBeenCalledWith(['ssr'], { + capabilities: [], + }); + expect(installCapabilities).toHaveBeenCalledWith( + '/repo', + '/proj', + expect.anything(), + ); + const [, written] = writePharnConfig.mock.calls[0]!; + expect((written as PharnConfig).skillsVersion).toBe('1.1.0'); + expect((written as PharnConfig).capabilities).toEqual([ + { name: 'a11y', role: 'griller' }, + { name: 'security', role: 'griller' }, + ]); + expect(cleanup).toHaveBeenCalled(); + }); + + it('cancels when declined — no clone, no copy', async () => { + readPharnConfig.mockReturnValue(archConfig); + fetchRemoteSkillsVersion.mockResolvedValue('1.1.0'); + vi.mocked(prompts.confirm).mockResolvedValue(false); + + await expect(runUpdate()).rejects.toMatchObject(new ProcessExit(0)); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(installCapabilities).not.toHaveBeenCalled(); + }); +}); From 430cb7c004a7e839a978c7257ebc58beae02779e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Tue, 7 Jul 2026 20:56:51 +0200 Subject: [PATCH 4/4] fix --- src/lib/install-capabilities.ts | 33 +++++++-- tests/install-capabilities.test.ts | 107 ++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/lib/install-capabilities.ts b/src/lib/install-capabilities.ts index d240077..1a9ab9a 100644 --- a/src/lib/install-capabilities.ts +++ b/src/lib/install-capabilities.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs'; import { safeJoin } from './install-modules.js'; import { assertNoDotDot, @@ -53,6 +53,15 @@ export interface InstallCapabilitiesResult { const isTestFile = (p: string): boolean => /\.test\.(mjs|cjs)$/.test(p); +// Trust (P2): the fetched repo is untrusted, and a recursive cpSync copies +// symlinks VERBATIM by default — so a malicious clone could plant a symlink +// (e.g. onto a file outside the project) that lands in the user's tree and is +// later followed by their tools. Reject symlinks the same way copyFilteredDir +// does (skip, never materialize): `isSymlink` guards each whole-dir/single-file +// copy root; `noSymlinks` is the cpSync filter that skips nested symlinks. +const isSymlink = (p: string): boolean => lstatSync(p).isSymbolicLink(); +const noSymlinks = (src: string): boolean => !isSymlink(src); + /** * Copy the resolved capabilities + the fixed product surfaces from `repoDir` * into `projectRoot`. Pre-flights every selected capability source before any @@ -81,13 +90,18 @@ export function installCapabilityDirs( `Capability "${cap.name}" (${cap.role}) is missing at ${subtree}/${cap.name} in the fetched repo.`, ); } + if (isSymlink(from)) { + throw new ManifestValidationError( + `Capability "${cap.name}" (${cap.role}) is a symlink at ${subtree}/${cap.name}; refusing to copy from the untrusted repo.`, + ); + } return { name: cap.name, role: cap.role, subtree, from }; }); const installed: InstalledCapability[] = []; for (const cap of planned) { const to = safeJoin(projectRoot, `${cap.subtree}/${cap.name}`); - cpSync(cap.from, to, { recursive: true, force: true }); + cpSync(cap.from, to, { recursive: true, force: true, filter: noSymlinks }); installed.push({ name: cap.name, role: cap.role }); } return installed; @@ -122,7 +136,11 @@ export function installCapabilities( const settingsFrom = safeJoin(repoDir, CLAUDE_SETTINGS_FILE); const settingsTo = safeJoin(projectRoot, CLAUDE_SETTINGS_FILE); const settingsPreserved = existsSync(settingsTo); - if (!settingsPreserved && existsSync(settingsFrom)) { + if ( + !settingsPreserved && + existsSync(settingsFrom) && + !isSymlink(settingsFrom) + ) { mkdirSync(safeJoin(projectRoot, '.claude'), { recursive: true }); cpSync(settingsFrom, settingsTo, { force: true }); } @@ -130,27 +148,28 @@ export function installCapabilities( // --- trusted docs (root) --------------------------------------------------- for (const doc of TRUSTED_DOCS) { const from = safeJoin(repoDir, doc); - if (existsSync(from)) { + if (existsSync(from) && !isSymlink(from)) { cpSync(from, safeJoin(projectRoot, doc), { force: true }); } } // --- pharn-contracts/ (whole dir) ------------------------------------------ const contractsFrom = safeJoin(repoDir, CONTRACTS_DIR); - if (existsSync(contractsFrom)) { + if (existsSync(contractsFrom) && !isSymlink(contractsFrom)) { cpSync(contractsFrom, safeJoin(projectRoot, CONTRACTS_DIR), { recursive: true, force: true, + filter: noSymlinks, }); } // --- .dev/floor/ (checkers only — test files excluded) --------------------- const floorFrom = safeJoin(repoDir, FLOOR_DIR); - if (existsSync(floorFrom)) { + if (existsSync(floorFrom) && !isSymlink(floorFrom)) { cpSync(floorFrom, safeJoin(projectRoot, FLOOR_DIR), { recursive: true, force: true, - filter: (src) => !isTestFile(src), + filter: (src) => !isTestFile(src) && noSymlinks(src), }); } diff --git a/tests/install-capabilities.test.ts b/tests/install-capabilities.test.ts index 2b0af67..daf562d 100644 --- a/tests/install-capabilities.test.ts +++ b/tests/install-capabilities.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { useTmpDir } from './helpers.js'; @@ -14,6 +22,18 @@ function write(path: string, content = 'x'): void { writeFileSync(path, content); } +// True if ANYTHING exists at `path` (file, dir, OR symlink). Uses lstatSync so a +// copied symlink is detected even when its target is absent (existsSync follows +// the link and would report a dangling symlink as missing). +function isPresent(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} + // A fake fetched pharn-oss clone with the full product + dev surface, so the // copy's dev-only exclusions can be asserted. function scaffoldRepo(repo: string): void { @@ -200,6 +220,91 @@ describe('installCapabilities', () => { false, ); }); + + // --- symlink rejection (P2): the fetched repo is untrusted; a recursive cpSync + // copies symlinks verbatim by default, so a malicious clone could plant one + // into the user's tree. Sources below point at REAL files so existsSync passes + // and the isSymlink/filter guards (not the missing-source path) are exercised. + + it('does NOT copy a symlink planted inside a selected capability dir', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + symlinkSync( + join(repo, 'pharn-pipeline/grillers/a11y/a11y.md'), + join(repo, 'pharn-pipeline/grillers/a11y/evil-link'), + ); + installCapabilities(repo, proj, selection()); + // real files copied; the nested symlink was skipped by the cpSync filter. + expect(existsSync(join(proj, 'pharn-pipeline/grillers/a11y/a11y.md'))).toBe( + true, + ); + expect( + isPresent(join(proj, 'pharn-pipeline/grillers/a11y/evil-link')), + ).toBe(false); + }); + + it('rejects (pre-flight, nothing written) a selected capability whose source dir is a symlink', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + rmSync(join(repo, 'pharn-pipeline/grillers/a11y'), { + recursive: true, + force: true, + }); + // swap the griller dir for a symlink onto another real capability dir. + symlinkSync( + join(repo, 'pharn-review/n-plus-one'), + join(repo, 'pharn-pipeline/grillers/a11y'), + ); + expect(() => installCapabilities(repo, proj, selection())).toThrow( + ManifestValidationError, + ); + // pre-flight threw before any copy: no product surfaces leaked in. + expect(existsSync(join(proj, '.claude/commands/pharn-plan.md'))).toBe( + false, + ); + }); + + it('does NOT copy symlinked fixed surfaces (settings, trusted docs, contracts, floor)', () => { + const repo = join(tmp.path(), 'repo'); + const proj = join(tmp.path(), 'proj'); + mkdirSync(proj, { recursive: true }); + scaffoldRepo(repo); + // single-file surfaces: replace the real file with a symlink onto a real file. + rmSync(join(repo, '.claude/settings.json')); + symlinkSync( + join(repo, 'CONSTITUTION.md'), + join(repo, '.claude/settings.json'), + ); + rmSync(join(repo, 'ARCHITECTURE.md')); + symlinkSync(join(repo, 'CONSTITUTION.md'), join(repo, 'ARCHITECTURE.md')); + // whole-dir surfaces: plant a nested symlink alongside the real files. + symlinkSync( + join(repo, 'pharn-contracts/finding-shape.md'), + join(repo, 'pharn-contracts/evil-link'), + ); + symlinkSync( + join(repo, '.dev/floor/validate.mjs'), + join(repo, '.dev/floor/evil-link'), + ); + + const result = installCapabilities(repo, proj, selection()); + + // symlinked single-file surfaces skipped (settings not preserved, not copied). + expect(result.settingsPreserved).toBe(false); + expect(isPresent(join(proj, '.claude/settings.json'))).toBe(false); + expect(isPresent(join(proj, 'ARCHITECTURE.md'))).toBe(false); + // real siblings still copied; planted nested symlinks skipped. + expect(existsSync(join(proj, 'pharn-contracts/finding-shape.md'))).toBe( + true, + ); + expect(isPresent(join(proj, 'pharn-contracts/evil-link'))).toBe(false); + expect(existsSync(join(proj, '.dev/floor/validate.mjs'))).toBe(true); + expect(isPresent(join(proj, '.dev/floor/evil-link'))).toBe(false); + }); }); describe('installCapabilityDirs', () => {