diff --git a/CHANGELOG.md b/CHANGELOG.md index 14efeb4..e8a7d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`pharn remove `** — the inverse of `pharn add`. Removing a + skill is a precise single-directory delete with no clone or network; removing a module + clones once, computes the exact files that module contributed (so shared directories like + `commands/` keep other modules' files), deletes only those, and prunes emptied directories. + Refuses to remove `pharn-core` or a module with installed dependents, never touches + `CONSTITUTION.md` / `memory-bank/`, and updates `pharn.config.json` to match. No arg opens + an interactive picker; `--yes`/`-y` skips the confirmation and `rm` is an alias. +- **`pharn list`** — a read-only inventory of installed vs. available modules and + `category:skill` skills, with update markers when the manifest is newer. Adds `--json` + for scripting/CI (single object on stdout; diagnostics on stderr). Never writes or clones. +- **`pharn status`** — a read-only audit of the install: a version section (is `skillsVersion` + / each module current?) and a drift section that clones `pharn-dev/pharn-oss@main` and + byte-compares every PHARN-owned file against `.claude/`, reporting locally-modified and + missing files. Never writes, deletes, or overwrites — the temporary clone is always cleaned + up. `.claude/CONSTITUTION.md` and `.claude/memory-bank/` are excluded (hand-edited, anchored + at the root, so `templates/` is still diffed). `--strict` exits 1 on any drift/outdated for + CI; `--no-drift` skips the clone and checks the version only. - Repo-health tooling: `CHANGELOG.md`, GitHub issue/PR templates, `CODEOWNERS`, Dependabot config, markdownlint for docs, an aggregate `npm run check` script, and an enforced test-coverage gate in CI. @@ -21,6 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Docs: completed the getting-started day-to-day loop with `/pharn-regress` and refreshed the `pharn.config.json` example module versions to match the current `pharn-oss` manifest (`skillsVersion` 0.70.0). +- Docs: the root `README.md` Commands table and the `pharn -h` help text now document the + already-implemented `add :` form (install one technology skill, e.g. + `orm:prisma`) alongside the whole-module `add ` form. No CLI code change. ## [0.2.0] — 2026-06-11 diff --git a/CLAUDE.md b/CLAUDE.md index 8551ac4..7e1c959 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ ESM-only (`"type": "module"`, NodeNext). **Relative imports must use `.js` exten ## Architecture -`src/index.ts` parses argv with minimist and dispatches to `commands/{init,add,update}.ts`. `init` is the default command. +`src/index.ts` parses argv with minimist and dispatches to `commands/{init,add,remove,update,list,status}.ts`. `init` is the default command. `list` is read-only (reads `pharn.config.json` + the remote manifest; no clone, no writes) and shares the `add`/`update` no-config exit + manifest-fetch patterns; `--json` emits a single inventory object on stdout (diagnostics to stderr). `status` is also read-only (the read side of `update`) — see its addressing note below. `remove` (alias `rm`, `--yes`/`-y` skips its confirm) is the inverse of `add` — see its addressing note below. **`commands/init.ts` is a step pipeline.** Each `steps/*.ts` file is one stage and uses `@clack/prompts` for I/O: @@ -54,6 +54,10 @@ ESM-only (`"type": "module"`, NodeNext). **Relative imports must use `.js` exten **`pharn add` addressing.** `add ` installs a whole methodology module / stack pack (v1 + v2). `add :` (v2 only, e.g. `add orm:prisma`) maps `` → `pharn-skills-`, resolves the wizard option, and installs just that skill: already-installed → no-op; a different sibling of the same category already installed → confirm before installing alongside (appends to `installedSkills`, **never** edits `stackAnswers`); unknown → lists valid addresses. `pharn update` re-resolves `installedSkills` against the new wizard and drops (reporting, never guessing) any `from` path that no longer exists upstream. +**`pharn remove` addressing** (`commands/remove.ts`) is the inverse, with the same two forms. `remove :` deletes that one isolated `.claude/skills//` dir and drops its `installedSkills` entry — **no clone, no network** (everything is derivable from `installedSkills` + the filesystem), **never** edits `stackAnswers`/`modules`; not-installed → no-op listing the installed (removable) skills. `remove ` clones once and reads the manifest **from the clone** (`@main` HEAD, not the pinned `commit` — hence a possible orphan if a path was renamed upstream; documented), then **refuses** (before any write/delete) `pharn-core`, an uninstalled module, or one with installed dependents (named). Because modules merge into shared dest dirs, it never `rm -rf`s a dest dir: it `statSync`-branches each `installs` entry (file vs. dir), walks the source to map the **exact** contributed files into `.claude/`, deletes only those, prunes emptied dirs (never one holding a surviving module's files), and **never** touches `CONSTITUTION.md`/`memory-bank/`. No arg → an interactive picker that pre-applies these guards so it only offers genuinely removable items. The shared delete core is `planAndApplyModuleRemoval`; cleanup runs in a `finally` and all `process.exit`/`cancelAndExit` calls happen *after* it (Node skips `finally` on `process.exit`). + +**`pharn status` (`commands/status.ts`) is strictly read-only** — it never writes, deletes, or overwrites (fixing is `update`/`add`). Two sections: a **version** check (reuses `update`'s `skillsVersion` + per-module `changes` diff; overlaps `list` by design) and a **drift** check. Default clones `@main` once and reuses it for both (the HEAD manifest is the "latest" for version *and* the source for drift); `--no-drift` skips the clone and uses `fetchRemoteManifest` for the version section only; `--strict` exits 1 on any outdated/modified/missing (CI gate, default exit 0). Cleanup runs in a `finally`, and every `process.exit` happens *after* it. The pure (no I/O) engine is **`lib/diff.ts` → `diffInstalled`**: it mirrors `installModule`/`installSkills` to derive the **expected** file set (`statSync`-branching each `installs` entry — file vs. dir — and walking dirs; skills → `skills//`), then `sha256`-compares each against `.claude/`, returning `{modified, missing, okCount}`. It reuses `install-modules.ts`'s exported `safeJoin` to guard every read. **It excludes the two user-owned surfaces — `CONSTITUTION.md` and `memory-bank/` — anchored at the `.claude/` root** (so the PHARN-owned `templates/memory-bank/` sources are still diffed); this exclusion is a correctness requirement. Drift is derived live from the clone (no stored hashes), always against `@main`, never the pinned `commit`. + ## Testing Tests live in `tests/*.test.ts` (vitest, node env). `tests/helpers.ts` provides `stubProcessExit` (turns `process.exit` into a throwable `ProcessExit`), `useTmpDir`, and `CANCEL`. The lib tests build fake fetched-repos on disk to exercise copy/materialize without network. When changing behavior, update the matching test before touching code. diff --git a/README.md b/README.md index 37a5ae6..497c1a8 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The wizard also asks a **privacy-posture** question and writes the matching cons Full reference: **[docs/](docs/README.md)** - [Getting started](docs/getting-started.md) -- [Commands](docs/commands/init.md) — `init`, `add`, `update` +- [Commands](docs/commands/init.md) — `init`, `add`, `remove`, `update`, `list`, `status` - [pharn.config.json](docs/reference/pharn-config.md) - [Troubleshooting](docs/troubleshooting.md) @@ -44,7 +44,11 @@ See [Getting started](docs/getting-started.md) for the full flow and fresh-proje | ------- | ----------- | | `pharn init` | Interactive setup wizard (default) | | `pharn add ` | Add a module to an existing PHARN project | +| `pharn add :` | Add one technology skill (e.g. `orm:prisma`) | +| `pharn remove ` | Remove a module or skill from this project | | `pharn update` | Update installed modules to the latest skills version | +| `pharn list` | List installed and available modules/skills | +| `pharn status` | Show version + local-drift status (read-only; `--strict`, `--no-drift`) | | `pharn -h`, `--help` | Show help | | `pharn -v`, `--version` | Show version | diff --git a/docs/README.md b/docs/README.md index 64010dd..b7ee471 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,10 @@ - [init](commands/init.md) — interactive setup wizard - [add](commands/add.md) — add a module to an existing project +- [remove](commands/remove.md) — remove a module or skill from an existing project - [update](commands/update.md) — update installed modules to the latest skills version +- [list](commands/list.md) — list installed and available modules/skills +- [status](commands/status.md) — read-only version + local-drift audit ## Reference diff --git a/docs/commands/list.md b/docs/commands/list.md new file mode 100644 index 0000000..9e53835 --- /dev/null +++ b/docs/commands/list.md @@ -0,0 +1,51 @@ +# pharn list + +Show what PHARN modules and skills are installed in this project (one with a `pharn.config.json`) and what is still available to add. **Read-only** — it never writes files and never clones the repo. + +```bash +pharn list # human-readable inventory +pharn list --json # machine-readable JSON (for scripts / CI) +``` + +## Behavior + +1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. +2. Fetches `manifest.json` to learn the latest versions and the available catalog. (No clone — only the same lightweight fetch `pharn update` uses.) +3. Prints **INSTALLED**: + - your skills version, flagged `→ vX (update available, run pharn update)` when the manifest is newer; + - each installed module with its version, flagged `→ vX` when the manifest has a newer one; + - any individually installed technology skills (schemaVersion 2). +4. Prints **AVAILABLE TO ADD**: + - optional modules and stack packs you don't have yet — the same set `pharn add` offers; + - on a `schemaVersion 2` manifest, every `category:skill` you haven't installed. + + A group with nothing left shows `(all installed)`. + +Nothing is ever written, and the repo is never cloned. + +## JSON output + +`pharn list --json` prints a single JSON object and nothing else (no spinner, intro, or outro), suitable for scripting: + +```json +{ + "skillsVersion": "0.69.0", + "latestSkillsVersion": "0.70.0", + "installed": { + "modules": [{ "name": "pharn-core", "version": "0.1.0", "latest": "0.1.0" }], + "skills": [{ "skill": "prisma", "from": "pharn-skills-orm/skills/prisma" }] + }, + "available": { + "modules": [{ "name": "pharn-review", "version": "0.4.0", "description": "…" }], + "skills": [{ "category": "orm", "skill": "drizzle", "install": "pharn-skills-orm/skills/drizzle" }] + } +} +``` + +Diagnostics (such as a missing config or a failed fetch) go to **stderr**, and the exit code is non-zero on failure — so stdout always parses cleanly. + +## Related + +- [add](add.md) +- [update](update.md) +- [pharn.config.json](../reference/pharn-config.md) diff --git a/docs/commands/remove.md b/docs/commands/remove.md new file mode 100644 index 0000000..199156a --- /dev/null +++ b/docs/commands/remove.md @@ -0,0 +1,55 @@ +# pharn remove + +Remove a PHARN module or a single technology skill from an existing project (one with a `pharn.config.json`). The inverse of [`add`](add.md): it deletes exactly the files the module or skill contributed and updates `pharn.config.json` to match. + +```bash +pharn remove # a whole methodology module / stack pack +pharn remove : # a single technology skill (schemaVersion 2) +pharn remove # interactive picker +pharn remove --yes # skip the confirmation prompt +``` + +`rm` is accepted as an alias for `remove`. + +## Behavior + +1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. +2. An argument containing `:` removes a **skill** (§A); any other argument removes a **module** (§B); no argument opens the **interactive picker**, which lists only items that are genuinely removable (so you never pick something the command would refuse). + +`CONSTITUTION.md` and everything under `memory-bank/` are **never** deleted — they are user-owned and materialized only at `init`. + +## Removing a skill (`:`) + +Each skill lives in its own isolated `.claude/skills//` directory, so removal is precise and needs **no network and no clone** — the CLI works entirely from `installedSkills` plus your filesystem. + +```bash +pharn remove orm:prisma # deletes .claude/skills/prisma/ +pharn remove auth:clerk # deletes .claude/skills/clerk/ +``` + +- Deletes the skill's directory and drops its entry from `installedSkills`. +- **Does not** touch `stackAnswers`, `modules`, `skillsVersion`, or `commit` — your recorded wizard answer stays authoritative. +- **Not installed** → a no-op (nothing is written). For an unrecognized address, the CLI lists the skills you actually have installed as the valid values. +- Already-deleted directory → treated as done (idempotent). + +## Removing a module (``) + +Modules share destination directories (`pharn-core`, `pharn-pipeline`, `pharn-review`, and `pharn-audits` all merge into `commands/`, `skills/`, `hooks/`, …), so `remove` never wipes a directory. It clones `pharn-dev/pharn-oss` once, reads the module's `installs` from the clone, computes the **exact** set of files that module contributed, and deletes only those — then prunes any directories left empty (never one that still holds another module's files). + +Refusals (checked **before** anything is deleted or written — no partial removals): + +- **`pharn-core`** is required by every other module and cannot be removed. +- A module that **is not installed** is reported and nothing happens. +- A module with **installed dependents** is refused, naming them — remove those first. (There is no automatic cascade.) This also blocks removing a stack pack's base, e.g. `pharn-stack-react` while `pharn-stack-nextjs` is installed. + +Unless `--yes` / `-y` is given, the CLI confirms before deleting. `modules` in `pharn.config.json` is rewritten to the resolved survivor set; `skillsVersion`, `commit`, `stackAnswers`, and `installedSkills` are left unchanged. + +### Orphan caveat + +The contributed file set is computed from the repository's `main` branch, **not** the pinned `commit` recorded at install. If a file was renamed or removed upstream since you installed, an orphaned copy may remain in `.claude/`. This is acceptable for now; `pharn status` is planned to surface such orphans (see the [roadmap](../roadmap.md)). + +## Related + +- [add](add.md) +- [list](list.md) +- [pharn.config.json](../reference/pharn-config.md) diff --git a/docs/commands/status.md b/docs/commands/status.md new file mode 100644 index 0000000..3e2e610 --- /dev/null +++ b/docs/commands/status.md @@ -0,0 +1,40 @@ +# pharn status + +Read-only audit of your install: is it on the latest version, and have any PHARN-owned files drifted from upstream? + +```bash +pharn status +pharn status --no-drift # version check only (skips the clone) +pharn status --strict # exit 1 if outdated, modified, or missing (for CI) +``` + +`status` is the read side of [`update`](update.md): it surfaces the same state `update` would overwrite, but **never writes, deletes, or overwrites anything**. It is a report, not a guard. + +## Behavior + +1. Reads `pharn.config.json`. If none exists, exits with a hint to run `pharn init` first. +2. By default, clones `pharn-dev/pharn-oss@main` once and reuses it for both sections below (the temporary clone is always cleaned up). `--no-drift` skips the clone and uses the lightweight `manifest.json` fetch for the version section only. +3. **Version** — compares your `skillsVersion` and per-module versions against the manifest, then reports either "up to date" or that an update is available (run `pharn update`). This overlaps with part of [`pharn list`](list.md) by design. +4. **Drift** — derives the set of files each installed module and skill is expected to contribute (mirroring how `init` / `add` / `update` install them), then byte-compares each against your `.claude/`: + - **Locally modified (PHARN-owned)** — files present but whose contents differ. `pharn update` will overwrite these. + - **Missing (expected but absent)** — files an installed module/skill expects that aren't on disk. Re-run `pharn update` (or `pharn add`) to restore them. + - If neither, reports **No drift**. + +The comparison is always against `pharn-dev/pharn-oss@main` (the same ref the rest of the CLI installs from), not the `commit` pinned in your config. + +## What is intentionally excluded + +`.claude/CONSTITUTION.md` and everything under `.claude/memory-bank/` are **never** flagged. They are materialized once at `init` and are meant to be hand-edited — `update` never touches them, so reporting local edits to them would be noise. + +This exclusion is anchored at the `.claude/` root. The pristine template **sources** under `.claude/templates/` (including `templates/memory-bank/` and `templates/constitution/`) are PHARN-owned and **are** diffed — only the materialized working copies at the root are skipped. + +## Exit code + +Exits `0` by default, even when drift or an available update is found (it is a report). Pass `--strict` to exit `1` whenever anything is outdated, modified, or missing — useful as a CI gate. + +## Related + +- [update](update.md) — apply the fixes `status` reports (overwrite drift, restore missing) +- [list](list.md) — read-only inventory of installed vs. available modules/skills +- [add](add.md) — install a module or a single `category:skill` +- [pharn.config.json](../reference/pharn-config.md) — `skillsVersion`, `modules`, `installedSkills` diff --git a/docs/roadmap.md b/docs/roadmap.md index 1745b00..e9b21d8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,10 @@ What PHARN CLI does today versus what is planned. | Materialize `memory-bank/` and the chosen `CONSTITUTION.md` | Shipped | | Pin commit SHA + write `pharn.config.json` | Shipped | | `pharn add ` — add a module to an existing project | Shipped | +| `pharn remove ` — remove a module or skill | Shipped | | `pharn update` — refresh installed modules to the latest version | Shipped | +| `pharn list` — show installed + available modules/skills (read-only, `--json`) | Shipped | +| `pharn status` — read-only version + local-drift report (modified/missing PHARN-owned files; `--strict`, `--no-drift`) | Shipped | ## Planned @@ -25,6 +28,7 @@ What PHARN CLI does today versus what is planned. | Stack scaffolding | Install npm packages / generate app code from the stack pack | | Additional stack packs | Beyond `pharn-stack-nextjs` | | Migration for existing projects | Onboard repos with significant git history (today the CLI only warns) | +| Orphaned-file detection in `pharn status` | `status` today reports modified + missing PHARN-owned files; flagging files left orphaned in `.claude/` after an upstream rename (see [remove](commands/remove.md)) is not built yet | | Other agents | Codex and Cursor in addition to Claude Code | ## Related @@ -32,5 +36,8 @@ What PHARN CLI does today versus what is planned. - [Getting started](getting-started.md) - [init](commands/init.md) - [add](commands/add.md) +- [remove](commands/remove.md) - [update](commands/update.md) +- [list](commands/list.md) +- [status](commands/status.md) - [pharn.config.json](reference/pharn-config.md) diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..df18289 --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,193 @@ +import { intro, log, note, outro, spinner } from '@clack/prompts'; +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'; + +// 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. +interface ListInventory { + skillsVersion: string; + latestSkillsVersion: string; + installed: { + modules: { name: string; version: string; latest: string | null }[]; + skills: { skill: string; from: string }[]; + }; + available: { + modules: { name: string; version: string; description: string }[]; + skills: { category: string; skill: string; install: string }[]; + }; +} + +// 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 { + const json = opts.json ?? false; + const cwd = process.cwd(); + if (!json) intro('pharn list'); + + const config = readPharnConfig(cwd); + if (!config) { + emitError('No pharn.config.json found. Run `pharn init` first.', json); + process.exit(1); + } + + const manifest = await loadManifest(json); + const inventory = buildInventory(config, manifest); + + if (json) { + // stdout carries only the JSON object; diagnostics went to stderr above. + console.log(JSON.stringify(inventory, null, 2)); + return; + } + + const hasWizard = manifest.schemaVersion === 2 && Boolean(manifest.wizard); + renderHuman(inventory, hasWizard); +} + +function buildInventory( + config: PharnConfig, + manifest: Manifest, +): ListInventory { + const latestByName = new Map( + manifest.modules.map((m) => [m.name, m.version]), + ); + + const installedModules = config.modules.map((m) => ({ + name: m.name, + version: m.version, + latest: latestByName.get(m.name) ?? null, + })); + const installedSkills = (config.installedSkills ?? []).map((s) => ({ + skill: s.skill, + from: s.from, + })); + + // Mirror `add`'s addable set exactly so the two commands never disagree. + const { optional, stackPacks } = categorizeModules(manifest); + const installedNames = new Set(config.modules.map((m) => m.name)); + const availableModules = [...optional, ...stackPacks] + .filter((m) => !installedNames.has(m.name)) + .map((m) => ({ + name: m.name, + version: m.version, + description: m.description, + })); + + // schemaVersion 2 only: every wizard skill not already recorded (matched by + // its `install` path against installedSkills `from`). + let availableSkills: ListInventory['available']['skills'] = []; + if (manifest.schemaVersion === 2 && manifest.wizard) { + const installedFroms = new Set( + (config.installedSkills ?? []).map((s) => s.from), + ); + availableSkills = listSkillAddresses(manifest.wizard) + .filter((a) => !installedFroms.has(a.install)) + .map((a) => ({ + category: a.category, + skill: a.skill, + install: a.install, + })); + } + + return { + skillsVersion: config.skillsVersion, + latestSkillsVersion: manifest.skillsVersion, + installed: { modules: installedModules, skills: installedSkills }, + available: { modules: availableModules, skills: availableSkills }, + }; +} + +function renderHuman(inv: ListInventory, hasWizard: boolean): void { + const skillsLine = row('Skills version', `v${inv.skillsVersion}`); + const installed: string[] = [ + inv.latestSkillsVersion !== inv.skillsVersion + ? `${skillsLine} ${pc.dim( + `→ v${inv.latestSkillsVersion} (update available, run \`pharn update\`)`, + )}` + : skillsLine, + '', + ' MODULES', + ]; + for (const m of inv.installed.modules) { + const base = row(m.name, `v${m.version}`); + installed.push( + m.latest && m.latest !== m.version + ? `${base} ${pc.dim(`→ v${m.latest}`)}` + : base, + ); + } + if (inv.installed.skills.length > 0) { + installed.push('', ' SKILLS'); + for (const s of inv.installed.skills) { + installed.push(row(` ${s.skill}`, pc.dim(s.from))); + } + } + note(installed.join('\n'), 'INSTALLED'); + + const available: string[] = [' MODULES']; + if (inv.available.modules.length > 0) { + for (const m of inv.available.modules) { + available.push( + row( + m.name, + `v${m.version} ${pc.dim(shortDescription(m.description))}`, + ), + ); + } + } else { + available.push(' (all installed)'); + } + // schemaVersion 1 has no wizard, so there is no skills concept to list. + if (hasWizard) { + available.push('', ' SKILLS'); + if (inv.available.skills.length > 0) { + for (const a of inv.available.skills) { + available.push(` ${a.category}:${a.skill}`); + } + } else { + available.push(' (all installed)'); + } + } + note(available.join('\n'), 'AVAILABLE TO ADD'); + + outro( + pc.dim( + 'Read-only — nothing changed. `pharn add ` to install, `pharn update` to upgrade.', + ), + ); +} + +function emitError(message: string, json: boolean): void { + // JSON mode keeps stdout pure for the object, so errors go to stderr. + if (json) console.error(message); + else log.error(message); +} + +async function loadManifest(json: boolean): Promise { + if (json) { + try { + return await fetchRemoteManifest(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(message); + if (process.env.PHARN_DEBUG) console.error(err); + process.exit(1); + } + } + const s = spinner(); + s.start('Fetching module catalog'); + try { + const manifest = await fetchRemoteManifest(); + s.stop('Module catalog loaded'); + return manifest; + } catch (err) { + s.stop('Failed to load module catalog'); + const message = err instanceof Error ? err.message : String(err); + log.error(`⚠ ${message}`); + if (process.env.PHARN_DEBUG) console.error(err); + process.exit(1); + } +} diff --git a/src/commands/remove.ts b/src/commands/remove.ts new file mode 100644 index 0000000..4f4c81a --- /dev/null +++ b/src/commands/remove.ts @@ -0,0 +1,522 @@ +import { existsSync, readdirSync, rmSync, rmdirSync, statSync } from 'node:fs'; +import { basename, dirname, resolve, sep } from 'node:path'; +import { + confirm, + intro, + isCancel, + log, + outro, + select, + spinner, +} from '@clack/prompts'; +import pc from 'picocolors'; +import { cancelAndExit } from '../lib/confirm.js'; +import { + CORE_MODULE, + REPO_URL, + SKILL_MODULE_PREFIX, +} from '../lib/constants.js'; +import { + readManifest, + readModuleManifest, + resolveModules, +} from '../lib/manifest.js'; +import { fetchRepo, type FetchedRepo } from '../lib/repo.js'; +import { readPharnConfig, writePharnConfig } from '../lib/pharn-config.js'; +import { ManifestValidationError } from '../lib/validate.js'; +import type { InstalledSkill, ModuleManifest, PharnConfig } from '../types.js'; + +// The inverse of `pharn add`. Removes a methodology module (clones to learn the +// exact files it contributed) or a single category:skill (precise single-dir +// delete — no clone, no network), then updates pharn.config.json to match. +export async function runRemove( + arg: string | undefined, + opts: { yes?: boolean } = {}, +): Promise { + intro('pharn remove'); + + const cwd = process.cwd(); + const config = readPharnConfig(cwd); + if (!config) { + log.error('No pharn.config.json found. Run `pharn init` first.'); + process.exit(1); + } + const claudeDir = resolve(cwd, '.claude'); + + // 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(':')) { + await removeSkill(cwd, claudeDir, config, arg); + return; + } + if (arg !== undefined) { + await removeModule(cwd, claudeDir, config, arg, opts); + return; + } + await runPicker(cwd, claudeDir, config); +} + +// --------------------------------------------------------------------------- +// §A — remove a category:skill (no clone, no network) +// --------------------------------------------------------------------------- + +// What is installed is fully derivable from installedSkills + the filesystem, so +// this path never fetches the manifest or clones the repo. +async function removeSkill( + cwd: string, + claudeDir: string, + config: PharnConfig, + arg: string, +): Promise { + const idx = arg.indexOf(':'); + const category = arg.slice(0, idx); + const skill = arg.slice(idx + 1); + const moduleRoot = `${SKILL_MODULE_PREFIX}${category}`; + + const installedSkills = config.installedSkills ?? []; + const target = installedSkills.find( + (s) => s.from.split('/')[0] === moduleRoot && s.skill === skill, + ); + + // Not installed → benign no-op (no config write). Without a manifest we can't + // tell "valid but absent" from "unknown", so list the installed skills (the + // only things actually removable) as the valid values. + if (!target) { + const valid = installedSkills.map(skillAddress).sort(); + const hint = valid.length + ? ` Installed skills: ${valid.join(', ')}.` + : ' No skills are installed.'; + log.warn(`"${arg}" is not an installed skill.${hint}`); + outro('Nothing was removed.'); + return; + } + + // Each skill lives in its own isolated dir, so removal is a precise recursive + // delete; siblings in the same category are never touched. + const skillDir = safeUnderClaude( + claudeDir, + `skills/${basename(target.from)}`, + ); + let note = ''; + if (existsSync(skillDir)) { + rmSync(skillDir, { recursive: true, force: true }); + } else { + note = pc.dim(' (its files were already gone)'); + } + + // Drop only this entry. stackAnswers / modules / skillsVersion / commit are + // left untouched — the recorded wizard answer stays authoritative. + await writePharnConfig(cwd, { + ...config, + installedSkills: installedSkills.filter((s) => s.from !== target.from), + installedAt: new Date().toISOString(), + }); + + outro( + `${pc.green('✔')} Removed ${target.skill}${note} ${pc.dim('from .claude/skills/')}`, + ); +} + +// --------------------------------------------------------------------------- +// §B — remove a module (clones once) +// --------------------------------------------------------------------------- + +type RemoveOutcome = + | { kind: 'refused'; message: string } + | { kind: 'cancelled' } + | { kind: 'removed'; removed: string[] }; + +async function removeModule( + cwd: string, + claudeDir: string, + config: PharnConfig, + name: string, + opts: { yes?: boolean }, +): Promise { + // Cheap refusals first — decidable from the config alone, before any clone. + if (name === CORE_MODULE) { + log.error( + `${CORE_MODULE} is required by every other module and cannot be removed.`, + ); + process.exit(1); + } + const installedNames = config.modules.map((m) => m.name); + if (!installedNames.includes(name)) { + log.error(`${name} is not installed. Run \`pharn list\` to see what is.`); + process.exit(1); + } + + // Clone once and read the manifest FROM the clone (@main HEAD) — one source of + // truth, no raw-vs-HEAD race. Always cleanup() before any process.exit, since + // process.exit skips finally blocks in production. + const repo = await cloneRepo(); + let outcome: RemoveOutcome | undefined; + let failure: unknown; + try { + outcome = await planAndApplyModuleRemoval( + cwd, + claudeDir, + config, + name, + opts.yes ?? false, + repo.dir, + ); + } catch (err) { + failure = err; + } finally { + repo.cleanup(); + } + + // Surface a clean one-line message (parity with add/update) instead of letting + // a manifest/resolve error propagate as a raw stack — but only after cleanup. + if (failure !== undefined) { + reportError(failure); + process.exit(1); + } + + const result = outcome!; + switch (result.kind) { + case 'refused': + log.error(result.message); + process.exit(1); + break; + case 'cancelled': + cancelAndExit(); + break; + case 'removed': + outro( + `${pc.green('✔')} Removed ${result.removed.join(', ')} ${pc.dim('from .claude/')}`, + ); + break; + } +} + +// Resolve the survivor set, confirm, then delete exactly the target's files and +// rewrite the config. Returns an outcome instead of exiting so the caller can +// cleanup() the clone first. Performs no writes/deletes on a refusal or cancel. +async function planAndApplyModuleRemoval( + cwd: string, + claudeDir: string, + config: PharnConfig, + name: string, + yes: boolean, + repoDir: string, +): Promise { + const manifest = readManifest(repoDir); + const installedNames = config.modules.map((m) => m.name); + + // Dependents guard: refuse while any other installed module still needs this + // one (directly or transitively). No automatic cascade in v1. + const dependents = installedDependents(manifest, installedNames, name); + if (dependents.length > 0) { + return { + kind: 'refused', + message: `${name} is required by ${dependents.join(', ')} — remove ${ + dependents.length > 1 ? 'those' : 'that' + } first.`, + }; + } + + // The survivor set is the safe source of truth for what to delete: modules + // present before but absent after. Resolved before any file is touched. + const remaining = installedNames.filter((n) => n !== name); + const survivors = resolveModules(manifest, remaining); + const survivorNames = new Set(survivors.map((m) => m.name)); + const toRemove = installedNames.filter((n) => !survivorNames.has(n)); + + // An empty toRemove means `name` survived re-resolution despite being dropped + // from the explicit set — i.e. resolveModules force-includes it because the + // manifest marks it `required`. Refuse rather than no-op with a false success. + if (toRemove.length === 0) { + return { + kind: 'refused', + message: `${name} is a required module and cannot be removed.`, + }; + } + + if (!yes) { + const ok = await confirm({ + message: `Remove ${toRemove.join(', ')} from .claude/?`, + initialValue: false, + }); + if (isCancel(ok) || ok !== true) return { kind: 'cancelled' }; + } + + // Compute the exact contributed file set up front (fail-before-write). + const files = new Set(); + const dirs = new Set(); + for (const mod of toRemove) { + const mm = readModuleManifest(repoDir, mod); + collectModuleFiles(repoDir, claudeDir, mod, mm, files, dirs); + } + + for (const file of files) { + if (existsSync(file)) rmSync(file, { force: true }); + } + pruneEmptyDirs(dirs, claudeDir); + + // Keep the survivors' RECORDED versions — remove deletes files but never + // reinstalls, so resampling versions from the clone's @main manifest would + // misreport what's on disk and mask pending per-module updates. + await writePharnConfig(cwd, { + ...config, + modules: config.modules.filter((m) => survivorNames.has(m.name)), + installedAt: new Date().toISOString(), + }); + + return { kind: 'removed', removed: toRemove }; +} + +// --------------------------------------------------------------------------- +// Interactive picker (no arg) +// --------------------------------------------------------------------------- + +// Lists only genuinely removable items — installed optional modules (excluding +// pharn-core and any module with an installed dependent) plus installed skills — +// so we never offer something the command would immediately refuse. Clones once +// (the dependents guard needs the manifest) and reuses that clone for the delete. +async function runPicker( + cwd: string, + claudeDir: string, + config: PharnConfig, +): Promise { + const installedNames = config.modules.map((m) => m.name); + const removableSkills = (config.installedSkills ?? []) + .map(skillAddress) + .sort(); + const hasOptionalModules = installedNames.some((n) => n !== CORE_MODULE); + + // Only the module dependents-guard needs the manifest; skill removal is + // cloneless. Skip the clone entirely when no optional module is installed. + const repo = hasOptionalModules ? await cloneRepo() : undefined; + let cancelled = false; + let none = false; + let skillArg: string | undefined; + let moduleOutcome: RemoveOutcome | undefined; + let failure: unknown; + try { + let removableModules: string[] = []; + if (repo) { + const manifest = readManifest(repo.dir); + removableModules = installedNames.filter( + (n) => + n !== CORE_MODULE && + installedDependents(manifest, installedNames, n).length === 0, + ); + } + + if (removableModules.length === 0 && removableSkills.length === 0) { + none = true; + } else { + const choice = await select({ + message: 'What do you want to remove?', + options: [ + ...removableModules.map((n) => ({ + value: n, + label: n, + hint: 'module', + })), + ...removableSkills.map((a) => ({ + value: a, + label: a, + hint: 'skill', + })), + ], + }); + if (isCancel(choice)) { + cancelled = true; + } else if ((choice as string).includes(':')) { + // A skill address carries a colon; a module name never does. + skillArg = choice as string; + } else { + // A bare module name can only come from removableModules, which is empty + // unless the clone exists — so repo is non-null here. Remove without a + // second confirm (the pick is the intent), while the clone is held. + moduleOutcome = await planAndApplyModuleRemoval( + cwd, + claudeDir, + config, + choice as string, + true, + repo!.dir, + ); + } + } + } catch (err) { + failure = err; + } finally { + repo?.cleanup(); + } + + // Act only after the clone is cleaned up (so process.exit can't skip cleanup). + if (failure !== undefined) { + reportError(failure); + process.exit(1); + } + if (none) { + outro( + 'Nothing to remove — pharn-core is required and no skills are installed.', + ); + return; + } + if (cancelled) cancelAndExit(); + if (skillArg !== undefined) { + await removeSkill(cwd, claudeDir, config, skillArg); + return; + } + if (moduleOutcome?.kind === 'removed') { + outro( + `${pc.green('✔')} Removed ${moduleOutcome.removed.join(', ')} ${pc.dim('from .claude/')}`, + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function cloneRepo(): Promise { + const s = spinner(); + s.start(`Fetching module catalog from ${REPO_URL}`); + try { + const repo = await fetchRepo(); + s.stop('Module catalog loaded'); + return repo; + } catch (err) { + s.stop('Failed to fetch repository'); + reportError(err); + process.exit(1); + } +} + +// Clean one-line error (parity with add/update/status); full detail under +// PHARN_DEBUG. Used by every catch in this command. +function reportError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + log.error(`⚠ ${message}`); + if (process.env.PHARN_DEBUG) console.error(err); +} + +// `category:skill` rendering of an installed skill, e.g. orm:prisma. +function skillAddress(s: InstalledSkill): string { + const root = s.from.split('/')[0] ?? ''; + const category = root.startsWith(SKILL_MODULE_PREFIX) + ? root.slice(SKILL_MODULE_PREFIX.length) + : root; + return `${category}:${s.skill}`; +} + +// Installed modules whose transitive dependsOn includes `target`. +function installedDependents( + manifest: { modules: { name: string; dependsOn: string[] }[] }, + installedNames: string[], + target: string, +): string[] { + const byName = new Map(manifest.modules.map((m) => [m.name, m])); + const dependsOnTarget = (start: string): boolean => { + const seen = new Set(); + const stack = [...(byName.get(start)?.dependsOn ?? [])]; + while (stack.length) { + const dep = stack.pop()!; + if (dep === target) return true; + if (seen.has(dep)) continue; + seen.add(dep); + stack.push(...(byName.get(dep)?.dependsOn ?? [])); + } + return false; + }; + return installedNames.filter((n) => n !== target && dependsOnTarget(n)); +} + +// Map a module's `installs` entries to the exact files they contributed under +// .claude/, mirroring installModule's cpSync(from, to) semantics. statSync the +// source and branch — the schema permits a single-file `from`, not only dirs. +function collectModuleFiles( + repoDir: string, + claudeDir: string, + moduleName: string, + mm: ModuleManifest, + files: Set, + dirs: Set, +): void { + const moduleDir = resolve(repoDir, moduleName); + for (const [src, dest] of Object.entries(mm.installs)) { + const from = resolve(moduleDir, src); + const to = safeUnderClaude(claudeDir, dest); + // A `from` missing at @main HEAD means we can't know which files it once + // contributed — leave them (the documented orphan caveat) rather than guess. + if (!existsSync(from)) continue; + + if (statSync(from).isDirectory()) { + for (const rel of walkFiles(from)) { + const file = resolve(to, rel); + if (isProtected(claudeDir, file)) continue; + files.add(file); + dirs.add(dirname(file)); + } + dirs.add(to); + } else { + if (isProtected(claudeDir, to)) continue; + files.add(to); + dirs.add(dirname(to)); + } + } +} + +// Relative paths of every file (not directory) under `dir`, recursively. +function* walkFiles(dir: string, prefix = ''): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + yield* walkFiles(resolve(dir, entry.name), rel); + } else { + yield rel; + } + } +} + +// CONSTITUTION.md and the memory bank are user-owned (materialized only at init) +// and must never be deleted, even if a module's file set somehow references them. +function isProtected(claudeDir: string, target: string): boolean { + const memoryBank = resolve(claudeDir, 'memory-bank'); + return ( + target === resolve(claudeDir, 'CONSTITUTION.md') || + target === memoryBank || + target.startsWith(memoryBank + sep) + ); +} + +// Remove now-empty directories bottom-up, but never one that still holds a +// surviving module's files (re-checked with readdirSync before each rmdir). +function pruneEmptyDirs(dirs: Set, claudeDir: string): void { + const candidates = new Set(); + for (const dir of dirs) { + let cur = dir; + while (cur.startsWith(claudeDir + sep)) { + candidates.add(cur); + cur = dirname(cur); + } + } + const depth = (p: string): number => p.split(sep).length; + for (const dir of [...candidates].sort((a, b) => depth(b) - depth(a))) { + if (isProtected(claudeDir, dir) || !existsSync(dir)) continue; + try { + if (readdirSync(dir).length === 0) rmdirSync(dir); + } catch { + // Non-empty (surviving files) or a race — leave it in place. + } + } +} + +// Defense-in-depth: never let a delete escape .claude/ (mirrors install-modules +// safeJoin). dest paths are already validated by INSTALL_PATH_RE upstream. +function safeUnderClaude(claudeDir: string, rel: string): string { + const target = resolve(claudeDir, rel); + const root = resolve(claudeDir); + if (target !== root && !target.startsWith(root + sep)) { + throw new ManifestValidationError( + `Refusing path escape: ${rel} resolves outside ${claudeDir}`, + ); + } + return target; +} diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..6fae95a --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,173 @@ +import { resolve } from 'node:path'; +import { intro, log, note, outro, spinner } from '@clack/prompts'; +import pc from 'picocolors'; +import { REPO, REPO_BRANCH } from '../lib/constants.js'; +import { + fetchRemoteManifest, + readManifest, + resolveModules, +} from '../lib/manifest.js'; +import { fetchRepo } from '../lib/repo.js'; +import { diffInstalled } from '../lib/diff.js'; +import { row } from '../lib/format.js'; +import { readPharnConfig } from '../lib/pharn-config.js'; +import type { Manifest, PharnConfig } from '../types.js'; + +const REF = `${REPO}@${REPO_BRANCH}`; + +/** + * Read-only audit: is the install current (version section) and have any + * PHARN-owned files drifted from `pharn-dev/pharn-oss@main` (drift section)? + * Never writes, deletes, or overwrites — fixing is `pharn update` / `pharn add`. + * + * Default clones the repo once and reuses it for both sections. `--no-drift` + * skips the clone and only checks the version (via the lightweight manifest + * fetch). `--strict` exits 1 when anything is outdated, modified, or missing. + */ +export async function runStatus( + opts: { strict?: boolean; drift?: boolean } = {}, +): Promise { + const strict = opts.strict ?? false; + const drift = opts.drift ?? true; + + intro('pharn status'); + + const cwd = process.cwd(); + const config = readPharnConfig(cwd); + if (!config) { + log.error('No pharn.config.json found. Run `pharn init` first.'); + process.exit(1); + } + const claudeDir = resolve(cwd, '.claude'); + + // Version-only: no clone, just the manifest fetch list/update already use. + if (!drift) { + const s = spinner(); + s.start('Checking for updates'); + let manifest; + try { + manifest = await fetchRemoteManifest(); + s.stop(`Latest skills v${manifest.skillsVersion}`); + } catch (err) { + s.stop('Failed to check for updates'); + reportError(err); + process.exit(1); + } + const { outdated } = printVersionSection(config, manifest); + if (strict && outdated) process.exit(1); + outro(pc.dim('Read-only — nothing changed (drift check skipped).')); + return; + } + + // Default: clone once; the HEAD manifest is both the "latest" for the version + // section and the source for the byte-level drift comparison (no double fetch). + 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); + } + + // cleanup() must run before any process.exit (Node skips finally on exit), so + // record the desired code and exit only after the finally has cleaned up. + let exitCode = 0; + try { + const manifest = readManifest(repo.dir); + const { outdated } = printVersionSection(config, manifest); + + const resolved = resolveModules( + manifest, + config.modules.map((m) => m.name), + ); + const result = diffInstalled({ + repoDir: repo.dir, + claudeDir, + moduleNames: resolved.map((m) => m.name), + skills: config.installedSkills ?? [], + }); + 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: skillsVersion + any per-module version bumps. Returns whether +// the install is behind upstream (drives the --strict gate). Mirrors the diff +// computation in `pharn update`. +function printVersionSection( + config: PharnConfig, + manifest: Manifest, +): { outdated: boolean } { + const latest = new Map(manifest.modules.map((m) => [m.name, m.version])); + const changes = config.modules + .filter((m) => latest.has(m.name) && latest.get(m.name) !== m.version) + .map((m) => ({ name: m.name, from: m.version, to: latest.get(m.name)! })); + const outdated = + config.skillsVersion !== manifest.skillsVersion || changes.length > 0; + + const skillsLine = + config.skillsVersion === manifest.skillsVersion + ? `${row('Skills version', `v${config.skillsVersion}`)} ${pc.dim('(up to date)')}` + : `${row('Skills version', `v${config.skillsVersion} → v${manifest.skillsVersion}`)} ${pc.dim('(update available, run `pharn update`)')}`; + + const lines = [skillsLine]; + if (changes.length) { + lines.push('', ' MODULE UPDATES'); + for (const c of changes) lines.push(row(c.name, `v${c.from} → v${c.to}`)); + } + note(lines.join('\n'), 'VERSION'); + return { outdated }; +} + +// DRIFT note: locally-modified and missing PHARN-owned files, or a clean bill. +function printDriftSection(result: { + modified: string[]; + missing: string[]; + okCount: number; +}): void { + if (result.modified.length === 0 && result.missing.length === 0) { + note(`No drift — ${result.okCount} file(s) match ${REF}.`, 'DRIFT'); + return; + } + + const lines: string[] = []; + if (result.modified.length) { + lines.push(' LOCALLY MODIFIED (PHARN-owned)'); + for (const p of result.modified) lines.push(` ${p}`); + lines.push(pc.dim(' `pharn update` will overwrite these.')); + } + if (result.missing.length) { + if (lines.length) lines.push(''); + lines.push(' MISSING (expected but absent)'); + for (const p of result.missing) lines.push(` ${p}`); + lines.push( + pc.dim(' Re-run `pharn update` (or `pharn add`) to restore them.'), + ); + } + lines.push('', pc.dim(` ${result.okCount} file(s) match ${REF}.`)); + note(lines.join('\n'), 'DRIFT'); +} + +function reportError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + log.error(`⚠ ${message}`); + if (process.env.PHARN_DEBUG) console.error(err); +} diff --git a/src/index.ts b/src/index.ts index 37b7d10..24690f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url'; import minimist from 'minimist'; import { runInit } from './commands/init.js'; import { runAdd } from './commands/add.js'; +import { runRemove } from './commands/remove.js'; import { runUpdate } from './commands/update.js'; +import { runList } from './commands/list.js'; +import { runStatus } from './commands/status.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; @@ -16,18 +19,29 @@ Usage: pharn [command] [options] Commands: - init Run the setup wizard (default) - add Add a module to an existing PHARN project - update Update installed modules to the latest version + init Run the setup wizard (default) + add Add a methodology module or stack pack + add : Add one technology skill (e.g. orm:prisma) + remove Remove a module or skill from this project + update Update installed modules to the latest version + list List installed and available modules/skills + status Show version + local-drift status (read-only) Options: + -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 + --json Emit list output as JSON -h, --help Show this help text -v, --version Show the version number`; export async function main(): Promise { const argv = minimist(process.argv.slice(2), { - boolean: ['help', 'version'], - alias: { h: 'help', v: 'version' }, + boolean: ['help', 'version', 'json', 'yes', 'strict', 'drift'], + // `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 }, + alias: { h: 'help', v: 'version', y: 'yes' }, }); if (argv.version) { @@ -49,9 +63,22 @@ export async function main(): Promise { case 'add': await runAdd(argv._[1]); return; + case 'remove': + case 'rm': + await runRemove(argv._[1], { yes: Boolean(argv.yes) }); + return; case 'update': await runUpdate(); return; + case 'list': + await runList({ json: Boolean(argv.json) }); + return; + case 'status': + await runStatus({ + strict: Boolean(argv.strict), + drift: argv.drift !== false, + }); + return; default: console.error(`Unknown command: ${cmd}\n`); console.error(USAGE); diff --git a/src/lib/diff.ts b/src/lib/diff.ts new file mode 100644 index 0000000..b0c9a7a --- /dev/null +++ b/src/lib/diff.ts @@ -0,0 +1,127 @@ +import { createHash } from 'node:crypto'; +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'; + +export interface InstallDiff { + // .claude-relative paths present on disk but whose bytes differ from upstream. + modified: string[]; + // Expected by an installed module/skill but absent on disk. + missing: string[]; + // Files present on disk and byte-identical to upstream. + okCount: number; +} + +/** + * Read-only comparison of the installed `.claude/` tree against a fetched + * pharn-oss clone. Derives the *expected* file set by mirroring installModule / + * installSkills (lib/install-modules.ts), then byte-compares each file with disk. + * + * Pure: no console, no process.exit, no network, no writes — the caller owns + * I/O and presentation. The two user-owned surfaces (`.claude/CONSTITUTION.md` + * and `.claude/memory-bank/`) are materialized at init and hand-edited, so they + * are excluded; the PHARN-owned template *sources* under `.claude/templates/` + * are not (the exclusion is anchored at the .claude/ root). + */ +export function diffInstalled(params: { + repoDir: string; + claudeDir: string; + moduleNames: string[]; + skills: InstalledSkill[]; +}): InstallDiff { + const { repoDir, claudeDir, moduleNames, skills } = params; + + // .claude-relative posix path → repo absolute source-file path. Modules are + // collected first, then skills, last-write-wins — mirroring the installer's + // cpSync(force) order so an overlapping dest is resolved deterministically. + const expected = new Map(); + const add = (rel: string, repoPath: string): void => { + const norm = toPosix(rel); + if (isExcluded(norm)) return; + expected.set(norm, repoPath); + }; + + // Modules: each installs `from`→`to`. statSync the source and branch — the + // schema permits a single-file `from`, not only directories. + for (const name of moduleNames) { + const mm = readModuleManifest(repoDir, name); + const moduleDir = resolve(repoDir, name); + for (const [src, dest] of Object.entries(mm.installs)) { + const from = safeJoin(moduleDir, src); + // A `from` missing at @main HEAD is an upstream packaging inconsistency, + // not local drift — there is nothing to compare, so skip it (never throw). + if (!existsSync(from)) continue; + if (statSync(from).isDirectory()) { + for (const rel of walkFiles(from)) + add(join(dest, rel), resolve(from, rel)); + } else { + add(dest, from); + } + } + } + + // Skills: each `from` lands in .claude/skills// in isolation. + for (const skill of skills) { + const from = safeJoin(repoDir, skill.from); + const destBase = `skills/${basename(skill.from)}`; + if (!existsSync(from)) continue; + if (statSync(from).isDirectory()) { + for (const rel of walkFiles(from)) { + add(join(destBase, rel), resolve(from, rel)); + } + } else { + add(destBase, from); + } + } + + const modified: string[] = []; + const missing: string[] = []; + let okCount = 0; + for (const [rel, repoPath] of expected) { + const diskPath = safeJoin(claudeDir, rel); + if (!existsSync(diskPath)) { + missing.push(rel); + } else if (hash(repoPath) === hash(diskPath)) { + okCount += 1; + } else { + modified.push(rel); + } + } + modified.sort(); + missing.sort(); + return { modified, missing, okCount }; +} + +// User-owned surfaces, anchored at the .claude/ root: the materialized working +// constitution + memory bank. Template sources nested under templates/ (e.g. +// templates/memory-bank/) start with "templates/" and are deliberately NOT matched. +function isExcluded(rel: string): boolean { + return ( + rel === 'CONSTITUTION.md' || + rel === 'memory-bank' || + rel.startsWith('memory-bank/') + ); +} + +function hash(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +// Relative paths (posix) of every file (not directory) under `dir`, recursively. +function* walkFiles(dir: string, prefix = ''): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + yield* walkFiles(resolve(dir, entry.name), rel); + } else { + yield rel; + } + } +} + +// Normalize a dest/relpath to a posix, no-trailing-slash .claude-relative key. +function toPosix(rel: string): string { + return rel.split(sep).join('/').replace(/\/+$/, ''); +} diff --git a/src/lib/install-modules.ts b/src/lib/install-modules.ts index 032d3fb..3edb9f2 100644 --- a/src/lib/install-modules.ts +++ b/src/lib/install-modules.ts @@ -122,8 +122,9 @@ export function materializeCore( } // Defense-in-depth against path traversal in installs maps (already validated -// by INSTALL_PATH_RE, but never let a copy escape its base directory). -function safeJoin(base: string, rel: string): string { +// by INSTALL_PATH_RE, but never let a copy escape its base directory). Exported +// so the read-only drift check (lib/diff.ts) guards its reads the same way. +export function safeJoin(base: string, rel: string): string { const target = resolve(base, rel); const root = resolve(base); if (target !== root && !target.startsWith(root + sep)) { diff --git a/tests/diff.test.ts b/tests/diff.test.ts new file mode 100644 index 0000000..46ca1b1 --- /dev/null +++ b/tests/diff.test.ts @@ -0,0 +1,227 @@ +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 { ManifestValidationError } from '../src/lib/validate.js'; + +function write(path: string, content = 'x'): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content); +} + +// Write repoDir//module.json with the given installs map. Names + install +// paths must satisfy MODULE_NAME_RE / INSTALL_PATH_RE (readModuleManifest +// validates them), so: `pharn-*` names, lowercase dot-free install paths. +function writeModule( + repoDir: string, + name: string, + installs: Record, +): void { + write( + join(repoDir, name, 'module.json'), + JSON.stringify({ + name, + version: '0.1.0', + required: false, + dependsOn: [], + description: 'test module', + installs, + }), + ); +} + +// relpath -> content for every file under dir (used to prove zero writes). +function snapshot(dir: string): Record { + const out: Record = {}; + const walk = (d: string, prefix: string): void => { + for (const e of readdirSync(d, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${e.name}` : e.name; + if (e.isDirectory()) walk(join(d, e.name), rel); + else out[rel] = readFileSync(join(d, e.name), 'utf8'); + } + }; + walk(dir, ''); + return out; +} + +describe('diffInstalled', () => { + const tmp = useTmpDir(); + + it('reports ok / modified / missing across directory and single-file installs', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + // Directory install (commands/) + single-file install (notes, no extension). + writeModule(repoDir, 'pharn-core', { + commands: 'commands/', + notes: 'notes', + }); + write(join(repoDir, 'pharn-core', 'commands', 'a.md'), 'A'); + write(join(repoDir, 'pharn-core', 'commands', 'sub', 'b.md'), 'B'); + write(join(repoDir, 'pharn-core', 'notes'), 'N'); + + write(join(claudeDir, 'commands', 'a.md'), 'A'); // identical + write(join(claudeDir, 'commands', 'sub', 'b.md'), 'CHANGED'); // modified + // .claude/notes is absent → missing (file branch). + + const diff = diffInstalled({ + repoDir, + claudeDir, + moduleNames: ['pharn-core'], + skills: [], + }); + + expect(diff.okCount).toBe(1); + expect(diff.modified).toEqual(['commands/sub/b.md']); + expect(diff.missing).toEqual(['notes']); + }); + + it('diffs selectively-installed skills under skills//', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + write( + join(repoDir, 'pharn-skills-orm', 'skills', 'prisma', 'SKILL.md'), + 'S', + ); + write(join(claudeDir, 'skills', 'prisma', 'SKILL.md'), 'EDITED'); + + const diff = diffInstalled({ + repoDir, + claudeDir, + moduleNames: [], + skills: [{ skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }], + }); + + expect(diff.modified).toEqual(['skills/prisma/SKILL.md']); + expect(diff.missing).toEqual([]); + expect(diff.okCount).toBe(0); + }); + + it('diffs a single-file skill source (file branch)', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + write(join(repoDir, 'pharn-skills-orm', 'loose'), 'L'); + write(join(claudeDir, 'skills', 'loose'), 'L'); + + const diff = diffInstalled({ + repoDir, + claudeDir, + moduleNames: [], + skills: [{ skill: 'loose', from: 'pharn-skills-orm/loose' }], + }); + + expect(diff.okCount).toBe(1); + expect(diff.modified).toEqual([]); + expect(diff.missing).toEqual([]); + }); + + it('never reports CONSTITUTION.md or memory-bank/ even when present and differing', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + // A module mapping into the user-owned memory bank — both a directory and a + // bare-file form (the file form exercises the `=== 'memory-bank'` branch). + writeModule(repoDir, 'pharn-core', { + mbdir: 'memory-bank/', + mbfile: 'memory-bank', + }); + write(join(repoDir, 'pharn-core', 'mbdir', 'progress.md'), 'UPSTREAM'); + write(join(repoDir, 'pharn-core', 'mbfile'), 'UPSTREAM'); + + // Hand-edited working copies on disk — and a CONSTITUTION.md that no install + // maps. None of these may surface as drift. + write(join(claudeDir, 'memory-bank', 'progress.md'), 'MY NOTES'); + write(join(claudeDir, 'CONSTITUTION.md'), 'MY CONSTITUTION'); + + const diff = diffInstalled({ + repoDir, + claudeDir, + moduleNames: ['pharn-core'], + skills: [], + }); + + expect(diff.okCount).toBe(0); + expect(diff.modified).toEqual([]); + expect(diff.missing).toEqual([]); + expect([...diff.modified, ...diff.missing]).not.toContainEqual( + expect.stringContaining('memory-bank'), + ); + expect([...diff.modified, ...diff.missing]).not.toContain( + 'CONSTITUTION.md', + ); + }); + + it('reports template sources under templates/ (exclusion is anchored at the .claude/ root)', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + // templates/memory-bank/ is a PHARN-owned source copy — it must NOT be + // swallowed by the memory-bank/ exclusion (which only anchors at root). + writeModule(repoDir, 'pharn-core', { templates: 'templates/' }); + write(join(repoDir, 'pharn-core', 'templates', 'memory-bank', 't.md'), 'R'); + write(join(claudeDir, 'templates', 'memory-bank', 't.md'), 'DIFFERENT'); + + const diff = diffInstalled({ + repoDir, + claudeDir, + moduleNames: ['pharn-core'], + skills: [], + }); + + expect(diff.modified).toEqual(['templates/memory-bank/t.md']); + }); + + it('skips installs sources that are absent upstream (never reports them missing)', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + writeModule(repoDir, 'pharn-core', { ghost: 'ghost/' }); // no ghost/ in repo + + const diff = diffInstalled({ + repoDir, + claudeDir, + moduleNames: ['pharn-core'], + skills: [], + }); + + expect(diff).toEqual({ modified: [], missing: [], okCount: 0 }); + }); + + it('guards path-escape inputs (a crafted skill.from cannot read outside repoDir)', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + write(join(repoDir, 'pharn-core', 'module.json'), '{}'); // unused + + expect(() => + diffInstalled({ + repoDir, + claudeDir, + moduleNames: [], + skills: [{ skill: 'x', from: '../escape' }], + }), + ).toThrow(ManifestValidationError); + }); + + it('performs zero writes or deletes', () => { + const repoDir = join(tmp.path(), 'repo'); + const claudeDir = join(tmp.path(), '.claude'); + writeModule(repoDir, 'pharn-core', { + commands: 'commands/', + notes: 'notes', + }); + write(join(repoDir, 'pharn-core', 'commands', 'a.md'), 'A'); + write(join(repoDir, 'pharn-core', 'notes'), 'N'); + write(join(claudeDir, 'commands', 'a.md'), 'CHANGED'); + write(join(claudeDir, 'extra.md'), 'untouched'); + + const repoBefore = snapshot(repoDir); + const claudeBefore = snapshot(claudeDir); + + diffInstalled({ + repoDir, + claudeDir, + moduleNames: ['pharn-core'], + skills: [], + }); + + expect(snapshot(repoDir)).toEqual(repoBefore); + expect(snapshot(claudeDir)).toEqual(claudeBefore); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 19e8588..b9124c5 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -3,10 +3,20 @@ import { ProcessExit, stubProcessExit } from './helpers.js'; const runInit = vi.fn(async () => undefined); const runAdd = vi.fn(async (_arg?: string) => undefined); +const runRemove = vi.fn( + async (_arg?: string, _opts?: { yes?: boolean }) => undefined, +); const runUpdate = vi.fn(async () => undefined); +const runList = vi.fn(async (_opts?: { json?: boolean }) => undefined); +const runStatus = vi.fn( + async (_opts?: { strict?: boolean; drift?: boolean }) => undefined, +); vi.mock('../src/commands/init.js', () => ({ runInit })); vi.mock('../src/commands/add.js', () => ({ runAdd })); +vi.mock('../src/commands/remove.js', () => ({ runRemove })); vi.mock('../src/commands/update.js', () => ({ runUpdate })); +vi.mock('../src/commands/list.js', () => ({ runList })); +vi.mock('../src/commands/status.js', () => ({ runStatus })); // Importing the module does not auto-run main(): under vitest, argv[1] is the // test runner, not this module, so the isEntryPoint() guard is false. @@ -46,12 +56,60 @@ describe('main (argv dispatch)', () => { expect(runAdd).toHaveBeenCalledWith('orm:prisma'); }); + it('routes `remove ` to runRemove with the argument and yes:false', async () => { + setArgv('remove', 'pharn-review'); + await main(); + expect(runRemove).toHaveBeenCalledWith('pharn-review', { yes: false }); + }); + + it('passes yes:true through for `remove --yes`', async () => { + setArgv('remove', 'pharn-review', '--yes'); + await main(); + expect(runRemove).toHaveBeenCalledWith('pharn-review', { yes: true }); + }); + + it('routes the `rm` alias to runRemove', async () => { + setArgv('rm', 'orm:prisma'); + await main(); + expect(runRemove).toHaveBeenCalledWith('orm:prisma', { yes: false }); + }); + it('routes `update` to runUpdate', async () => { setArgv('update'); await main(); expect(runUpdate).toHaveBeenCalledTimes(1); }); + it('routes `list` to runList with json:false by default', async () => { + setArgv('list'); + await main(); + expect(runList).toHaveBeenCalledWith({ json: false }); + }); + + it('routes `list --json` to runList with json:true', async () => { + setArgv('list', '--json'); + await main(); + expect(runList).toHaveBeenCalledWith({ json: true }); + }); + + it('routes `status` to runStatus with strict:false, drift:true by default', async () => { + setArgv('status'); + await main(); + expect(runStatus).toHaveBeenCalledWith({ strict: false, drift: true }); + }); + + it('passes strict:true through for `status --strict`', async () => { + setArgv('status', '--strict'); + await main(); + expect(runStatus).toHaveBeenCalledWith({ strict: true, drift: true }); + }); + + it('maps `status --no-drift` to drift:false', async () => { + setArgv('status', '--no-drift'); + await main(); + expect(runStatus).toHaveBeenCalledWith({ strict: false, drift: false }); + }); + it('prints the version for --version and runs no command', async () => { setArgv('--version'); await main(); diff --git a/tests/list.test.ts b/tests/list.test.ts new file mode 100644 index 0000000..191a706 --- /dev/null +++ b/tests/list.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProcessExit, stubProcessExit } from './helpers.js'; +import { v2Manifest } from './wizard-fixture.js'; +import type { + InstalledSkill, + Manifest, + ManifestModule, + PharnConfig, +} from '../src/types.js'; + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + note: vi.fn(), + outro: vi.fn(), + spinner: () => ({ start: vi.fn(), stop: vi.fn() }), +})); + +const fetchRemoteManifest = vi.fn(); +const optional: ManifestModule[] = [ + { + name: 'pharn-pipeline', + version: '0.5.0', + required: false, + dependsOn: [], + description: 'The spec to ship pipeline', + }, + { + name: 'pharn-review', + version: '0.4.0', + required: false, + dependsOn: [], + description: 'Context-lens review', + }, +]; +const stackPacks: ManifestModule[] = [ + { + name: 'pharn-stack-nextjs', + version: '0.30.0', + required: false, + dependsOn: [], + exclusiveWith: ['pharn-stack-*'], + description: 'Next.js stack pack', + }, +]; +const categorizeModules = vi.fn(() => ({ core: [], optional, stackPacks })); +// wizard.js + format.js are intentionally NOT mocked — list uses the real +// listSkillAddresses / row / shortDescription against the v2 fixture wizard. +vi.mock('../src/lib/manifest.js', () => ({ + fetchRemoteManifest, + categorizeModules, +})); + +const readPharnConfig = vi.fn(); +vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig })); + +const { runList } = await import('../src/commands/list.js'); +const prompts = await import('@clack/prompts'); + +function config( + modules: string[], + extra: Partial = {}, +): PharnConfig { + return { + pharnVersion: '0.2.0', + skillsVersion: '0.68.0', + repo: 'pharn-dev/pharn-oss', + commit: 'old', + constitution: 'standard', + modules: modules.map((name) => ({ name, version: '0.1.0' })), + installedAt: '2026-06-11T00:00:00.000Z', + ...extra, + }; +} + +// The body string passed to the note() call rendered under the given title. +function noteBody(title: string): string { + const call = vi.mocked(prompts.note).mock.calls.find((c) => c[1] === title); + return (call?.[0] as string | undefined) ?? ''; +} + +describe('runList', () => { + stubProcessExit(); + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/proj'); + categorizeModules.mockReturnValue({ core: [], optional, stackPacks }); + fetchRemoteManifest.mockResolvedValue(v2Manifest()); + }); + afterEach(() => vi.clearAllMocks()); + + it('exits(1) when there is no config', async () => { + readPharnConfig.mockReturnValue(null); + await expect(runList()).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRemoteManifest).not.toHaveBeenCalled(); + }); + + it('exits(1) when the module catalog cannot be loaded', async () => { + readPharnConfig.mockReturnValue(config(['pharn-core'])); + fetchRemoteManifest.mockRejectedValueOnce(new Error('offline')); + await expect(runList()).rejects.toMatchObject(new ProcessExit(1)); + }); + + it('shows installed modules and flags available updates', async () => { + readPharnConfig.mockReturnValue( + config(['pharn-core', 'pharn-pipeline'], { skillsVersion: '0.68.0' }), + ); + const manifest = v2Manifest(); + manifest.skillsVersion = '0.69.0'; + manifest.modules = manifest.modules.map((m) => + m.name === 'pharn-pipeline' ? { ...m, version: '0.9.0' } : m, + ); + fetchRemoteManifest.mockResolvedValue(manifest); + + await runList(); + + const installed = noteBody('INSTALLED'); + expect(installed).toContain('pharn-pipeline'); + expect(installed).toContain('v0.1.0'); + expect(installed).toContain('→ v0.9.0'); + expect(installed).toContain('update available'); + }); + + it('omits update markers when everything is current', async () => { + // v2Manifest is skillsVersion 0.69.0 with every module at 0.1.0. + readPharnConfig.mockReturnValue( + config(['pharn-core', 'pharn-pipeline'], { skillsVersion: '0.69.0' }), + ); + + await runList(); + + const installed = noteBody('INSTALLED'); + expect(installed).not.toContain('update available'); + expect(installed).not.toContain('→ v'); + }); + + it('lists available modules, excluding installed ones (parity with add)', async () => { + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-pipeline'])); + + await runList(); + + const available = noteBody('AVAILABLE TO ADD'); + expect(available).toContain('pharn-review'); + expect(available).toContain('pharn-stack-nextjs'); + expect(available).not.toContain('pharn-pipeline'); + }); + + it('shows "(all installed)" when no modules are available', async () => { + categorizeModules.mockReturnValue({ + core: [], + optional: [optional[0]!], + stackPacks: [], + }); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-pipeline'])); + + await runList(); + + expect(noteBody('AVAILABLE TO ADD')).toContain('(all installed)'); + }); + + it('lists available category:skill, excluding installed skills', async () => { + readPharnConfig.mockReturnValue( + config(['pharn-core'], { + installedSkills: [ + { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, + ], + }), + ); + + await runList(); + + const available = noteBody('AVAILABLE TO ADD'); + expect(available).toContain('orm:drizzle'); + expect(available).toContain('db:neon'); + expect(available).not.toContain('orm:prisma'); + + const installed = noteBody('INSTALLED'); + expect(installed).toContain('prisma'); + expect(installed).toContain('pharn-skills-orm/skills/prisma'); + }); + + it('shows "(all installed)" for skills when every wizard skill is installed', async () => { + const all: InstalledSkill[] = [ + 'pharn-skills-db/skills/neon', + 'pharn-skills-orm/skills/drizzle', + 'pharn-skills-orm/skills/prisma', + 'pharn-skills-auth/skills/better-auth', + 'pharn-skills-auth/skills/clerk', + 'pharn-skills-auth/skills/supabase-auth', + 'pharn-skills-email/skills/resend', + 'pharn-skills-payments/skills/stripe', + ].map((from) => ({ skill: from.split('/').pop()!, from })); + readPharnConfig.mockReturnValue( + config(['pharn-core'], { installedSkills: all }), + ); + + await runList(); + + const available = noteBody('AVAILABLE TO ADD'); + expect(available).toContain('SKILLS'); + expect(available).toContain('(all installed)'); + expect(available).not.toContain('orm:'); + }); + + it('handles a schemaVersion 1 manifest with no skills section', async () => { + const manifest: Manifest = { + schemaVersion: 1, + skillsVersion: '0.70.0', + modules: [ + { + name: 'pharn-core', + version: '0.1.0', + required: true, + dependsOn: [], + description: 'core', + }, + ], + }; + fetchRemoteManifest.mockResolvedValue(manifest); + readPharnConfig.mockReturnValue(config(['pharn-core'])); + + await expect(runList()).resolves.toBeUndefined(); + + expect(noteBody('AVAILABLE TO ADD')).not.toContain('SKILLS'); + expect(noteBody('INSTALLED')).not.toContain('SKILLS'); + }); +}); + +describe('runList --json', () => { + stubProcessExit(); + let logSpy: ReturnType; + let errSpy: ReturnType; + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/proj'); + categorizeModules.mockReturnValue({ core: [], optional, stackPacks }); + fetchRemoteManifest.mockResolvedValue(v2Manifest()); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('emits a single JSON object and no decorative output', async () => { + readPharnConfig.mockReturnValue( + config(['pharn-core', 'pharn-pipeline'], { + skillsVersion: '0.68.0', + installedSkills: [ + { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, + ], + }), + ); + + await runList({ json: true }); + + expect(prompts.intro).not.toHaveBeenCalled(); + expect(prompts.outro).not.toHaveBeenCalled(); + expect(prompts.note).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(logSpy.mock.calls[0]![0] as string); + expect(payload).toMatchObject({ + skillsVersion: '0.68.0', + latestSkillsVersion: '0.69.0', + }); + expect(payload.installed.modules).toContainEqual({ + name: 'pharn-pipeline', + version: '0.1.0', + latest: '0.1.0', + }); + expect(payload.installed.skills).toEqual([ + { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, + ]); + const addrs = payload.available.skills.map( + (s: { category: string; skill: string }) => `${s.category}:${s.skill}`, + ); + expect(addrs).toContain('orm:drizzle'); + expect(addrs).not.toContain('orm:prisma'); + }); + + it('keeps stdout clean and exits(1) when there is no config', async () => { + readPharnConfig.mockReturnValue(null); + await expect(runList({ json: true })).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(logSpy).not.toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalled(); + }); + + it('keeps stdout clean and exits(1) when the fetch fails', async () => { + readPharnConfig.mockReturnValue(config(['pharn-core'])); + fetchRemoteManifest.mockRejectedValueOnce(new Error('offline')); + await expect(runList({ json: true })).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(logSpy).not.toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalledWith('offline'); + }); +}); diff --git a/tests/remove.test.ts b/tests/remove.test.ts new file mode 100644 index 0000000..70876b8 --- /dev/null +++ b/tests/remove.test.ts @@ -0,0 +1,557 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CANCEL, ProcessExit, stubProcessExit, useTmpDir } from './helpers.js'; +import type { PharnConfig } from '../src/types.js'; + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + isCancel: (v: unknown) => v === CANCEL, + confirm: vi.fn(), + select: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + outro: vi.fn(), + spinner: () => ({ start: vi.fn(), stop: vi.fn() }), +})); + +// Only fetchRepo is mocked — §B exercises the REAL readManifest / +// readModuleManifest / resolveModules against a real tmp repo on disk. +const cleanup = vi.fn(); +const fetchRepo = vi.fn(); +vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); + +const readPharnConfig = vi.fn(); +const writePharnConfig = vi.fn(); +vi.mock('../src/lib/pharn-config.js', () => ({ + readPharnConfig, + writePharnConfig, + toInstalledModules: (m: { name: string; version: string }[]) => + m.map(({ name, version }) => ({ name, version })), +})); + +const { runRemove } = await import('../src/commands/remove.js'); +const prompts = await import('@clack/prompts'); + +function write(path: string, content = 'x'): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +interface ModSpec { + name: string; + version?: string; + required?: boolean; + dependsOn?: string[]; + exclusiveWith?: string[]; + installs?: Record; + files?: Record; +} + +// Build a real fetched-repo tree: manifest.json catalog + each module's +// module.json + its source files. +function scaffoldRepo(repoDir: string, mods: ModSpec[]): void { + write( + join(repoDir, 'manifest.json'), + JSON.stringify({ + schemaVersion: 1, + skillsVersion: '0.70.0', + modules: mods.map((m) => ({ + name: m.name, + version: m.version ?? '0.1.0', + required: m.required ?? false, + dependsOn: m.dependsOn ?? [], + ...(m.exclusiveWith ? { exclusiveWith: m.exclusiveWith } : {}), + description: m.name, + })), + }), + ); + for (const m of mods) { + if (m.installs) { + write( + join(repoDir, m.name, 'module.json'), + JSON.stringify({ + name: m.name, + version: m.version ?? '0.1.0', + required: m.required ?? false, + dependsOn: m.dependsOn ?? [], + description: m.name, + installs: m.installs, + }), + ); + } + for (const [p, content] of Object.entries(m.files ?? {})) { + write(join(repoDir, m.name, p), content); + } + } +} + +function config( + modules: string[], + extra: Partial = {}, +): PharnConfig { + return { + pharnVersion: '0.2.0', + skillsVersion: '0.70.0', + repo: 'pharn-dev/pharn-oss', + commit: 'old', + constitution: 'standard', + modules: modules.map((name) => ({ name, version: '0.1.0' })), + installedAt: '2026-06-11T00:00:00.000Z', + ...extra, + }; +} + +// A core + review repo where both modules merge into commands/, used by the +// precise-deletion and picker tests. +function scaffoldCoreReview(repoDir: string, projDir: string): void { + scaffoldRepo(repoDir, [ + { + name: 'pharn-core', + required: true, + installs: { commands: 'commands/', skills: 'skills/' }, + files: { 'commands/pharn-init.md': 'init', 'skills/base/SKILL.md': 's' }, + }, + { + name: 'pharn-review', + dependsOn: ['pharn-core'], + installs: { commands: 'commands/', rules: 'rules/' }, + // rules/ has a nested subdir so removal exercises the recursive walk. + files: { 'commands/pharn-review.md': 'rev', 'rules/sub/lens.md': 'l' }, + }, + ]); + write(join(projDir, '.claude', 'commands', 'pharn-init.md'), 'init'); + write(join(projDir, '.claude', 'commands', 'pharn-review.md'), 'rev'); + write(join(projDir, '.claude', 'skills', 'base', 'SKILL.md'), 's'); + write(join(projDir, '.claude', 'rules', 'sub', 'lens.md'), 'l'); +} + +function lastWritten(): PharnConfig { + return writePharnConfig.mock.calls[0]![1] as PharnConfig; +} + +const tmp = useTmpDir(); +let proj = ''; +let repo = ''; + +describe('runRemove', () => { + stubProcessExit(); + beforeEach(() => { + proj = join(tmp.path(), 'proj'); + repo = join(tmp.path(), 'repo'); + vi.spyOn(process, 'cwd').mockReturnValue(proj); + fetchRepo.mockResolvedValue({ dir: repo, cleanup }); + }); + afterEach(() => vi.clearAllMocks()); + + it('exits(1) when there is no config', async () => { + readPharnConfig.mockReturnValue(null); + await expect(runRemove('pharn-review')).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(fetchRepo).not.toHaveBeenCalled(); + }); + + // §A — category:skill removal (no clone, no network) ----------------------- + + it('removes an installed skill and leaves siblings + config fields intact', async () => { + write(join(proj, '.claude', 'skills', 'prisma', 'SKILL.md'), 'p'); + write(join(proj, '.claude', 'skills', 'drizzle', 'SKILL.md'), 'd'); + readPharnConfig.mockReturnValue( + config(['pharn-core'], { + stackAnswers: { orm: 'prisma' }, + installedSkills: [ + { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, + { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, + ], + }), + ); + + await runRemove('orm:prisma'); + + expect(existsSync(join(proj, '.claude', 'skills', 'prisma'))).toBe(false); + expect( + existsSync(join(proj, '.claude', 'skills', 'drizzle', 'SKILL.md')), + ).toBe(true); + // No clone / network on the skill path. + expect(fetchRepo).not.toHaveBeenCalled(); + + const written = lastWritten(); + expect(written.installedSkills).toEqual([ + { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, + ]); + // stackAnswers + modules are never touched. + expect(written.stackAnswers).toEqual({ orm: 'prisma' }); + expect(written.modules).toEqual([{ name: 'pharn-core', version: '0.1.0' }]); + }); + + it('drops the config entry even when the skill dir is already gone', async () => { + readPharnConfig.mockReturnValue( + config(['pharn-core'], { + installedSkills: [ + { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, + ], + }), + ); + + await runRemove('orm:prisma'); + + expect(lastWritten().installedSkills).toEqual([]); + }); + + it('no-ops without writing when the skill is not installed', async () => { + readPharnConfig.mockReturnValue( + config(['pharn-core'], { + installedSkills: [ + { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, + ], + }), + ); + + await runRemove('orm:prisma'); + + expect(writePharnConfig).not.toHaveBeenCalled(); + expect(fetchRepo).not.toHaveBeenCalled(); + }); + + it('lists installed skills (not the manifest) for an unrecognized skill', async () => { + readPharnConfig.mockReturnValue( + config(['pharn-core'], { + installedSkills: [ + { skill: 'drizzle', from: 'pharn-skills-orm/skills/drizzle' }, + ], + }), + ); + + await runRemove('foo:bar'); + + const msg = vi.mocked(prompts.log.warn).mock.calls[0]![0] as string; + expect(msg).toContain('orm:drizzle'); + // A manifest-only skill that isn't installed is never offered as valid. + expect(msg).not.toContain('prisma'); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); + + // §B — module removal (clones once) ---------------------------------------- + + it('refuses to remove pharn-core before any clone', async () => { + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + await expect(runRemove('pharn-core')).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); + + it('refuses to remove a module that is not installed, before any clone', async () => { + readPharnConfig.mockReturnValue(config(['pharn-core'])); + await expect(runRemove('pharn-review')).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(fetchRepo).not.toHaveBeenCalled(); + }); + + it('refuses to remove a module with installed dependents, naming them', async () => { + scaffoldRepo(repo, [ + { name: 'pharn-core', required: true }, + { name: 'pharn-stack-react', dependsOn: ['pharn-core'] }, + { + name: 'pharn-stack-nextjs', + dependsOn: ['pharn-core', 'pharn-stack-react'], + exclusiveWith: ['pharn-stack-*'], + }, + ]); + write(join(proj, '.claude', 'commands', 'init.md'), 'i'); + readPharnConfig.mockReturnValue( + config(['pharn-core', 'pharn-stack-react', 'pharn-stack-nextjs']), + ); + + await expect(runRemove('pharn-stack-react')).rejects.toMatchObject( + new ProcessExit(1), + ); + + const msg = vi.mocked(prompts.log.error).mock.calls[0]![0] as string; + expect(msg).toContain('pharn-stack-nextjs'); + expect(writePharnConfig).not.toHaveBeenCalled(); + // Nothing deleted; clone cleaned up after the post-clone refusal. + expect(existsSync(join(proj, '.claude', 'commands', 'init.md'))).toBe(true); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('refuses on a transitive dependent (not only direct ones)', async () => { + // pharn-top → pharn-mid → pharn-base: removing the base must be refused + // because pharn-top depends on it transitively. + scaffoldRepo(repo, [ + { name: 'pharn-core', required: true }, + { name: 'pharn-base', dependsOn: ['pharn-core'] }, + { name: 'pharn-mid', dependsOn: ['pharn-base'] }, + { name: 'pharn-top', dependsOn: ['pharn-mid'] }, + ]); + readPharnConfig.mockReturnValue( + config(['pharn-core', 'pharn-base', 'pharn-mid', 'pharn-top']), + ); + + await expect(runRemove('pharn-base')).rejects.toMatchObject( + new ProcessExit(1), + ); + + const msg = vi.mocked(prompts.log.error).mock.calls[0]![0] as string; + expect(msg).toContain('pharn-mid'); + expect(msg).toContain('pharn-top'); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); + + it('deletes only the target files, keeps shared-dir siblings, prunes empties', async () => { + scaffoldCoreReview(repo, proj); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + + await runRemove('pharn-review', { yes: true }); + + // review's contributions are gone… + expect( + existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), + ).toBe(false); + // …and its now-empty private dir is pruned. + expect(existsSync(join(proj, '.claude', 'rules'))).toBe(false); + // core's files in the shared commands/ dir survive. + expect(existsSync(join(proj, '.claude', 'commands', 'pharn-init.md'))).toBe( + true, + ); + expect( + existsSync(join(proj, '.claude', 'skills', 'base', 'SKILL.md')), + ).toBe(true); + + expect(lastWritten().modules).toEqual([ + { name: 'pharn-core', version: '0.1.0' }, + ]); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('handles a module whose installs maps a single file', async () => { + scaffoldRepo(repo, [ + { + name: 'pharn-core', + required: true, + installs: { commands: 'commands/' }, + files: { 'commands/init.md': 'i' }, + }, + { + name: 'pharn-extras', + dependsOn: ['pharn-core'], + installs: { 'extras/license': 'license' }, + files: { 'extras/license': 'MIT' }, + }, + ]); + write(join(proj, '.claude', 'commands', 'init.md'), 'i'); + write(join(proj, '.claude', 'license'), 'MIT'); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-extras'])); + + await runRemove('pharn-extras', { yes: true }); + + expect(existsSync(join(proj, '.claude', 'license'))).toBe(false); + expect(existsSync(join(proj, '.claude', 'commands', 'init.md'))).toBe(true); + }); + + it('preserves survivors recorded versions (never resamples the @main manifest)', async () => { + // Clone @main ships core@0.2.0, but the install recorded 0.1.0. remove + // deletes files without reinstalling, so it must NOT bump the recorded + // version — doing so would mask a pending `pharn update`. + scaffoldRepo(repo, [ + { + name: 'pharn-core', + required: true, + version: '0.2.0', + installs: { commands: 'commands/' }, + files: { 'commands/init.md': 'i' }, + }, + { + name: 'pharn-review', + dependsOn: ['pharn-core'], + version: '0.2.0', + installs: { rules: 'rules/' }, + files: { 'rules/lens.md': 'l' }, + }, + ]); + write(join(proj, '.claude', 'commands', 'init.md'), 'i'); + write(join(proj, '.claude', 'rules', 'lens.md'), 'l'); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + + await runRemove('pharn-review', { yes: true }); + + expect(lastWritten().modules).toEqual([ + { name: 'pharn-core', version: '0.1.0' }, + ]); + }); + + it('refuses to remove a required (non-core) module instead of no-op success', async () => { + scaffoldRepo(repo, [ + { name: 'pharn-core', required: true }, + { + name: 'pharn-pipeline', + required: true, + dependsOn: ['pharn-core'], + installs: { commands: 'commands/' }, + files: { 'commands/plan.md': 'p' }, + }, + ]); + write(join(proj, '.claude', 'commands', 'plan.md'), 'p'); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-pipeline'])); + + await expect( + runRemove('pharn-pipeline', { yes: true }), + ).rejects.toMatchObject(new ProcessExit(1)); + + const msg = vi.mocked(prompts.log.error).mock.calls[0]![0] as string; + expect(msg).toContain('required'); + expect(writePharnConfig).not.toHaveBeenCalled(); + expect(existsSync(join(proj, '.claude', 'commands', 'plan.md'))).toBe(true); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('never deletes CONSTITUTION.md or the memory bank', async () => { + scaffoldRepo(repo, [ + { + name: 'pharn-core', + required: true, + installs: { commands: 'commands/' }, + files: { 'commands/init.md': 'i' }, + }, + { + name: 'pharn-rogue', + dependsOn: ['pharn-core'], + installs: { mb: 'memory-bank/' }, + files: { 'mb/architecture-context.md': 'x' }, + }, + ]); + write(join(proj, '.claude', 'commands', 'init.md'), 'i'); + write(join(proj, '.claude', 'CONSTITUTION.md'), 'user-owned'); + write( + join(proj, '.claude', 'memory-bank', 'architecture-context.md'), + 'user-owned', + ); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-rogue'])); + + await runRemove('pharn-rogue', { yes: true }); + + expect(existsSync(join(proj, '.claude', 'CONSTITUTION.md'))).toBe(true); + expect( + existsSync( + join(proj, '.claude', 'memory-bank', 'architecture-context.md'), + ), + ).toBe(true); + }); + + it('asks for confirmation and removes on yes', async () => { + scaffoldCoreReview(repo, proj); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + vi.mocked(prompts.confirm).mockResolvedValue(true); + + await runRemove('pharn-review'); + + expect(prompts.confirm).toHaveBeenCalled(); + expect( + existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), + ).toBe(false); + }); + + it('cancels without deleting or writing when the confirm is declined', async () => { + scaffoldCoreReview(repo, proj); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + vi.mocked(prompts.confirm).mockResolvedValue(false); + + await expect(runRemove('pharn-review')).rejects.toMatchObject( + new ProcessExit(0), + ); + + expect( + existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), + ).toBe(true); + expect(writePharnConfig).not.toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('reports a clean error (not a raw throw) and cleans up when the manifest cannot be read', async () => { + write(join(repo, 'manifest.json'), 'not json'); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + + await expect( + runRemove('pharn-review', { yes: true }), + ).rejects.toMatchObject(new ProcessExit(1)); + expect(prompts.log.error).toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('exits(1) when the clone fails', async () => { + fetchRepo.mockRejectedValue(new Error('offline')); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + await expect( + runRemove('pharn-review', { yes: true }), + ).rejects.toMatchObject(new ProcessExit(1)); + }); + + // Interactive picker (no arg) ---------------------------------------------- + + it('picker lists only removable items and removes the chosen module', async () => { + scaffoldCoreReview(repo, proj); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + vi.mocked(prompts.select).mockResolvedValue('pharn-review'); + + await runRemove(undefined); + + const options = ( + vi.mocked(prompts.select).mock.calls[0]![0] as { + options: { value: string }[]; + } + ).options.map((o) => o.value); + expect(options).toContain('pharn-review'); + expect(options).not.toContain('pharn-core'); + + expect( + existsSync(join(proj, '.claude', 'commands', 'pharn-review.md')), + ).toBe(false); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('picker can remove an installed skill', async () => { + scaffoldRepo(repo, [{ name: 'pharn-core', required: true }]); + write(join(proj, '.claude', 'skills', 'prisma', 'SKILL.md'), 'p'); + readPharnConfig.mockReturnValue( + config(['pharn-core'], { + installedSkills: [ + { skill: 'prisma', from: 'pharn-skills-orm/skills/prisma' }, + ], + }), + ); + vi.mocked(prompts.select).mockResolvedValue('orm:prisma'); + + await runRemove(undefined); + + expect(existsSync(join(proj, '.claude', 'skills', 'prisma'))).toBe(false); + expect(lastWritten().installedSkills).toEqual([]); + // No optional module installed → skill-only picker needs no clone. + expect(fetchRepo).not.toHaveBeenCalled(); + }); + + it('picker reports nothing to remove when only core is installed', async () => { + scaffoldRepo(repo, [{ name: 'pharn-core', required: true }]); + readPharnConfig.mockReturnValue(config(['pharn-core'])); + + await runRemove(undefined); + + expect(prompts.select).not.toHaveBeenCalled(); + expect(prompts.outro).toHaveBeenCalled(); + // Nothing module-shaped to resolve → no wasted clone. + expect(fetchRepo).not.toHaveBeenCalled(); + }); + + it('picker cancels cleanly, cleaning up the clone', async () => { + scaffoldCoreReview(repo, proj); + readPharnConfig.mockReturnValue(config(['pharn-core', 'pharn-review'])); + vi.mocked(prompts.select).mockResolvedValue(CANCEL); + + await expect(runRemove(undefined)).rejects.toMatchObject( + new ProcessExit(0), + ); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(writePharnConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/status.test.ts b/tests/status.test.ts new file mode 100644 index 0000000..2b1b0ab --- /dev/null +++ b/tests/status.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProcessExit, stubProcessExit } from './helpers.js'; +import type { Manifest, PharnConfig } from '../src/types.js'; + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + note: vi.fn(), + outro: vi.fn(), + spinner: () => ({ start: vi.fn(), stop: vi.fn() }), +})); + +const fetchRemoteManifest = vi.fn(); +const readManifest = vi.fn(); +const resolveModules = vi.fn(); +vi.mock('../src/lib/manifest.js', () => ({ + fetchRemoteManifest, + readManifest, + resolveModules, +})); + +const fetchRepo = vi.fn(); +vi.mock('../src/lib/repo.js', () => ({ fetchRepo })); + +const diffInstalled = vi.fn(); +vi.mock('../src/lib/diff.js', () => ({ diffInstalled })); + +const readPharnConfig = vi.fn(); +vi.mock('../src/lib/pharn-config.js', () => ({ readPharnConfig })); + +const { runStatus } = await import('../src/commands/status.js'); +const prompts = await import('@clack/prompts'); + +function config(extra: Partial = {}): PharnConfig { + return { + pharnVersion: '0.2.0', + skillsVersion: '0.68.0', + repo: 'pharn-dev/pharn-oss', + commit: 'old', + constitution: 'standard', + modules: [{ name: 'pharn-core', version: '0.1.0' }], + installedAt: '2026-06-11T00:00:00.000Z', + ...extra, + }; +} + +function manifest(skillsVersion = '0.68.0', coreVersion = '0.1.0'): Manifest { + return { + schemaVersion: 2, + skillsVersion, + modules: [ + { + name: 'pharn-core', + version: coreVersion, + required: true, + dependsOn: [], + description: 'core', + }, + ], + }; +} + +// The body string of the note() rendered under the given title. +function noteBody(title: string): string { + const call = vi.mocked(prompts.note).mock.calls.find((c) => c[1] === title); + return (call?.[0] as string | undefined) ?? ''; +} + +const CLEAN = { modified: [] as string[], missing: [] as string[], okCount: 5 }; + +describe('runStatus', () => { + stubProcessExit(); + let cleanup: ReturnType; + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/proj'); + cleanup = vi.fn(); + readPharnConfig.mockReturnValue(config()); + fetchRepo.mockResolvedValue({ dir: '/clone', cleanup }); + readManifest.mockReturnValue(manifest()); + fetchRemoteManifest.mockResolvedValue(manifest()); + resolveModules.mockReturnValue([{ name: 'pharn-core', version: '0.1.0' }]); + diffInstalled.mockReturnValue(CLEAN); + }); + afterEach(() => vi.clearAllMocks()); + + it('exits(1) when there is no config', async () => { + readPharnConfig.mockReturnValue(null); + await expect(runStatus()).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(fetchRemoteManifest).not.toHaveBeenCalled(); + }); + + it('exits(1) when the clone cannot be fetched', async () => { + fetchRepo.mockRejectedValueOnce(new Error('offline')); + await expect(runStatus()).rejects.toMatchObject(new ProcessExit(1)); + expect(diffInstalled).not.toHaveBeenCalled(); + }); + + it('cleans up the clone and exits(1) when reading the manifest fails', async () => { + readManifest.mockImplementationOnce(() => { + throw new Error('bad manifest'); + }); + await expect(runStatus()).rejects.toMatchObject(new ProcessExit(1)); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('prints both sections and cleans up on a normal run', async () => { + diffInstalled.mockReturnValue({ + modified: ['commands/x.md'], + missing: ['skills/prisma/SKILL.md'], + okCount: 3, + }); + await runStatus(); + expect(noteBody('VERSION')).toContain('Skills version'); + const drift = noteBody('DRIFT'); + expect(drift).toContain('commands/x.md'); + expect(drift).toContain('skills/prisma/SKILL.md'); + expect(drift).toContain('LOCALLY MODIFIED'); + expect(drift).toContain('MISSING'); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('reports "No drift" when nothing differs', async () => { + await runStatus(); + expect(noteBody('DRIFT')).toContain('No drift'); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('flags an available update in the version section', async () => { + readManifest.mockReturnValue(manifest('0.69.0')); + await runStatus(); + expect(noteBody('VERSION')).toContain('update available'); + }); + + it('lists per-module version bumps in the version section', async () => { + readManifest.mockReturnValue(manifest('0.69.0', '0.2.0')); + await runStatus(); + const version = noteBody('VERSION'); + expect(version).toContain('MODULE UPDATES'); + expect(version).toContain('pharn-core'); + expect(version).toContain('v0.1.0 → v0.2.0'); + }); + + it('--no-drift exits(1) when the manifest fetch fails', async () => { + fetchRemoteManifest.mockRejectedValueOnce(new Error('offline')); + await expect(runStatus({ drift: false })).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(fetchRepo).not.toHaveBeenCalled(); + }); + + it('--no-drift skips the clone and prints only the version section', async () => { + await runStatus({ drift: false }); + expect(fetchRepo).not.toHaveBeenCalled(); + expect(diffInstalled).not.toHaveBeenCalled(); + expect(fetchRemoteManifest).toHaveBeenCalledTimes(1); + expect(noteBody('VERSION')).toContain('Skills version'); + expect(noteBody('DRIFT')).toBe(''); + }); + + it('--strict exits(1) when drift is present (and still cleans up)', async () => { + diffInstalled.mockReturnValue({ + modified: ['commands/x.md'], + missing: [], + okCount: 1, + }); + await expect(runStatus({ strict: true })).rejects.toMatchObject( + new ProcessExit(1), + ); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('a default (non-strict) run with drift resolves without exiting', async () => { + diffInstalled.mockReturnValue({ + modified: ['commands/x.md'], + missing: [], + okCount: 1, + }); + await expect(runStatus()).resolves.toBeUndefined(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('--no-drift --strict exits(1) when the version is behind', async () => { + fetchRemoteManifest.mockResolvedValue(manifest('0.69.0')); + await expect( + runStatus({ strict: true, drift: false }), + ).rejects.toMatchObject(new ProcessExit(1)); + expect(fetchRepo).not.toHaveBeenCalled(); + }); +});