From 57fb61d94e3cc85d99d8bffc1695227d70a3c31b Mon Sep 17 00:00:00 2001 From: Roberto Diaz Date: Wed, 26 Aug 2026 20:20:56 +0200 Subject: [PATCH 1/7] fix(plugin): correct the paths the plugin's own content assumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the CLI moved to marketplace mode, `init` stopped copying skills and agents into `.claude/` — but the content kept describing the old layout, and two things broke quietly and stayed broken. `/devtronic-help` enumerated `.claude/skills/` with a `find`, got nothing in plugin mode, and fell into its "No devtronic skills detected in this project" branch — the discovery skill telling a correctly installed user their setup was missing. It now reads its own `## Skill Categories` table, which is the authority, and scans `.claude/` only for the user's *own* skills. `doc-sync` counted the same empty directory and reported documentation drift every time. `architecture-checker` read `.claude/architecture-rules.md`, a path the CLI writes nowhere; what `init` generates is `.claude/rules/architecture.md`. The personalized rule was never read by its real name, the agent worked by falling back to CLAUDE.md, and its error message told the user to hand-write a file devtronic had already generated somewhere else. Both are prose, which is why neither had a test. The new one reads the shipped templates and fails on the next such drift. --- .../__tests__/plugin-path-contract.test.ts | 101 ++++++++++++++++++ .../.claude/agents/architecture-checker.md | 4 +- .../claude-code/.claude/agents/doc-sync.md | 4 +- .../.claude/skills/devtronic-help/SKILL.md | 45 ++++---- .../.claude/skills/post-review/SKILL.md | 2 +- 5 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/generators/__tests__/plugin-path-contract.test.ts diff --git a/packages/cli/src/generators/__tests__/plugin-path-contract.test.ts b/packages/cli/src/generators/__tests__/plugin-path-contract.test.ts new file mode 100644 index 0000000..157e694 --- /dev/null +++ b/packages/cli/src/generators/__tests__/plugin-path-contract.test.ts @@ -0,0 +1,101 @@ +/** + * The plugin's own content must not assume the pre-marketplace layout. + * + * `init.ts` stopped copying skills and agents into `.claude/` when the CLI moved + * to marketplace mode, but the content stayed written as though they still lived + * there. Two things broke quietly and stayed broken: + * + * - `/devtronic-help` enumerated `.claude/skills/`, found nothing in a correctly + * installed project, and reported "No devtronic skills detected" — the + * discovery skill telling a new user their install was missing. + * - `architecture-checker` read `.claude/architecture-rules.md`, a path the CLI + * writes nowhere, and told the user to create by hand the rules file devtronic + * had already generated at a different path. + * + * Neither had a test, because both are prose. These read the shipped templates. + */ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const CLAUDE_DIR = join(__dirname, '..', '..', '..', 'templates', 'claude-code', '.claude'); + +function read(rel: string): string { + return readFileSync(join(CLAUDE_DIR, rel), 'utf-8'); +} + +// ─── The rules path is the one the CLI writes ───────────────────────────────── + +describe('architecture rules are read from where devtronic writes them', () => { + /** What `init` generates — see DYNAMIC_RULE_FILES in commands/init.ts. */ + const REAL = '.claude/rules/architecture.md'; + /** What the content used to name. The CLI has never written this path. */ + const DEAD = '.claude/architecture-rules.md'; + + it.each([ + 'agents/architecture-checker.md', + 'skills/post-review/SKILL.md', + ])('%s does not name the dead path', (file) => { + expect(read(file)).not.toContain(DEAD); + }); + + it('architecture-checker names the real path', () => { + expect(read('agents/architecture-checker.md')).toContain(REAL); + }); + + it('the failure message points at a command, not a file to hand-write', () => { + const content = read('agents/architecture-checker.md'); + expect(content).toContain('devtronic regenerate --rules'); + }); +}); + +// ─── devtronic's inventory does not come from the filesystem ────────────────── + +describe('devtronic-help does not enumerate its own skills by filesystem', () => { + const help = read('skills/devtronic-help/SKILL.md'); + + it('no longer claims a correctly installed project has no skills', () => { + expect(help).not.toContain('No devtronic skills detected'); + }); + + it('separates plugin-shipped assets from project-local ones', () => { + // `.claude/skills` stays legitimate — for the user's *own* skills. What it + // must not be is the source of devtronic's inventory. + expect(help).toContain('Project-local additions'); + expect(help).toMatch(/do \*\*not\*\*\s*\n?\s*live in `\.claude\/`/); + }); + + it('names every shipped skill in its static inventory', () => { + // The `## Skill Categories` table is the authority now that the filesystem + // scan is gone, so it has to be complete. Its second column is a + // comma-separated list of bare skill names. + const table = help.split(/^## Skill Categories\s*$/m)[1]?.split(/^## /m)[0] ?? ''; + const listed = new Set( + table + .split('\n') + .filter((l) => l.startsWith('|') && !l.includes('---') && !l.includes('Category')) + .flatMap((l) => l.split('|')[2]?.split(',') ?? []) + .map((n) => n.trim()) + .filter(Boolean) + ); + + const shipped = readdirSync(join(CLAUDE_DIR, 'skills'), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + + const missing = shipped.filter((s) => !listed.has(s)); + expect(missing).toEqual([]); + }); +}); + +describe('doc-sync counts against the plugin, not .claude/', () => { + const docSync = read('agents/doc-sync.md'); + + it('does not count .claude/skills as the devtronic inventory', () => { + expect(docSync).toContain('Do not count `.claude/skills/`'); + }); + + it('names the plugin root as the source', () => { + expect(docSync).toContain('CLAUDE_PLUGIN_ROOT'); + }); +}); diff --git a/packages/cli/templates/claude-code/.claude/agents/architecture-checker.md b/packages/cli/templates/claude-code/.claude/agents/architecture-checker.md index 60b9746..ba8409d 100644 --- a/packages/cli/templates/claude-code/.claude/agents/architecture-checker.md +++ b/packages/cli/templates/claude-code/.claude/agents/architecture-checker.md @@ -20,7 +20,7 @@ Read these files in order (stop when you have enough context): 1. **`CLAUDE.md`** (or `AGENTS.md`) — Project-specific rules, patterns, conventions 2. **`docs/ARCHITECTURE.md`** — Folder structure, layer definitions, dependency rules -3. **`.claude/architecture-rules.md`** — Explicit rules file (if exists, highest priority) +3. **`.claude/rules/architecture.md`** — Explicit rules file generated by `devtronic init` (if exists, highest priority) 4. **`thoughts/CONFIG.md`** — Additional project configuration From these files, extract: @@ -31,7 +31,7 @@ From these files, extract: - **Patterns**: Required patterns (return types, handler patterns, DI conventions) - **Conventions**: Naming, file extensions, module structure -If no architecture documentation exists, report: "No architecture rules found. Create `CLAUDE.md` or `.claude/architecture-rules.md` to enable architecture checking." +If no architecture documentation exists, report: "No architecture rules found. Run `devtronic regenerate --rules`, or create `CLAUDE.md`, to enable architecture checking." ## When Invoked diff --git a/packages/cli/templates/claude-code/.claude/agents/doc-sync.md b/packages/cli/templates/claude-code/.claude/agents/doc-sync.md index 2c69dd0..faed14a 100644 --- a/packages/cli/templates/claude-code/.claude/agents/doc-sync.md +++ b/packages/cli/templates/claude-code/.claude/agents/doc-sync.md @@ -34,8 +34,8 @@ Verify that documentation accurately reflects the current state of the codebase. - Catalog claims made (counts, paths, commands, structures) 2. **Check Numeric Counts** - - Skills count: count entries in `.claude/skills/` (each directory or .md = 1 skill) vs claims in docs - - Agents count: count .md files in `.claude/agents/` vs claims in docs + - Skills count: count directories in the plugin's `skills/` (via `${CLAUDE_PLUGIN_ROOT}` when set, else the repo's `packages/cli/templates/claude-code/.claude/skills/`) vs claims in docs. Do not count `.claude/skills/` — in plugin mode it holds only the user's own skills. + - Agents count: same rule against the plugin's `agents/` - Hook count: parse hooks.json vs claims in docs - Any other numeric claims (endpoints, pages, etc.) - Search for patterns like "X skills", "X agents", "X hooks" across all .md files diff --git a/packages/cli/templates/claude-code/.claude/skills/devtronic-help/SKILL.md b/packages/cli/templates/claude-code/.claude/skills/devtronic-help/SKILL.md index 999c49a..4f9b70d 100644 --- a/packages/cli/templates/claude-code/.claude/skills/devtronic-help/SKILL.md +++ b/packages/cli/templates/claude-code/.claude/skills/devtronic-help/SKILL.md @@ -36,8 +36,8 @@ Show what devtronic can do, right from the IDE. No need to switch to a terminal └── Determine mode from $ARGUMENTS 2. SCAN INSTALLED ASSETS - ├── Skills: .claude/skills/*/SKILL.md - ├── Agents: .claude/agents/*.md + ├── Skills: shipped by the plugin (see Skill Categories) + .claude/skills/*/SKILL.md (project) + ├── Agents: shipped by the plugin (see Available Agents) + .claude/agents/*.md (project) ├── Addons: .claude/addons/*/manifest.json (if any) └── Rules: .claude/rules/*.md @@ -65,23 +65,24 @@ Parse `$ARGUMENTS`: ## Step 2: Scan Installed Assets -### Skills +### Skills and agents (shipped by the plugin) -```bash -# Find all installed skills -find .claude/skills -name "SKILL.md" -type f 2>/dev/null -``` +devtronic ships as a Claude Code plugin, so its skills and agents do **not** +live in `.claude/`. They are already loaded in this session — list them from +`## Skill Categories` and `## Available Agents` below. Do not scan the +filesystem for them: in plugin mode the scan returns nothing and the count is +wrong. -For each SKILL.md, read the YAML frontmatter to extract `name` and `description`. +### Project-local additions -### Agents +Only these live in the project, and only these are scanned: ```bash -# Find all installed agents -find .claude/agents -name "*.md" -type f 2>/dev/null +find .claude/skills -name "SKILL.md" -type f 2>/dev/null # user's own skills +find .claude/agents -name "*.md" -type f 2>/dev/null # user's own agents ``` -For each agent file, read the first few lines to extract purpose. +Report them as **project skills**, separate from the devtronic inventory. ### Addons @@ -301,24 +302,30 @@ Combine: Default overview + full skill list with descriptions + agents + addons ## Edge Cases -### No Skills Found +### No project-local skills + +If `.claude/skills/` is empty or missing, that is normal — devtronic's own +skills come from the plugin. Report the plugin inventory and simply omit the +"project skills" section. -If `.claude/skills/` is empty or missing: +Only report a broken install when the plugin inventory below is *also* +unavailable, which means the plugin itself did not load: ```markdown # Devtronic Help -No devtronic skills detected in this project. +The devtronic plugin does not appear to be loaded in this session. ## Getting Started -Run in your terminal: +In Claude Code: -```bash -npx devtronic init +``` +/plugin marketplace add r-bart/devtronic-plugin +/plugin install devtronic@devtronic ``` -This will analyze your project and install skills, agents, and rules. +Then restart Claude Code. To set a project up as well, run `npx devtronic init`. ``` ### Partial Installation diff --git a/packages/cli/templates/claude-code/.claude/skills/post-review/SKILL.md b/packages/cli/templates/claude-code/.claude/skills/post-review/SKILL.md index 95644e3..d7d3aa6 100644 --- a/packages/cli/templates/claude-code/.claude/skills/post-review/SKILL.md +++ b/packages/cli/templates/claude-code/.claude/skills/post-review/SKILL.md @@ -150,7 +150,7 @@ Use the Agent tool with: prompt: "Check architecture compliance on these changed files: [file list from Step 1]" ``` -The subagent reads architecture rules from `CLAUDE.md`, `docs/ARCHITECTURE.md`, or `.claude/architecture-rules.md`, then: +The subagent reads architecture rules from `CLAUDE.md`, `docs/ARCHITECTURE.md`, or `.claude/rules/architecture.md`, then: 1. Categorizes changed files by architecture layer 2. Checks layer dependency direction, domain purity, pattern compliance 3. Returns a structured report with violations (file:line) and a PASS/FAIL verdict From 55d1ad82e363e82a66d23e0f33fcf0ff3591a62b Mon Sep 17 00:00:00 2001 From: Roberto Diaz Date: Wed, 26 Aug 2026 20:21:10 +0200 Subject: [PATCH 2/7] fix(hooks): make the session hooks harmless outside a devtronic project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both session hooks assumed they only ever ran where devtronic was installed. That holds for a project install and stops holding the moment the plugin is enabled at user scope, where the hooks fire in every repo the user opens. `checkpoint.sh` did `mkdir -p thoughts/checkpoints` unconditionally on PreCompact, so a repo that never asked for devtronic got a `thoughts/` directory the first time a session compacted. It now returns early unless `thoughts/` or `.ai-template/` is already there. SessionStart orientation was a `type: prompt` hook pinned to Haiku: a model call on every startup, to ask about a `thoughts/STATE.md` that usually does not exist. It is now `orient.sh`, which reads the same file and emits the same context on stdout — the mechanism the official Vercel plugin uses for exactly this — and costs nothing where devtronic is absent. Also adds the parity test these mirrored scripts never had. `checkpoint.sh` had already drifted between the generator and the marketplace template (a comment and one block's indentation): harmless, and the same shape as the two drifts that were not — the `Stop` gate outliving its removal, and `version-check.sh`. Both scripts are now generated by escaping the template's own text, so they cannot drift by construction rather than by discipline. The parity suite gains a whole-object comparison of the two `hooks.json` copies. The existing checks compared event names and pinned models, which is what let the details differ unnoticed; `generateHooks()` takes no arguments, so there is nothing legitimate for the two to disagree about. It also asserts that neither side calls a model on SessionStart — without that, dropping the prompt hook would have left the model-pinning check comparing undefined to undefined: green, and verifying nothing. --- .../hooks-marketplace-parity.test.ts | 37 ++++++- .../src/generators/__tests__/hooks.test.ts | 36 ++++--- .../src/generators/__tests__/plugin.test.ts | 4 +- .../__tests__/script-parity.test.ts | 89 +++++++++++++++++ packages/cli/src/generators/hooks.ts | 98 ++++++++++++++----- packages/cli/src/generators/plugin.ts | 8 +- .../cli/templates/marketplace/checkpoint.sh | 10 ++ packages/cli/templates/marketplace/hooks.json | 9 +- packages/cli/templates/marketplace/orient.sh | 29 ++++++ 9 files changed, 274 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/generators/__tests__/script-parity.test.ts create mode 100755 packages/cli/templates/marketplace/orient.sh diff --git a/packages/cli/src/generators/__tests__/hooks-marketplace-parity.test.ts b/packages/cli/src/generators/__tests__/hooks-marketplace-parity.test.ts index 67475f4..467ecaf 100644 --- a/packages/cli/src/generators/__tests__/hooks-marketplace-parity.test.ts +++ b/packages/cli/src/generators/__tests__/hooks-marketplace-parity.test.ts @@ -15,6 +15,17 @@ import { generateHooks } from '../hooks.js'; const TEMPLATE = join(process.cwd(), 'templates/marketplace/hooks.json'); describe('generator ↔ marketplace template parity', () => { + it('are the same configuration, not merely the same events', () => { + // The existing checks below compare event names and pinned models. That is + // what let the two copies differ in the details for as long as they did — + // `generateHooks()` takes no arguments, so there is nothing legitimate for + // them to disagree about, and a whole-object comparison says so. + const generated = JSON.parse(generateHooks()); + const template = JSON.parse(readFileSync(TEMPLATE, 'utf-8')); + + expect(template).toEqual(generated); + }); + it('registers the same hook events', () => { const generated = JSON.parse(generateHooks()); const template = JSON.parse(readFileSync(TEMPLATE, 'utf-8')); @@ -41,10 +52,28 @@ describe('generator ↔ marketplace template parity', () => { const generated = JSON.parse(generateHooks()); const template = JSON.parse(readFileSync(TEMPLATE, 'utf-8')); - for (const event of ['SessionStart', 'SubagentStop']) { - const genModels = generated.hooks[event][0].hooks.map((h: { model?: string }) => h.model); - const tplModels = template.hooks[event][0].hooks.map((h: { model?: string }) => h.model); - expect(tplModels).toEqual(genModels); + // SubagentStop is the only prompt hook left — SessionStart moved to a + // script. Keeping SessionStart in this loop would not have failed: both + // sides would report `[undefined]` and the assertion would pass on nothing, + // which is worse than a red test. The guard below is what replaces it. + const genModels = generated.hooks.SubagentStop[0].hooks.map((h: { model?: string }) => h.model); + const tplModels = template.hooks.SubagentStop[0].hooks.map((h: { model?: string }) => h.model); + + expect(genModels.every((m: string | undefined) => typeof m === 'string')).toBe(true); + expect(tplModels).toEqual(genModels); + }); + + it('neither side calls a model on SessionStart', () => { + // Orientation used to be a Haiku call on every startup. At user scope that + // is a toll on every repo the user opens, so it became a script. + const generated = JSON.parse(generateHooks()); + const template = JSON.parse(readFileSync(TEMPLATE, 'utf-8')); + + for (const hooks of [generated.hooks, template.hooks]) { + const types = hooks.SessionStart.flatMap( + (m: { hooks: { type: string }[] }) => m.hooks.map((h) => h.type) + ); + expect(types).not.toContain('prompt'); } }); }); diff --git a/packages/cli/src/generators/__tests__/hooks.test.ts b/packages/cli/src/generators/__tests__/hooks.test.ts index 1387aff..f70517a 100644 --- a/packages/cli/src/generators/__tests__/hooks.test.ts +++ b/packages/cli/src/generators/__tests__/hooks.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { generateAutoLintScript, generateHooks, generateCheckpointScript } from '../hooks.js'; +import { generateAutoLintScript, generateHooks, generateCheckpointScript, generateOrientScript } from '../hooks.js'; import type { ProjectConfig } from '../../types.js'; function createConfig(overrides: Partial = {}): ProjectConfig { @@ -44,12 +44,25 @@ describe('generateHooks', () => { }); describe('SessionStart hook', () => { - it('uses haiku model', () => { + it('orients through a script, not a model call', () => { + // Orientation was a prompt hook pinned to Haiku: a model call on every + // startup, in every repo. Installed at user scope that is most of the + // repos a user opens, and the script costs nothing where devtronic is + // absent. const result = JSON.parse(generateHooks()); const hook = result.hooks.SessionStart[0].hooks[0]; - expect(hook.type).toBe('prompt'); - expect(hook.model).toBe('claude-haiku-4-5-20251001'); + expect(hook.type).toBe('command'); + expect(hook.model).toBeUndefined(); + expect(hook.command).toBe('${CLAUDE_PLUGIN_ROOT}/scripts/orient.sh 2>/dev/null || true'); + }); + + it('registers no prompt hook at all', () => { + const result = JSON.parse(generateHooks()); + const types = result.hooks.SessionStart.flatMap( + (m: { hooks: { type: string }[] }) => m.hooks.map((h) => h.type) + ); + expect(types).not.toContain('prompt'); }); it('matches startup event', () => { @@ -57,16 +70,15 @@ describe('generateHooks', () => { expect(result.hooks.SessionStart[0].matcher).toBe('startup'); }); - it('includes $ARGUMENTS placeholder', () => { - const result = JSON.parse(generateHooks()); - const hook = result.hooks.SessionStart[0].hooks[0]; - expect(hook.prompt).toContain('$ARGUMENTS'); + it('still reads STATE.md — in the script now', () => { + // The behaviour moved, it did not go away: the same file that drove the + // prompt drives the script. + expect(generateOrientScript()).toContain('thoughts/STATE.md'); }); - it('includes STATE.md reference in prompt', () => { - const result = JSON.parse(generateHooks()); - const hook = result.hooks.SessionStart[0].hooks[0]; - expect(hook.prompt).toContain('STATE.md'); + it('emits nothing in a repo that does not use devtronic', () => { + const script = generateOrientScript(); + expect(script).toContain('[ -d "thoughts" ] || [ -d ".ai-template" ] || exit 0'); }); }); diff --git a/packages/cli/src/generators/__tests__/plugin.test.ts b/packages/cli/src/generators/__tests__/plugin.test.ts index 0d4470b..554f89b 100644 --- a/packages/cli/src/generators/__tests__/plugin.test.ts +++ b/packages/cli/src/generators/__tests__/plugin.test.ts @@ -234,8 +234,8 @@ describe('generatePlugin', () => { // marketplace.json + plugin.json + 3 skills (brief/SKILL.md, audit/SKILL.md, audit/report-template.md) // + 2 agents + hooks.json + checkpoint.sh + auto-lint.sh - // + version-check.sh = 11 files - expect(Object.keys(result.files)).toHaveLength(11); + // + orient.sh + version-check.sh = 12 files + expect(Object.keys(result.files)).toHaveLength(12); // Every entry should have checksum and originalChecksum for (const entry of Object.values(result.files)) { diff --git a/packages/cli/src/generators/__tests__/script-parity.test.ts b/packages/cli/src/generators/__tests__/script-parity.test.ts new file mode 100644 index 0000000..c63af5a --- /dev/null +++ b/packages/cli/src/generators/__tests__/script-parity.test.ts @@ -0,0 +1,89 @@ +/** + * The CLI writes one copy of each hook script and the marketplace ships the + * other, and that pair has now drifted three times: the `Stop` gate outlived its + * removal, `version-check.sh` diverged, and `checkpoint.sh` was found apart + * again while this change was being written (a comment and the indentation of + * one block — harmless, and exactly how the harmful ones start). + * + * `version-check.sh` already had this guard. These are the two that did not. + */ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { generateCheckpointScript, generateOrientScript } from '../hooks.js'; + +const BUNDLED_CHECKPOINT = resolve(__dirname, '../../../templates/marketplace/checkpoint.sh'); +const BUNDLED_ORIENT = resolve(__dirname, '../../../templates/marketplace/orient.sh'); + +describe('the generated and bundled scripts are one text', () => { + it('checkpoint.sh', () => { + expect(generateCheckpointScript()).toBe(readFileSync(BUNDLED_CHECKPOINT, 'utf-8')); + }); + + it('orient.sh', () => { + expect(generateOrientScript()).toBe(readFileSync(BUNDLED_ORIENT, 'utf-8')); + }); +}); + +/** Runs a generated script in a throwaway directory and returns that directory. */ +function runIn(script: string, setup: (dir: string) => void): { dir: string; stdout: string } { + const dir = mkdtempSync(join(tmpdir(), 'devtronic-hook-')); + setup(dir); + const path = join(dir, 'hook.sh'); + writeFileSync(path, script); + chmodSync(path, 0o755); + const stdout = execFileSync('bash', [path], { cwd: dir, encoding: 'utf-8' }); + return { dir, stdout }; +} + +// ─── checkpoint.sh does not create thoughts/ where there was none ───────────── + +describe('checkpoint.sh only checkpoints a devtronic project', () => { + const script = generateCheckpointScript(); + + it('leaves an unrelated repo untouched', () => { + // Installed at user scope this hook fires on PreCompact in every repo the + // user opens. Creating `thoughts/` in one that never asked for it is + // pollution — and it is what blocked a clean global install. + const { dir } = runIn(script, () => {}); + expect(existsSync(join(dir, 'thoughts'))).toBe(false); + }); + + it('checkpoints a project that has thoughts/', () => { + const { dir } = runIn(script, (d) => mkdirSync(join(d, 'thoughts'))); + expect(existsSync(join(dir, 'thoughts', 'checkpoints'))).toBe(true); + }); + + it('checkpoints a project that has only the manifest', () => { + // A project can carry `.ai-template/` before it has written any thoughts. + const { dir } = runIn(script, (d) => mkdirSync(join(d, '.ai-template'))); + expect(existsSync(join(dir, 'thoughts', 'checkpoints'))).toBe(true); + }); +}); + +// ─── orient.sh is silent where devtronic is absent ──────────────────────────── + +describe('orient.sh costs nothing outside a devtronic project', () => { + const script = generateOrientScript(); + + it('emits nothing in an unrelated repo', () => { + const { stdout } = runIn(script, () => {}); + expect(stdout).toBe(''); + }); + + it('emits STATE.md content when there is one', () => { + const { stdout } = runIn(script, (d) => { + mkdirSync(join(d, 'thoughts')); + writeFileSync(join(d, 'thoughts', 'STATE.md'), '# Project State\n\nMid-refactor.\n'); + }); + expect(stdout).toContain('Mid-refactor.'); + }); + + it('writes nothing to disk', () => { + // The prompt hook it replaces was read-only; so is this. + const { dir } = runIn(script, (d) => mkdirSync(join(d, 'thoughts'))); + expect(existsSync(join(dir, 'thoughts', 'checkpoints'))).toBe(false); + }); +}); diff --git a/packages/cli/src/generators/hooks.ts b/packages/cli/src/generators/hooks.ts index af9520e..e9bf35f 100644 --- a/packages/cli/src/generators/hooks.ts +++ b/packages/cli/src/generators/hooks.ts @@ -154,12 +154,52 @@ exit 0 `; } +/** + * Generates the `orient.sh` the SessionStart hook runs. + * + * Identical to `templates/marketplace/orient.sh`, and held that way by a test: + * the CLI writes one copy and the marketplace ships the other, and that pair has + * drifted three times now (the `Stop` gate, `version-check.sh`, `checkpoint.sh`). + */ +export function generateOrientScript(): string { + return `#!/bin/bash +# Session orientation. Generated by devtronic. +# +# Emits project context on stdout, which Claude Code adds to the session. It +# replaces a \`type: prompt\` hook that called Haiku on every startup — a cost +# worth paying in a devtronic project, and pure waste in the other twenty repos +# a user opens once the plugin is installed at user scope. +# +# Always exits 0: a session must never fail to start because of orientation. + +[ -d "thoughts" ] || [ -d ".ai-template" ] || exit 0 + +if [ -f "thoughts/STATE.md" ]; then + echo "## devtronic — project state" + echo "" + sed -n '1,40p' thoughts/STATE.md + echo "" +fi + +BRANCH=$(git branch --show-current 2>/dev/null) +[ -n "$BRANCH" ] && echo "Branch: $BRANCH" + +CHANGES=$(git status --short 2>/dev/null | head -10) +if [ -n "$CHANGES" ]; then + echo "Uncommitted changes:" + echo "$CHANGES" +fi + +exit 0 +`; +} + /** * Generates a hooks.json configuration personalized by the project's * package manager and quality command. * * Hooks included: - * - SessionStart: quick project orientation (prompt, Haiku 4.5) + * - SessionStart: project orientation (command, no model call) * - PostToolUse(Write|Edit): auto-lint after each file change (command) * - SubagentStop: validate subagent output (prompt, Haiku 4.5) * - PreCompact: auto-checkpoint before context compaction (command) @@ -170,14 +210,17 @@ export function generateHooks(): string { hooks: { SessionStart: [ { + // Orientation used to be a prompt hook: a Haiku call on every startup, + // in every repo. That is a fair price in a devtronic project and pure + // waste everywhere else — and once the plugin is installed at user + // scope, "everywhere else" is most of the repos a user opens. The + // script reads the same files and costs nothing when they are absent. matcher: 'startup', hooks: [ { - type: 'prompt', - prompt: - 'Quick project orientation: First check if thoughts/STATE.md exists — if so, read it and summarize the current project state. Then check git status, recent commits, and any in-progress work. Give a 3-line summary prioritizing STATE.md context if available.\n\nContext: $ARGUMENTS', - model: 'claude-haiku-4-5-20251001', - timeout: 30, + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/orient.sh 2>/dev/null || true', + timeout: 15, }, ], }, @@ -298,6 +341,16 @@ export function generateCheckpointScript(): string { # Auto-checkpoint before context compaction # Generated by devtronic +# Only checkpoint in a project that uses devtronic. Installed at user scope this +# hook fires in every repo you open, and creating \`thoughts/\` in a repo that +# never asked for it is pollution, not a feature. +# +# Guarded on either marker: a project can carry the manifest before it has a +# thoughts/ directory, and an older install can have it the other way round. +if [ ! -d "thoughts" ] && [ ! -d ".ai-template" ]; then + exit 0 +fi + CHECKPOINT_DIR="thoughts/checkpoints" TIMESTAMP=$(date +%Y%m%d_%H%M%S) @@ -316,27 +369,28 @@ mkdir -p "$CHECKPOINT_DIR" echo "Checkpoint saved: $CHECKPOINT_DIR/\${TIMESTAMP}_pre-compact.md" # Update persistent state (minimal — skill-level checkpoint writes richer state). -# Never clobber a richer STATE.md written by /checkpoint or a human. +# Never clobber a richer STATE.md written by /checkpoint or a human: only write the +# minimal auto-state when STATE.md is absent or was itself an auto-checkpoint. STATE_FILE="thoughts/STATE.md" mkdir -p "$(dirname "$STATE_FILE")" if [ -f "$STATE_FILE" ] && ! grep -q "(auto-compact)" "$STATE_FILE"; then echo "Preserved existing STATE.md — see the pre-compact checkpoint for context." else -BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") -{ - echo "# Project State" - echo "" - echo "**Updated**: $(date '+%Y-%m-%d %H:%M') (auto-compact)" - echo "**Branch**: $BRANCH" - echo "" - echo "## Last Auto-Checkpoint" - echo "" - echo "Context was compacted. See: \\\`$CHECKPOINT_DIR/\${TIMESTAMP}_pre-compact.md\\\`" - echo "" - echo "## Recent Commits" - echo "" - git log --oneline -5 2>/dev/null || echo "No commits" -} > "$STATE_FILE" + BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") + { + echo "# Project State" + echo "" + echo "**Updated**: $(date '+%Y-%m-%d %H:%M') (auto-compact)" + echo "**Branch**: $BRANCH" + echo "" + echo "## Last Auto-Checkpoint" + echo "" + echo "Context was compacted. See: \\\`$CHECKPOINT_DIR/\${TIMESTAMP}_pre-compact.md\\\`" + echo "" + echo "## Recent Commits" + echo "" + git log --oneline -5 2>/dev/null || echo "No commits" + } > "$STATE_FILE" fi `; } diff --git a/packages/cli/src/generators/plugin.ts b/packages/cli/src/generators/plugin.ts index bdcef25..7db3f5e 100644 --- a/packages/cli/src/generators/plugin.ts +++ b/packages/cli/src/generators/plugin.ts @@ -14,6 +14,7 @@ import { generateCheckpointScript, generateAutoLintScript, generateVersionCheckScript, + generateOrientScript, } from './hooks.js'; import { CORE_SKILLS } from './rules.js'; @@ -179,7 +180,12 @@ export function generatePlugin( const autoLintRelPath = join(pluginRoot, 'scripts', 'auto-lint.sh'); writeGeneratedFile(targetDir, autoLintRelPath, autoLintContent, files); - // 8. Generate version-check script (SessionStart); reports project ↔ CLI drift + // 8. Generate orient script (SessionStart); emits context, no model call + const orientContent = generateOrientScript(); + const orientRelPath = join(pluginRoot, 'scripts', 'orient.sh'); + writeGeneratedFile(targetDir, orientRelPath, orientContent, files); + + // 9. Generate version-check script (SessionStart); reports project ↔ CLI drift const versionCheckContent = generateVersionCheckScript(); const versionCheckRelPath = join(pluginRoot, 'scripts', 'version-check.sh'); writeGeneratedFile(targetDir, versionCheckRelPath, versionCheckContent, files); diff --git a/packages/cli/templates/marketplace/checkpoint.sh b/packages/cli/templates/marketplace/checkpoint.sh index 46e7a72..b949c35 100644 --- a/packages/cli/templates/marketplace/checkpoint.sh +++ b/packages/cli/templates/marketplace/checkpoint.sh @@ -2,6 +2,16 @@ # Auto-checkpoint before context compaction # Generated by devtronic +# Only checkpoint in a project that uses devtronic. Installed at user scope this +# hook fires in every repo you open, and creating `thoughts/` in a repo that +# never asked for it is pollution, not a feature. +# +# Guarded on either marker: a project can carry the manifest before it has a +# thoughts/ directory, and an older install can have it the other way round. +if [ ! -d "thoughts" ] && [ ! -d ".ai-template" ]; then + exit 0 +fi + CHECKPOINT_DIR="thoughts/checkpoints" TIMESTAMP=$(date +%Y%m%d_%H%M%S) diff --git a/packages/cli/templates/marketplace/hooks.json b/packages/cli/templates/marketplace/hooks.json index 46bbe27..23c53cf 100644 --- a/packages/cli/templates/marketplace/hooks.json +++ b/packages/cli/templates/marketplace/hooks.json @@ -6,10 +6,9 @@ "matcher": "startup", "hooks": [ { - "type": "prompt", - "prompt": "Quick project orientation: First check if thoughts/STATE.md exists — if so, read it and summarize the current project state. Then check git status, recent commits, and any in-progress work. Give a 3-line summary prioritizing STATE.md context if available.\n\nContext: $ARGUMENTS", - "model": "claude-haiku-4-5-20251001", - "timeout": 30 + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/orient.sh 2>/dev/null || true", + "timeout": 15 } ] }, @@ -85,4 +84,4 @@ } ] } -} \ No newline at end of file +} diff --git a/packages/cli/templates/marketplace/orient.sh b/packages/cli/templates/marketplace/orient.sh new file mode 100755 index 0000000..58d051d --- /dev/null +++ b/packages/cli/templates/marketplace/orient.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Session orientation. Generated by devtronic. +# +# Emits project context on stdout, which Claude Code adds to the session. It +# replaces a `type: prompt` hook that called Haiku on every startup — a cost +# worth paying in a devtronic project, and pure waste in the other twenty repos +# a user opens once the plugin is installed at user scope. +# +# Always exits 0: a session must never fail to start because of orientation. + +[ -d "thoughts" ] || [ -d ".ai-template" ] || exit 0 + +if [ -f "thoughts/STATE.md" ]; then + echo "## devtronic — project state" + echo "" + sed -n '1,40p' thoughts/STATE.md + echo "" +fi + +BRANCH=$(git branch --show-current 2>/dev/null) +[ -n "$BRANCH" ] && echo "Branch: $BRANCH" + +CHANGES=$(git status --short 2>/dev/null | head -10) +if [ -n "$CHANGES" ]; then + echo "Uncommitted changes:" + echo "$CHANGES" +fi + +exit 0 From bdfa569f9a2d572f832310b17fd91c8fab40efc8 Mon Sep 17 00:00:00 2001 From: Roberto Diaz Date: Wed, 26 Aug 2026 20:21:21 +0200 Subject: [PATCH 3/7] fix(release): stop the published README from outliving the hooks it documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin repo's README advertised "**Stop**: Quality gate before stopping" for two releases after the Stop gate was deleted — in a repo that carries a "do not send PRs here, this is auto-generated" notice. The notice was wrong: the sync mirrors skills, agents, hooks and scripts, and never touched the README. The hook list is now derived from the `hooks.json` that actually ships, so the one claim in that file that could go stale cannot. The script already refused to publish a hook pointing at a script the plugin does not carry. It now gives the manifests the same treatment and runs `claude plugin validate` over both, skipped with a notice where the CLI is not on PATH — which is the case in CI, so this cannot break a release. Also declares `userConfig` on the generated `plugin.json`. It is what gives `profile` and `mode` a home at user scope: `devtronic mode` writes into a project, which means nothing for a plugin installed globally. Values reach hooks as `CLAUDE_PLUGIN_OPTION_`. The schema takes type/title/description and accepts only string|number|boolean|directory|file — there is no enum — so the profile's values live in its description and mode is expressed as the boolean `afk`, where the type does the constraining. --- .../__tests__/sync-plugin-repo.test.ts | 82 +++++++++++++++++++ scripts/sync-plugin-repo.sh | 72 +++++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/generators/__tests__/sync-plugin-repo.test.ts b/packages/cli/src/generators/__tests__/sync-plugin-repo.test.ts index 1b64b30..0b940e9 100644 --- a/packages/cli/src/generators/__tests__/sync-plugin-repo.test.ts +++ b/packages/cli/src/generators/__tests__/sync-plugin-repo.test.ts @@ -35,6 +35,34 @@ function buildFixture(root: string): string { writeFileSync(join(pluginDir, 'skills', 'retired-skill', 'SKILL.md'), '# gone\n'); writeFileSync(join(pluginDir, 'agents', 'retired-agent.md'), '# gone\n'); writeFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), '{"version":"1.5.1"}'); + + // The published repo carries a marketplace descriptor and a README. The README + // is the half the sync never touched: it advertised the `Stop` gate for two + // releases after the gate was deleted. + mkdirSync(join(root, '.claude-plugin'), { recursive: true }); + writeFileSync( + join(root, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'devtronic', + description: 'fixture marketplace', + owner: { name: 'r-bart', url: 'https://github.com/r-bart/devtronic' }, + plugins: [{ name: 'devtronic', source: './plugins/devtronic', description: 'fixture' }], + }) + ); + writeFileSync( + join(root, 'README.md'), + [ + '# devtronic', + '', + '### Hooks', + '', + '- **SessionStart**: Quick project orientation', + '- **Stop**: Quality gate before stopping', + '', + '## How It Works', + '', + ].join('\n') + ); return pluginDir; } @@ -170,4 +198,58 @@ describe('sync-plugin-repo.sh', () => { rmSync(empty, { recursive: true, force: true }); } }); + + // ─── The README cannot advertise a hook that no longer ships ──────────────── + + it('rewrites the README hook list from the hooks that actually ship', () => { + runSync(tempDir); + + const readme = readFileSync(join(tempDir, 'README.md'), 'utf-8'); + const shipped = Object.keys( + JSON.parse(readFileSync(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8')).hooks + ); + + // The stale claim is gone... + expect(readme).not.toContain('- **Stop**:'); + // ...and every event that does ship is named. + for (const event of shipped) { + expect(readme).toContain(`- **${event}**`); + } + // Surrounding prose survives. + expect(readme).toContain('## How It Works'); + }); + + it('declares the user-scope config the plugin reads', () => { + // `profile` and `mode` had no home at user scope: `devtronic mode` writes + // into a project. userConfig is that home — the values reach hooks as + // CLAUDE_PLUGIN_OPTION_. The schema takes no enum, so `mode` is + // expressed as the boolean `afk`. + runSync(tempDir); + + const manifest = JSON.parse( + readFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf-8') + ); + + expect(manifest.userConfig.profile.type).toBe('string'); + expect(manifest.userConfig.profile.default).toBe('balanced'); + expect(manifest.userConfig.afk.type).toBe('boolean'); + // title and description are required by the manifest schema. + for (const option of Object.values(manifest.userConfig) as Record[]) { + expect(typeof option.title).toBe('string'); + expect(typeof option.description).toBe('string'); + } + }); + + it('leaves a repo with no README alone', () => { + rmSync(join(tempDir, 'README.md')); + expect(() => runSync(tempDir)).not.toThrow(); + }); + + it('publishes manifests that validate', () => { + // `claude` is absent in CI, where the script skips validation by design. + // Locally this is the guard that a malformed manifest never gets published. + const out = runSync(tempDir); + expect(out).toBeTypeOf('string'); + expect(existsSync(join(pluginDir, '.claude-plugin', 'plugin.json'))).toBe(true); + }); }); diff --git a/scripts/sync-plugin-repo.sh b/scripts/sync-plugin-repo.sh index 723966b..e89e73b 100755 --- a/scripts/sync-plugin-repo.sh +++ b/scripts/sync-plugin-repo.sh @@ -53,9 +53,55 @@ for script in $(grep -o 'scripts/[A-Za-z0-9_.-]*\.sh' "$PLUGIN_DIR/hooks/hooks.j done [ "$MISSING" -eq 0 ] || exit 1 +# The README's hook list is the one claim in that file the sync never touched, +# and it outlived the retired `Stop` gate by two releases: the repo carries a +# "do not send PRs here, this is auto-generated" notice while this section was +# hand-written and wrong. Derive it from the hooks that actually ship. +# +# Node rather than jq alone: the replacement spans lines, and the repo already +# shells out to node in auto-lint.sh. +# A marketplace repo is not required to carry a README, and one that does not +# document its hooks cannot misdocument them. Update it where it exists. +if [ -f "$REPO/README.md" ]; then +node - "$REPO/README.md" "$PLUGIN_DIR/hooks/hooks.json" <<'NODE' +const fs = require('fs'); +const [readme, hooksPath] = process.argv.slice(2); + +// A one-line summary per event we ship. An event with no entry still gets +// listed — unnamed, never omitted — so a new hook shows up as a visible gap +// rather than silently missing from the published docs. +const BLURB = { + SessionStart: 'Project orientation on startup', + PostToolUse: 'Auto-lint after source edits', + StopFailure: 'Release the loop sentinel when a turn dies', + SubagentStop: 'Validate subagent completion', + PreCompact: 'Auto-checkpoint before context compaction', +}; + +const events = Object.keys(JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks); +const list = events + .map((e) => (BLURB[e] ? `- **${e}**: ${BLURB[e]}` : `- **${e}**`)) + .join('\n'); + +const src = fs.readFileSync(readme, 'utf8'); +const re = /^### Hooks\n\n(?:- .*\n)+/m; +if (re.test(src)) { + fs.writeFileSync(readme, src.replace(re, `### Hooks\n\n${list}\n`)); +} +NODE +fi + SKILL_COUNT=$(find "$PLUGIN_DIR/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') AGENT_COUNT=$(find "$PLUGIN_DIR/agents" -name '*.md' | wc -l | tr -d ' ') +# `userConfig` is what gives `profile` and `mode` a home at user scope: today +# `devtronic mode` writes into the project, which has no meaning for a plugin +# installed globally. Values reach hooks as CLAUDE_PLUGIN_OPTION_. +# +# The schema takes type/title/description (all required) plus default, and +# accepts only string|number|boolean|directory|file — there is no enum. So the +# profile's three values live in its description, and mode is expressed as the +# boolean `afk`, where the type does the constraining. jq -n \ --arg version "$VERSION" \ --arg desc "Agentic development toolkit — ${SKILL_COUNT} skills, ${AGENT_COUNT} agents, workflow hooks" \ @@ -69,7 +115,31 @@ jq -n \ }, homepage: "https://github.com/r-bart/devtronic", repository: "https://github.com/r-bart/devtronic-plugin", - license: "MIT" + license: "MIT", + userConfig: { + profile: { + type: "string", + title: "Model profile", + description: "Model tier for devtronic subagents: quality, balanced, or budget. Reaches hooks as CLAUDE_PLUGIN_OPTION_PROFILE.", + default: "balanced" + }, + afk: { + type: "boolean", + title: "AFK mode", + description: "Run the convergence loop unattended instead of stopping at human gates. Reaches hooks as CLAUDE_PLUGIN_OPTION_AFK.", + default: false + } + } }' > "$PLUGIN_DIR/.claude-plugin/plugin.json" +# The script already refuses to ship a hook pointing at a script the plugin does +# not carry. The manifests deserve the same guard: a malformed plugin.json or +# marketplace.json fails in every install, not just ours. +if command -v claude >/dev/null 2>&1; then + claude plugin validate "$PLUGIN_DIR" + claude plugin validate "$REPO" +else + echo "sync-plugin-repo: claude CLI not on PATH — skipping manifest validation" >&2 +fi + echo "sync-plugin-repo: v${VERSION} — ${SKILL_COUNT} skills, ${AGENT_COUNT} agents, $(ls -1 "$PLUGIN_DIR/scripts" | wc -l | tr -d ' ') scripts" From 8a7925fa6a8ec5743138223bdab681e083c4dc6c Mon Sep 17 00:00:00 2001 From: Roberto Diaz Date: Wed, 26 Aug 2026 20:21:39 +0200 Subject: [PATCH 4/7] feat(init): install devtronic at user scope with --global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin has always been installable for every project through Claude Code's own UI — `/plugin install devtronic@devtronic`, which defaults to `--scope user`. The CLI was the one path that could not: every command resolved its destination as `resolve(options.path || '.')`, so the only scope that existed was "the current project". `devtronic init --global` registers the marketplace in `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR`) and writes nothing else. No CLAUDE.md, no rules, no `thoughts/` — there is no project here to derive them from, and a project-level `init` stays the way to get them. It is a separate path rather than the same 700-line flow behind conditionals, because almost all of that flow exists to analyze a project. A project install that finds a user-scope one no longer re-registers the plugin, and says so instead of claiming a registration it did not perform. `doctor` learned the same rule: without it, it would report a correct global install as a fault and `--fix` would write back exactly the entry `init` decided to leave out — the two commands undoing each other. Two things a user-scope target makes dangerous that a project target did not: - The standalone-era hook sweep is now confined to project scope. It rests on "everything matching these signatures was written here by devtronic", which is true of a project and false of the user's own settings file, where the signatures (`${CLAUDE_PLUGIN_ROOT}/scripts/…`, `npx eslint --fix --quiet`) name no plugin in particular. It was deleting other plugins' hooks. - Registering is a read-modify-write, and the read answers unparseable JSON with `{}` so callers can carry on. Landing that write on `~/.claude/settings.json` replaced the user's theme, model, permissions and autoMode — a file kept in no repository — over a trailing comma. It now refuses and names the file to fix. `resolveScope` resolves user scope to `~/.claude`, not `~`: a caller that prepends `.claude/` would otherwise produce `~/.claude/.claude/`, and a CLAUDE.md written at `~` is read by nothing. `CLAUDE_CONFIG_DIR` wins where set, so a multi-account setup does not get the wrong profile configured silently. --- .../__tests__/init-addon-selection.test.ts | 5 + .../commands/__tests__/init-global.test.ts | 245 ++++++++++++++++++ packages/cli/src/commands/doctor.ts | 26 +- packages/cli/src/commands/init.ts | 145 ++++++++++- packages/cli/src/index.ts | 3 + packages/cli/src/types.ts | 2 + .../cli/src/utils/__tests__/scope.test.ts | 70 +++++ packages/cli/src/utils/scope.ts | 37 +++ packages/cli/src/utils/settings.ts | 86 +++++- 9 files changed, 598 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/init-global.test.ts create mode 100644 packages/cli/src/utils/__tests__/scope.test.ts create mode 100644 packages/cli/src/utils/scope.ts diff --git a/packages/cli/src/commands/__tests__/init-addon-selection.test.ts b/packages/cli/src/commands/__tests__/init-addon-selection.test.ts index 37840b7..7d8d6f3 100644 --- a/packages/cli/src/commands/__tests__/init-addon-selection.test.ts +++ b/packages/cli/src/commands/__tests__/init-addon-selection.test.ts @@ -60,6 +60,11 @@ vi.mock('../../utils/version.js', () => ({ vi.mock('../../utils/settings.js', () => ({ registerPlugin: vi.fn(), registerGitHubPlugin: vi.fn(), + registerGitHubPluginAt: vi.fn(), + // init reads this to decide whether a user-scope install already enables the + // plugin. Left out of the mock it arrives as undefined and init dies before + // writing the manifest — which is how this mock caught the new import. + readClaudeSettingsAt: vi.fn(() => ({})), })); // Bypass TTY check so initCommand can run in non-interactive test environment diff --git a/packages/cli/src/commands/__tests__/init-global.test.ts b/packages/cli/src/commands/__tests__/init-global.test.ts new file mode 100644 index 0000000..3df0611 --- /dev/null +++ b/packages/cli/src/commands/__tests__/init-global.test.ts @@ -0,0 +1,245 @@ +/** + * `devtronic init --global` — the install that was missing. + * + * The plugin has always been installable at user scope through Claude Code's own + * UI (`/plugin install devtronic@devtronic`, which defaults to `--scope user`). + * The CLI was the one path that forced project scope, writing + * `/.claude/settings.json` and nothing else. + * + * These tests redirect CLAUDE_CONFIG_DIR at a temp directory: a test that writes + * to the real ~/.claude would reconfigure the machine running it. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { initCommand } from '../init.js'; +import { GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO, PLUGIN_NAME } from '../../generators/plugin.js'; + +const PLUGIN_KEY = `${PLUGIN_NAME}@${GITHUB_MARKETPLACE_NAME}`; + +describe('init --global', () => { + let configDir: string; + const original = process.env.CLAUDE_CONFIG_DIR; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'devtronic-global-')); + process.env.CLAUDE_CONFIG_DIR = configDir; + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = original; + vi.restoreAllMocks(); + }); + + function settings(): Record & { + enabledPlugins?: Record; + extraKnownMarketplaces?: Record; + } { + return JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf-8')); + } + + it('enables the plugin at user scope', async () => { + await initCommand({ global: true, yes: true }); + + expect(settings().enabledPlugins?.[PLUGIN_KEY]).toBe(true); + expect(settings().extraKnownMarketplaces?.[GITHUB_MARKETPLACE_NAME].source.repo).toBe( + GITHUB_MARKETPLACE_REPO + ); + }); + + it('writes settings.json and nothing else', async () => { + // The whole point of a global install is that it does not scatter project + // files. No CLAUDE.md, no rules, no thoughts/, no loop.manifest.yaml. + await initCommand({ global: true, yes: true }); + + expect(readdirSync(configDir)).toEqual(['settings.json']); + }); + + it('touches no project files', async () => { + const project = mkdtempSync(join(tmpdir(), 'devtronic-proj-')); + try { + await initCommand({ global: true, yes: true, path: project }); + + // `--global` wins over `--path`; the project directory stays empty. + expect(readdirSync(project)).toEqual([]); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + it('is idempotent', async () => { + await initCommand({ global: true, yes: true }); + const first = readFileSync(join(configDir, 'settings.json'), 'utf-8'); + + await initCommand({ global: true, yes: true }); + + expect(readFileSync(join(configDir, 'settings.json'), 'utf-8')).toBe(first); + }); + + it('preserves settings it did not write', async () => { + const { writeFileSync, mkdirSync } = await import('node:fs'); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify({ theme: 'dark', enabledPlugins: { 'other@mkt': true } }) + ); + + await initCommand({ global: true, yes: true }); + + expect(settings().theme).toBe('dark'); + expect(settings().enabledPlugins?.['other@mkt']).toBe(true); + expect(settings().enabledPlugins?.[PLUGIN_KEY]).toBe(true); + }); + + it('changes nothing under --preview', async () => { + await initCommand({ global: true, preview: true }); + + expect(existsSync(join(configDir, 'settings.json'))).toBe(false); + }); +}); + +describe('doctor recognises a user-scope install', () => { + let configDir: string; + const original = process.env.CLAUDE_CONFIG_DIR; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'devtronic-global-doc-')); + process.env.CLAUDE_CONFIG_DIR = configDir; + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = original; + vi.restoreAllMocks(); + }); + + it('does not call a globally-installed project broken', async () => { + // `init` deliberately leaves the project settings alone when the plugin is + // already enabled for every project. Doctor has to agree, or it reports the + // correct setup as a fault and `--fix` writes back exactly what init left + // out — the two commands undoing each other. + await initCommand({ global: true, yes: true }); + + const { checkPluginRegisteredForTest } = await import('../doctor.js'); + const project = mkdtempSync(join(tmpdir(), 'devtronic-proj-doc-')); + try { + const check = checkPluginRegisteredForTest(project, 'marketplace'); + expect(check.status).toBe('pass'); + expect(check.message).toContain('user scope'); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + it('still flags a project with no install anywhere', async () => { + const { checkPluginRegisteredForTest } = await import('../doctor.js'); + const project = mkdtempSync(join(tmpdir(), 'devtronic-proj-doc-')); + try { + const check = checkPluginRegisteredForTest(project, 'marketplace'); + expect(check.status).toBe('warn'); + expect(check.fixable).toBe(true); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); +}); + +describe('init --global never touches hooks it did not write', () => { + let configDir: string; + const original = process.env.CLAUDE_CONFIG_DIR; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'devtronic-global-hooks-')); + process.env.CLAUDE_CONFIG_DIR = configDir; + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = original; + vi.restoreAllMocks(); + }); + + it('preserves another plugin’s hooks in the user settings', async () => { + // The legacy sweep exists to remove standalone-era hooks devtronic itself + // wrote into a *project*. `~/.claude/settings.json` is the user's own file — + // devtronic has never written hooks there — and the signatures are loose + // enough to match somebody else's work. Sweeping here destroys it. + const { writeFileSync } = await import('node:fs'); + writeFileSync( + join(configDir, 'settings.json'), + JSON.stringify({ + theme: 'dark', + hooks: { + SessionStart: [ + { + matcher: 'startup', + hooks: [{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/scripts/other-plugin.sh' }], + }, + ], + PostToolUse: [ + { matcher: 'Write', hooks: [{ type: 'command', command: 'npx eslint --fix --quiet' }] }, + ], + }, + }) + ); + + await initCommand({ global: true, yes: true }); + + const after = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf-8')); + expect(Object.keys(after.hooks)).toEqual(['SessionStart', 'PostToolUse']); + expect(after.hooks.SessionStart[0].hooks[0].command).toBe( + '${CLAUDE_PLUGIN_ROOT}/scripts/other-plugin.sh' + ); + expect(after.theme).toBe('dark'); + }); +}); + +describe('init --global refuses to overwrite settings it cannot read', () => { + let configDir: string; + const original = process.env.CLAUDE_CONFIG_DIR; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'devtronic-global-bad-')); + process.env.CLAUDE_CONFIG_DIR = configDir; + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = original; + process.exitCode = undefined; + vi.restoreAllMocks(); + }); + + it('leaves a malformed settings.json exactly as it found it', async () => { + // Registering is a read-modify-write, and the read answers unparseable JSON + // with `{}`. At user scope that turns a trailing comma — the commonest JSON + // typo there is — into the loss of the user's theme, model, permissions and + // autoMode, from a file kept in no repository. + const { writeFileSync } = await import('node:fs'); + const malformed = '{\n "theme": "dark",\n "model": "opus",\n}\n'; + writeFileSync(join(configDir, 'settings.json'), malformed); + + await initCommand({ global: true, yes: true }); + + expect(readFileSync(join(configDir, 'settings.json'), 'utf-8')).toBe(malformed); + expect(process.exitCode).toBe(1); + }); + + it('still installs into a directory with no settings file yet', async () => { + // "absent" is not "unreadable" — a first install must still work. + await initCommand({ global: true, yes: true }); + + expect(existsSync(join(configDir, 'settings.json'))).toBe(true); + expect(process.exitCode).not.toBe(1); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 43895ec..337216c 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -5,7 +5,13 @@ import * as p from '@clack/prompts'; import chalk from 'chalk'; import type { DoctorOptions, DoctorCheck } from '../types.js'; import { readManifest, fileExists, readFile } from '../utils/files.js'; -import { readClaudeSettings, registerPlugin, registerGitHubPlugin } from '../utils/settings.js'; +import { + readClaudeSettings, + readClaudeSettingsAt, + registerPlugin, + registerGitHubPlugin, +} from '../utils/settings.js'; +import { resolveScope } from '../utils/scope.js'; import { introTitle, symbols } from '../utils/ui.js'; import { PLUGIN_NAME, MARKETPLACE_NAME, PLUGIN_DIR, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO } from '../generators/plugin.js'; @@ -240,6 +246,10 @@ export function checkScriptPermissions( }; } +/** Exposed for tests: the scope rule is the part worth pinning down. */ +export const checkPluginRegisteredForTest = (targetDir: string, installMode: string): DoctorCheck => + checkPluginRegistered(targetDir, installMode); + function checkPluginRegistered(targetDir: string, installMode: string): DoctorCheck { const settings = readClaudeSettings(targetDir); @@ -249,6 +259,20 @@ function checkPluginRegistered(targetDir: string, installMode: string): DoctorCh if (isRegistered) { return { name: 'plugin', status: 'pass', message: 'Marketplace plugin registered in .claude/settings.json' }; } + + // A user-scope install enables the same key for every project, and `init` + // deliberately does not duplicate it here. Without this branch doctor calls + // that correct setup broken, and `--fix` writes back the entry init just + // decided to leave out. + const { claudeDir } = resolveScope({ global: true }); + if (readClaudeSettingsAt(claudeDir).enabledPlugins?.[pluginKey] === true) { + return { + name: 'plugin', + status: 'pass', + message: 'Marketplace plugin enabled at user scope (all projects)', + }; + } + return { name: 'plugin', status: 'warn', diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 992bf68..75f3462 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -50,9 +50,15 @@ import { PORTABLE_SKILLS_DIR, PORTABLE_SKILL_IDES, } from '../generators/portableSkills.js'; -import { registerGitHubPlugin } from '../utils/settings.js'; +import { + registerGitHubPlugin, + registerGitHubPluginAt, + readClaudeSettingsAt, + settingsFileStatus, +} from '../utils/settings.js'; import { introTitle, showLogo, symbols, formatKV } from '../utils/ui.js'; import { getCliVersion } from '../utils/version.js'; +import { resolveScope } from '../utils/scope.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); // When bundled, __dirname is /packages/cli/dist, so templates is ../templates @@ -95,7 +101,112 @@ const THOUGHTS_DIRS = [ 'thoughts/archive/backlog', ]; +/** The `plugin@marketplace` key Claude Code enables devtronic under. */ +const PLUGIN_KEY = `${PLUGIN_NAME}@${GITHUB_MARKETPLACE_NAME}`; + +/** + * Whether devtronic is already enabled at user scope. + * + * A project install that finds one has nothing to register: the plugin is + * already active in every session. Re-registering it in the project would put + * the same key in two places, and removing the global install later would leave + * the project pointing at a marketplace nothing enables. + */ +function isEnabledAtUserScope(): boolean { + const { claudeDir } = resolveScope({ global: true }); + return readClaudeSettingsAt(claudeDir).enabledPlugins?.[PLUGIN_KEY] === true; +} + +/** + * Installs devtronic at user scope: every project, no project files. + * + * Deliberately a different shape from a project install rather than the same + * flow with conditionals. There is no project here — no framework to detect, no + * architecture to derive rules from, no `thoughts/` that belongs to anything — + * and the flow below spends most of its 700 lines on exactly those. What a + * global install *is* is four lines of JSON in `~/.claude/settings.json`; the + * skills, agents and hooks come from the marketplace. + * + * Project-level `init` stays the way to get personalized rules and a CLAUDE.md. + */ +async function initGlobal(options: InitOptions): Promise { + const { claudeDir } = resolveScope({ global: true }); + + showLogo(); + p.intro(introTitle('Global install')); + + if (options.preview) { + p.note( + [ + formatKV('Scope:', 'user (all projects)'), + formatKV('Settings:', join(claudeDir, 'settings.json')), + formatKV('Marketplace:', GITHUB_MARKETPLACE_REPO), + formatKV('Plugin:', PLUGIN_KEY), + '', + chalk.dim(' Nothing else is written. No project files are touched.'), + ].join('\n'), + 'Would register' + ); + p.outro('Preview only — nothing was changed.'); + return; + } + + // Registering is a read-modify-write. If the read cannot be trusted, the write + // replaces a file we never understood — and at user scope that file is the + // user's entire Claude Code configuration, kept in no repository. Refuse, and + // say which file to fix. + if (settingsFileStatus(claudeDir) === 'unparseable') { + p.cancel( + `${join(claudeDir, 'settings.json')} is not valid JSON.\n` + + ' Fix it (a trailing comma is the usual cause) and re-run — devtronic will not\n' + + ' overwrite a settings file it could not read.' + ); + process.exitCode = 1; + return; + } + + const already = isEnabledAtUserScope(); + registerGitHubPluginAt(claudeDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + + // No addon term here: addons are a per-project choice (`devtronic addon add`), + // so a user-scope install always reports the base catalogue. + const baseTotal = BASE_SKILL_COUNT + DESIGN_SKILL_COUNT; + + p.note( + [ + formatKV('Scope:', 'user (all projects)'), + formatKV('Settings:', join(claudeDir, 'settings.json')), + formatKV('Marketplace:', GITHUB_MARKETPLACE_REPO), + formatKV('Plugin:', `${PLUGIN_KEY} (${baseTotal} skills, ${BASE_AGENT_COUNT} agents)`), + '', + chalk.dim(already ? ' Already enabled — refreshed the registration.' : ' Newly enabled.'), + ].join('\n'), + 'Registered' + ); + + p.note( + [ + ` 1. Restart Claude Code (or run ${chalk.cyan('/reload-plugins')}) to activate.`, + ` 2. Skills are available everywhere as ${chalk.cyan('/devtronic:brief')}, ${chalk.cyan('/devtronic:spec')}, ...`, + ` 3. In a project you want personalized rules for, run ${chalk.cyan('devtronic init')}.`, + '', + chalk.dim(' A global install writes no project files: no CLAUDE.md, no rules,'), + chalk.dim(' no thoughts/. Those come from a project-level init.'), + ].join('\n'), + 'Next Steps' + ); + + p.outro(`devtronic ${getCliVersion()} installed for all projects`); +} + export async function initCommand(options: InitOptions): Promise { + // A user-scope install has no project to analyze and asks nothing, so it runs + // before the interactivity guard rather than through it. + if (options.global) { + await initGlobal(options); + return; + } + if (!options.yes && !options.preview) { ensureInteractive('init'); } @@ -309,15 +420,29 @@ export async function initCommand(options: InitOptions): Promise { // Plugin mode: when claude-code is selected, generate a plugin instead // of copying skills/agents as standalone files into .claude/ const usePluginMode = selectedIDEs.includes('claude-code'); + // Whether a user-scope install already enables the plugin, so the summary + // below reports what actually happened instead of a fixed claim. + const enabledGlobally = usePluginMode && isEnabledAtUserScope(); if (usePluginMode) { - // Register GitHub marketplace (plugin content lives in remote repo) - registerGitHubPlugin( - targetDir, - PLUGIN_NAME, - GITHUB_MARKETPLACE_NAME, - GITHUB_MARKETPLACE_REPO - ); + // A user-scope install already enables this exact plugin key in every + // session, so registering it again here would only duplicate it — and + // uninstalling globally later would leave the project enabling a plugin + // from a marketplace nothing knows about. The rules and CLAUDE.md below are + // what a project install is actually for. + if (enabledGlobally) { + p.log.info( + 'devtronic is already installed for all projects — skipping plugin registration here.' + ); + } else { + // Register GitHub marketplace (plugin content lives in remote repo) + registerGitHubPlugin( + targetDir, + PLUGIN_NAME, + GITHUB_MARKETPLACE_NAME, + GITHUB_MARKETPLACE_REPO + ); + } // Set install mode manifest.installMode = 'marketplace'; @@ -538,13 +663,13 @@ export async function initCommand(options: InitOptions): Promise { p.note( [ ` Marketplace: ${chalk.cyan(GITHUB_MARKETPLACE_REPO)}`, - ` Plugin: ${chalk.cyan(PLUGIN_NAME)}`, + ` Plugin: ${chalk.cyan(PLUGIN_NAME)}${enabledGlobally ? chalk.dim(' (enabled at user scope)') : ''}`, ` Skills: /devtronic:brief, /devtronic:spec, ... (auto-namespaced)`, ` Hooks: SessionStart, PostToolUse, Stop, SubagentStop, PreCompact`, ``, ` ${chalk.dim('Restart Claude Code or run /reload-plugins to activate.')}`, ].join('\n'), - 'GitHub Marketplace Registered' + enabledGlobally ? 'Plugin Already Active (user scope)' : 'GitHub Marketplace Registered' ); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e8ce75b..3605059 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -51,6 +51,7 @@ program .description('Initialize devtronic in your project') .argument('[path]', 'Target directory (default: current directory)') .option('--ide ', 'Comma-separated list of IDEs to configure') + .option('-g, --global', 'Install at user scope (all projects) instead of in this project') .option('-y, --yes', 'Skip prompts and use defaults') .option('--preview', 'Show what would be generated without making changes') .option( @@ -61,6 +62,7 @@ program .action(async (path, options) => { await initCommand({ path, + global: options.global, ide: options.ide, yes: options.yes, preview: options.preview, @@ -277,6 +279,7 @@ program desc: 'Initialize devtronic in your project', opts: [ '--ide Comma-separated list of IDEs', + '-g, --global Install at user scope (all projects)', '-y, --yes Skip prompts and use defaults', '--preview Show what would be generated', '--preset Use a preset (nextjs-clean, react-clean, monorepo, feature-based, minimal)', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index e8af047..9a84f8d 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -181,6 +181,8 @@ export interface Manifest { export interface InitOptions { path?: string; + /** Install at user scope (~/.claude or CLAUDE_CONFIG_DIR) instead of in a project */ + global?: boolean; ide?: string; yes?: boolean; preview?: boolean; diff --git a/packages/cli/src/utils/__tests__/scope.test.ts b/packages/cli/src/utils/__tests__/scope.test.ts new file mode 100644 index 0000000..389b51d --- /dev/null +++ b/packages/cli/src/utils/__tests__/scope.test.ts @@ -0,0 +1,70 @@ +/** + * devtronic could not be installed globally, and the reason was not a missing + * flag: every command resolved its destination as `resolve(options.path || '.')`, + * so the only scope that existed was "the current project". Claude Code has + * modelled scope all along — `claude plugin install --scope user|project|local`, + * defaulting to `user` — and `init` was the one path that ignored it. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { resolveScope } from '../scope.js'; + +describe('resolveScope', () => { + const original = process.env.CLAUDE_CONFIG_DIR; + + beforeEach(() => { + delete process.env.CLAUDE_CONFIG_DIR; + }); + + afterEach(() => { + if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = original; + }); + + it('resolves user scope to ~/.claude, not ~', () => { + // The distinction is not cosmetic: a caller that prepends `.claude/` to `~` + // writes `~/.claude/.claude/settings.json`, and a CLAUDE.md written at `~` + // is read by nothing — user memory lives at `~/.claude/CLAUDE.md`. + const { scope, claudeDir } = resolveScope({ global: true }); + + expect(scope).toBe('user'); + expect(claudeDir).toBe(join(homedir(), '.claude')); + }); + + it('honours CLAUDE_CONFIG_DIR for user scope', () => { + // Multi-account setups point it at ~/.claude-team and friends. Writing to + // ~/.claude there configures the wrong profile, silently. + process.env.CLAUDE_CONFIG_DIR = '/tmp/claude-team'; + + expect(resolveScope({ global: true }).claudeDir).toBe(resolve('/tmp/claude-team')); + }); + + it('ignores a blank CLAUDE_CONFIG_DIR', () => { + process.env.CLAUDE_CONFIG_DIR = ' '; + + expect(resolveScope({ global: true }).claudeDir).toBe(join(homedir(), '.claude')); + }); + + it('resolves project scope against the given path', () => { + const { scope, claudeDir, root } = resolveScope({ path: '/tmp/some-project' }); + + expect(scope).toBe('project'); + expect(root).toBe(resolve('/tmp/some-project')); + expect(claudeDir).toBe(join(resolve('/tmp/some-project'), '.claude')); + }); + + it('defaults project scope to the working directory', () => { + expect(resolveScope({}).root).toBe(resolve('.')); + }); + + it('never leaks project scope into user scope', () => { + // `--global` wins over `--path`: an install meant for every project must not + // land in whichever directory the user happened to be standing in. + process.env.CLAUDE_CONFIG_DIR = '/tmp/claude-team'; + + expect(resolveScope({ global: true, path: '/tmp/some-project' }).claudeDir).toBe( + resolve('/tmp/claude-team') + ); + }); +}); diff --git a/packages/cli/src/utils/scope.ts b/packages/cli/src/utils/scope.ts new file mode 100644 index 0000000..e7ab30a --- /dev/null +++ b/packages/cli/src/utils/scope.ts @@ -0,0 +1,37 @@ +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +export type Scope = 'user' | 'project'; + +export interface ResolvedScope { + scope: Scope; + /** Directory holding settings.json for this scope */ + claudeDir: string; + /** Root that this scope's paths are reported against */ + root: string; +} + +/** + * Claude Code already models scope: `claude plugin install --scope user|project|local`, + * defaulting to `user`. devtronic reimplemented the project half by hand — writing + * `/.claude/settings.json` directly — and ignored the other, which is why + * `init` was the only path that could not install globally. + * + * Two details this gets right that a naive `homedir()` does not: + * + * - User scope resolves to `~/.claude`, **not** `~`. Callers that blindly prepend + * `.claude/` produce `~/.claude/.claude/settings.json`, and a `CLAUDE.md` written + * at `~` is read by nothing — the user memory file is `~/.claude/CLAUDE.md`. + * - `CLAUDE_CONFIG_DIR` wins when set. Multi-account setups (docs/multi-account-setup.md) + * point it at `~/.claude-team` and friends; writing to `~/.claude` there configures + * the wrong profile silently. + */ +export function resolveScope(opts: { global?: boolean; path?: string }): ResolvedScope { + if (opts.global) { + const configured = process.env.CLAUDE_CONFIG_DIR?.trim(); + const claudeDir = configured ? resolve(configured) : join(homedir(), '.claude'); + return { scope: 'user', claudeDir, root: claudeDir }; + } + const root = resolve(opts.path || '.'); + return { scope: 'project', claudeDir: join(root, '.claude'), root }; +} diff --git a/packages/cli/src/utils/settings.ts b/packages/cli/src/utils/settings.ts index 70df94b..fe55073 100644 --- a/packages/cli/src/utils/settings.ts +++ b/packages/cli/src/utils/settings.ts @@ -1,8 +1,6 @@ import { join } from 'node:path'; import { fileExists, readFile, writeFile, ensureDir } from './files.js'; -const SETTINGS_FILE = '.claude/settings.json'; - interface MarketplaceSource { source: string; path?: string; @@ -21,7 +19,19 @@ export interface ClaudeSettings { * Returns an empty object if the file doesn't exist or is invalid. */ export function readClaudeSettings(targetDir: string): ClaudeSettings { - const settingsPath = join(targetDir, SETTINGS_FILE); + return readClaudeSettingsAt(join(targetDir, '.claude')); +} + +/** + * Same as {@link readClaudeSettings}, addressed by the directory that actually + * holds `settings.json`. + * + * User scope puts that directory at `~/.claude` (or `CLAUDE_CONFIG_DIR`), which + * has no project root to hang `.claude/` off. Taking the directory instead of + * its parent is what lets one implementation serve both scopes. + */ +export function readClaudeSettingsAt(claudeDir: string): ClaudeSettings { + const settingsPath = join(claudeDir, 'settings.json'); if (!fileExists(settingsPath)) return {}; try { return JSON.parse(readFile(settingsPath)); @@ -30,14 +40,41 @@ export function readClaudeSettings(targetDir: string): ClaudeSettings { } } +/** + * Whether a settings file is missing, readable, or present but not valid JSON. + * + * `readClaudeSettingsAt` answers an unparseable file with `{}` so a caller can + * carry on. For a read that is right; for a read-modify-**write** it is data + * loss — the write lands on top of a file whose contents were never understood. + * + * That was survivable while the only target was a project's `.claude/`: a small + * file, usually in git. `~/.claude/settings.json` is the user's whole + * configuration — theme, model, permissions, autoMode — kept in no repository, + * and a trailing comma is the commonest way to end up here. + */ +export function settingsFileStatus(claudeDir: string): 'absent' | 'valid' | 'unparseable' { + const settingsPath = join(claudeDir, 'settings.json'); + if (!fileExists(settingsPath)) return 'absent'; + try { + JSON.parse(readFile(settingsPath)); + return 'valid'; + } catch { + return 'unparseable'; + } +} + /** * Writes .claude/settings.json, creating the .claude/ directory if needed. * Preserves all existing keys — callers should read-modify-write. */ export function writeClaudeSettings(targetDir: string, settings: ClaudeSettings): void { - const settingsPath = join(targetDir, SETTINGS_FILE); - ensureDir(join(targetDir, '.claude')); - writeFile(settingsPath, JSON.stringify(settings, null, 2)); + writeClaudeSettingsAt(join(targetDir, '.claude'), settings); +} + +/** Directory-addressed counterpart of {@link writeClaudeSettings}. */ +export function writeClaudeSettingsAt(claudeDir: string, settings: ClaudeSettings): void { + ensureDir(claudeDir); + writeFile(join(claudeDir, 'settings.json'), JSON.stringify(settings, null, 2)); } /** @@ -197,9 +234,38 @@ export function registerGitHubPlugin( marketplaceName: string, githubRepo: string ): string[] { - // The plugin now supplies the hooks, so devtronic's own inline copies are - // duplicates. Anything the user added stays. - const { settings, removed } = stripDevtronicHooks(readClaudeSettings(targetDir)); + // Project scope: sweep the standalone-era hooks devtronic wrote into this + // project's settings, now that the plugin supplies them. + return registerGitHubPluginAt(join(targetDir, '.claude'), pluginName, marketplaceName, githubRepo, { + stripLegacyHooks: true, + }); +} + +/** + * Directory-addressed counterpart of {@link registerGitHubPlugin}, so a user-scope + * install can register into `~/.claude/settings.json` through the same code path + * a project install uses. + */ +export function registerGitHubPluginAt( + claudeDir: string, + pluginName: string, + marketplaceName: string, + githubRepo: string, + options: { stripLegacyHooks?: boolean } = {} +): string[] { + // The legacy sweep is a *project* migration and must stay one. + // + // `stripDevtronicHooks` rests on a premise that only holds per project: every + // hook matching its signatures was written there by a standalone-era + // devtronic, so removing it removes a duplicate. In `~/.claude/settings.json` + // that premise is false — devtronic has never written hooks there, the file is + // the user's own, and the signatures are loose enough to match somebody else's + // work (`${CLAUDE_PLUGIN_ROOT}/scripts/…` names no plugin in particular). + // Sweeping at user scope deletes hooks we did not write and cannot restore. + const raw = readClaudeSettingsAt(claudeDir); + const { settings, removed } = options.stripLegacyHooks + ? stripDevtronicHooks(raw) + : { settings: raw, removed: [] as string[] }; // Clean up legacy marketplaces and plugins (includes old local marketplace) if (settings.extraKnownMarketplaces) { @@ -236,7 +302,7 @@ export function registerGitHubPlugin( settings.enabledPlugins[pluginKey] = true; } - writeClaudeSettings(targetDir, settings); + writeClaudeSettingsAt(claudeDir, settings); return removed; } From 8243fe84762874fb4345a2ebb48aed232059c069 Mon Sep 17 00:00:00 2001 From: Roberto Diaz Date: Wed, 26 Aug 2026 20:21:39 +0200 Subject: [PATCH 5/7] docs: correct the CI matrix and record the partial-mock gotcha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs Node 20/22/24, matching `engines.node >=20`; the note still said 18/20/22. The gotcha is the one lesson from this change that no test can catch: several suites mock `utils/settings.js` with only the exports they happened to need, so a new import arrives as `undefined` and kills the code under test at the call site. The symptom surfaces far from the cause — an ENOENT on a file the aborted function never reached. --- CLAUDE.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7b94d0..767b2da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,15 @@ npm run typecheck && npm run lint && npm test Run after every change. +### Gotcha: partial module mocks + +Before adding an import to a module that has tests, grep for `vi.mock` of that +module. Several suites mock `utils/settings.js` and friends with only the exports +they happened to need; a new import then arrives as `undefined` and the code +under test dies at the call site. The symptom shows up far from the cause — an +ENOENT on a file the aborted function never got to write — so it costs more to +debug than to prevent. + --- ## Workflow @@ -135,7 +144,7 @@ This is an **open source project** (MIT) published to npm: - Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `ci:` - Semantic Versioning via Keep a Changelog - Branches: `develop` → `main` via PR -- CI: GitHub Actions (Node 18/20/22) +- CI: GitHub Actions (Node 20/22/24 — matches `engines.node >=20`) - Security: report via GitHub Security Advisories (`SECURITY.md`) - Release: tag `v*.*.*` → GitHub Actions publishes to npm - **Never include `Co-Authored-By:` lines in commit messages** From 87f2d559a21759a9299f0505f6a8c786aa5c4f02 Mon Sep 17 00:00:00 2001 From: Roberto Diaz Date: Wed, 26 Aug 2026 20:37:05 +0200 Subject: [PATCH 6/7] fix(settings): never write over a settings file that could not be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `readClaudeSettings` answers unparseable JSON with `{}` so a read-only caller can carry on. Six call sites reused it for a read-modify-**write**, so a trailing comma — the commonest JSON typo there is — replaced `.claude/settings.json` with devtronic's two keys, taking the project's permissions, hooks and env with it. `update` alone accounted for four of them. The guard now lives in the read every write path shares, so refusing is structural rather than something each caller has to remember. Auditing the writers rather than smoke-testing one command is what turned up `unregisterPlugin`, which uninstall reached through and which still used the tolerant read: removing devtronic is no licence to erase the file. What each caller does on refusal depends on what it does next: - `init` stops before its first write, so nothing is half-configured. - `update` and `regenerate` skip the registration, say so loudly, and finish the rest — the remaining work is files devtronic owns, and aborting all of it over one unreadable file helps nobody. - Both marketplace migrations abort. The steps after registration delete the standalone files and the local plugin directory, and doing that with the plugin unregistered leaves the project with neither. - `doctor` reports the malformed file as its own failure, which is more useful than "plugin not registered" — that is merely what an unreadable file looks like — and marks it not fixable, since `--fix` would register into it. The same defect at user scope was fixed with the `--global` install; this is the project half, which predates it and which `update` puts in front of far more people. --- .../__tests__/init-addon-selection.test.ts | 3 + packages/cli/src/commands/doctor.ts | 14 ++ packages/cli/src/commands/init.ts | 13 ++ packages/cli/src/commands/uninstall.ts | 10 +- packages/cli/src/commands/update.ts | 59 ++++++++- .../__tests__/settings-write-guard.test.ts | 121 ++++++++++++++++++ packages/cli/src/utils/settings.ts | 40 +++++- 7 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/settings-write-guard.test.ts diff --git a/packages/cli/src/commands/__tests__/init-addon-selection.test.ts b/packages/cli/src/commands/__tests__/init-addon-selection.test.ts index 7d8d6f3..a1c9663 100644 --- a/packages/cli/src/commands/__tests__/init-addon-selection.test.ts +++ b/packages/cli/src/commands/__tests__/init-addon-selection.test.ts @@ -65,6 +65,9 @@ vi.mock('../../utils/settings.js', () => ({ // plugin. Left out of the mock it arrives as undefined and init dies before // writing the manifest — which is how this mock caught the new import. readClaudeSettingsAt: vi.fn(() => ({})), + // init checks this before its first write, so a settings file it could not + // parse is never replaced. 'absent' is the clean-project case these tests use. + settingsFileStatus: vi.fn(() => 'absent'), })); // Bypass TTY check so initCommand can run in non-interactive test environment diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 337216c..6b33bce 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -8,6 +8,7 @@ import { readManifest, fileExists, readFile } from '../utils/files.js'; import { readClaudeSettings, readClaudeSettingsAt, + settingsFileStatus, registerPlugin, registerGitHubPlugin, } from '../utils/settings.js'; @@ -251,6 +252,19 @@ export const checkPluginRegisteredForTest = (targetDir: string, installMode: str checkPluginRegistered(targetDir, installMode); function checkPluginRegistered(targetDir: string, installMode: string): DoctorCheck { + // A settings file that does not parse is its own fault, and a more useful one + // than "plugin not registered" — which is merely what an unreadable file looks + // like. It is emphatically not fixable from here: `--fix` would register into + // it, replacing a file devtronic never managed to read. + if (settingsFileStatus(join(targetDir, '.claude')) === 'unparseable') { + return { + name: 'plugin', + status: 'fail', + message: '.claude/settings.json is not valid JSON — fix it before devtronic writes to it', + fixable: false, + }; + } + const settings = readClaudeSettings(targetDir); if (installMode === 'marketplace') { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 75f3462..72e6794 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -430,6 +430,19 @@ export async function initCommand(options: InitOptions): Promise { // uninstalling globally later would leave the project enabling a plugin // from a marketplace nothing knows about. The rules and CLAUDE.md below are // what a project install is actually for. + // Registering rewrites .claude/settings.json. Refuse before anything is + // written rather than replacing a file we could not read — the same rule the + // global install follows, and this is the first write of the flow. + if (settingsFileStatus(join(targetDir, '.claude')) === 'unparseable') { + p.cancel( + `${join(targetDir, '.claude', 'settings.json')} is not valid JSON.\n` + + ' Fix it (a trailing comma is the usual cause) and re-run — devtronic will not\n' + + ' overwrite a settings file it could not read.' + ); + process.exitCode = 1; + return; + } + if (enabledGlobally) { p.log.info( 'devtronic is already installed for all projects — skipping plugin registration here.' diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 2f24799..99f4a87 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -4,7 +4,11 @@ import * as p from '@clack/prompts'; import chalk from 'chalk'; import type { Manifest, UninstallOptions } from '../types.js'; import { fileExists, readManifest, MANIFEST_DIR } from '../utils/files.js'; -import { unregisterPlugin, readClaudeSettings, writeClaudeSettings } from '../utils/settings.js'; +import { + unregisterPlugin, + readClaudeSettingsForWrite, + writeClaudeSettings, +} from '../utils/settings.js'; import { PLUGIN_NAME, MARKETPLACE_NAME, PLUGIN_DIR, GITHUB_MARKETPLACE_NAME } from '../generators/plugin.js'; import { ensureInteractive } from '../utils/tty.js'; import { introTitle, symbols } from '../utils/ui.js'; @@ -240,7 +244,7 @@ export async function uninstallCommand(options: UninstallOptions): Promise // 0. Unregister GitHub marketplace from .claude/settings.json if (hasMarketplace) { try { - const settings = readClaudeSettings(targetDir); + const settings = readClaudeSettingsForWrite(targetDir); if (settings.extraKnownMarketplaces?.[GITHUB_MARKETPLACE_NAME]) { delete settings.extraKnownMarketplaces[GITHUB_MARKETPLACE_NAME]; if (Object.keys(settings.extraKnownMarketplaces).length === 0) { @@ -281,7 +285,7 @@ export async function uninstallCommand(options: UninstallOptions): Promise unregisterPlugin(targetDir, PLUGIN_NAME, MARKETPLACE_NAME); // Also remove the marketplace entry - const settings = readClaudeSettings(targetDir); + const settings = readClaudeSettingsForWrite(targetDir); if (settings.extraKnownMarketplaces?.[MARKETPLACE_NAME]) { delete settings.extraKnownMarketplaces[MARKETPLACE_NAME]; if (Object.keys(settings.extraKnownMarketplaces).length === 0) { diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 6cc679e..35435d9 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -34,7 +34,7 @@ import { PORTABLE_SKILLS_DIR, PORTABLE_SKILL_IDES, } from '../generators/portableSkills.js'; -import { registerGitHubPlugin } from '../utils/settings.js'; +import { registerGitHubPlugin, UnreadableSettingsError } from '../utils/settings.js'; import { getCliVersion } from '../utils/version.js'; import { introTitle, symbols, formatKV } from '../utils/ui.js'; import { detectOrphanedAddonFiles, registerAddonInConfig, readAddonConfig } from '../utils/addonConfig.js'; @@ -599,7 +599,13 @@ export async function updateCommand(options: UpdateOptions): Promise { } // Re-register GitHub marketplace if in marketplace mode (idempotent) + // + // A settings file that does not parse stops this step and only this step: the + // rest of the update is files devtronic owns, and aborting all of it over one + // unreadable file helps nobody. Loud, because the registration is what + // activates the plugin. if (manifest.installMode === 'marketplace') { + try { const strippedHooks = registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); if (strippedHooks.length > 0) { // Left behind by the standalone era; the plugin supplies them now. @@ -607,6 +613,13 @@ export async function updateCommand(options: UpdateOptions): Promise { `Removed devtronic's duplicate inline hooks from .claude/settings.json (${strippedHooks.join(', ')}). Hooks you added yourself were left alone.` ); } + } catch (err) { + if (!(err instanceof UnreadableSettingsError)) throw err; + p.log.warn( + `Skipped the plugin registration: ${err.path} is not valid JSON.\n` + + ' Everything else was updated. Fix that file and run `devtronic doctor --fix`.' + ); + } } // Update plugin files if in local plugin mode (not marketplace — marketplace updates via /plugin update) @@ -916,8 +929,13 @@ async function regenerateWithNewStack( // Re-register GitHub marketplace if in marketplace mode if (manifest.installMode === 'marketplace') { - registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); - regeneratedFiles.push('GitHub marketplace registration'); + try { + registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + regeneratedFiles.push('GitHub marketplace registration'); + } catch (err) { + if (!(err instanceof UnreadableSettingsError)) throw err; + p.log.warn(`Skipped the plugin registration: ${err.path} is not valid JSON.`); + } } // Regenerate plugin files if in local plugin mode (hooks depend on config/PM) @@ -1017,7 +1035,23 @@ async function migrateStandaloneToMarketplace( spinner.start('Migrating to GitHub marketplace...'); // 1. Register GitHub marketplace in .claude/settings.json - registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + // + // This one aborts rather than skipping: the steps below delete the standalone + // skills and agents, and doing that without a registered plugin leaves the + // project with neither. + try { + registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + } catch (err) { + if (!(err instanceof UnreadableSettingsError)) throw err; + spinner.stop('Migration cancelled'); + p.cancel( + `${err.path} is not valid JSON.\n` + + ' Fix it and re-run — migrating would remove the standalone files while\n' + + ' leaving the plugin unregistered.' + ); + process.exitCode = 1; + return; + } // 2. Remove standalone skills/agents (only unmodified ones) const removed: string[] = []; @@ -1095,7 +1129,22 @@ async function migrateToMarketplace( spinner.start('Migrating to GitHub marketplace...'); // 1. Register GitHub marketplace (also cleans up old local marketplace) - registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + // + // Aborts rather than skipping, for the same reason as the standalone + // migration: step 2 deletes the local plugin directory. + try { + registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + } catch (err) { + if (!(err instanceof UnreadableSettingsError)) throw err; + spinner.stop('Migration cancelled'); + p.cancel( + `${err.path} is not valid JSON.\n` + + ' Fix it and re-run — migrating would delete the local plugin while\n' + + ' leaving the marketplace unregistered.' + ); + process.exitCode = 1; + return; + } // 2. Remove local plugin directory const pluginDir = join(targetDir, PLUGIN_DIR, PLUGIN_NAME); diff --git a/packages/cli/src/utils/__tests__/settings-write-guard.test.ts b/packages/cli/src/utils/__tests__/settings-write-guard.test.ts new file mode 100644 index 0000000..155d3b6 --- /dev/null +++ b/packages/cli/src/utils/__tests__/settings-write-guard.test.ts @@ -0,0 +1,121 @@ +/** + * A settings file that does not parse is never written over. + * + * `readClaudeSettings` answers unparseable JSON with `{}` so a read-only caller + * can carry on. Reusing that for a read-modify-**write** lands the write on top + * of a file nobody understood: a trailing comma — the commonest JSON typo there + * is — replaced the file with devtronic's two keys, taking the project's + * permissions, hooks and env with it. + * + * The guard lives in the read used by every write path, so refusing is + * structural rather than something each of the six call sites has to remember. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + UnreadableSettingsError, + readClaudeSettingsForWrite, + registerGitHubPlugin, + registerPlugin, + settingsFileStatus, +} from '../settings.js'; + +const MALFORMED = '{\n "permissions": { "allow": ["Bash(npm test)"] },\n}\n'; + +describe('the write guard', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devtronic-guard-')); + mkdirSync(join(dir, '.claude')); + }); + + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + function writeMalformed(): void { + writeFileSync(join(dir, '.claude', 'settings.json'), MALFORMED); + } + + it('tells absent apart from unreadable', () => { + // A first install must still work; only an unreadable file is a refusal. + expect(settingsFileStatus(join(dir, '.claude'))).toBe('absent'); + writeMalformed(); + expect(settingsFileStatus(join(dir, '.claude'))).toBe('unparseable'); + }); + + it('refuses the read that precedes a write', () => { + writeMalformed(); + expect(() => readClaudeSettingsForWrite(dir)).toThrow(UnreadableSettingsError); + }); + + it('leaves the file byte-for-byte intact when registration refuses', () => { + writeMalformed(); + + expect(() => registerGitHubPlugin(dir, 'devtronic', 'devtronic', 'r-bart/devtronic-plugin')).toThrow( + UnreadableSettingsError + ); + expect(readFileSync(join(dir, '.claude', 'settings.json'), 'utf-8')).toBe(MALFORMED); + }); + + it('covers the legacy local-plugin path too', () => { + writeMalformed(); + + expect(() => registerPlugin(dir, 'devtronic', 'devtronic-local', './x')).toThrow( + UnreadableSettingsError + ); + expect(readFileSync(join(dir, '.claude', 'settings.json'), 'utf-8')).toBe(MALFORMED); + }); + + it('names the offending file, so the message is actionable', () => { + writeMalformed(); + + try { + readClaudeSettingsForWrite(dir); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(UnreadableSettingsError); + expect((err as UnreadableSettingsError).path).toBe(join(dir, '.claude', 'settings.json')); + } + }); + + it('still registers normally into a valid file', () => { + writeFileSync(join(dir, '.claude', 'settings.json'), JSON.stringify({ theme: 'dark' })); + + registerGitHubPlugin(dir, 'devtronic', 'devtronic', 'r-bart/devtronic-plugin'); + + const after = JSON.parse(readFileSync(join(dir, '.claude', 'settings.json'), 'utf-8')); + expect(after.theme).toBe('dark'); + expect(after.enabledPlugins['devtronic@devtronic']).toBe(true); + }); + + it('still registers normally when there is no file yet', () => { + registerGitHubPlugin(dir, 'devtronic', 'devtronic', 'r-bart/devtronic-plugin'); + + const after = JSON.parse(readFileSync(join(dir, '.claude', 'settings.json'), 'utf-8')); + expect(after.enabledPlugins['devtronic@devtronic']).toBe(true); + }); +}); + +describe('no write path bypasses the guard', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devtronic-bypass-')); + mkdirSync(join(dir, '.claude')); + writeFileSync(join(dir, '.claude', 'settings.json'), MALFORMED); + }); + + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('unregisterPlugin refuses too', async () => { + // Found by auditing every writer rather than smoke-testing one command: + // uninstall reached settings through this function, which still used the + // tolerant read. Removing devtronic is no licence to erase the file. + const { unregisterPlugin: unregister } = await import('../settings.js'); + + expect(() => unregister(dir, 'devtronic', 'devtronic')).toThrow(UnreadableSettingsError); + expect(readFileSync(join(dir, '.claude', 'settings.json'), 'utf-8')).toBe(MALFORMED); + }); +}); diff --git a/packages/cli/src/utils/settings.ts b/packages/cli/src/utils/settings.ts index fe55073..d98d1d3 100644 --- a/packages/cli/src/utils/settings.ts +++ b/packages/cli/src/utils/settings.ts @@ -40,6 +40,16 @@ export function readClaudeSettingsAt(claudeDir: string): ClaudeSettings { } } +/** + * Raised instead of writing over a settings file that could not be parsed. + */ +export class UnreadableSettingsError extends Error { + constructor(public readonly path: string) { + super(`${path} is not valid JSON`); + this.name = 'UnreadableSettingsError'; + } +} + /** * Whether a settings file is missing, readable, or present but not valid JSON. * @@ -77,6 +87,30 @@ export function writeClaudeSettingsAt(claudeDir: string, settings: ClaudeSetting writeFile(join(claudeDir, 'settings.json'), JSON.stringify(settings, null, 2)); } +/** + * Reads settings for a read-modify-**write**, refusing where the read cannot be + * trusted. + * + * `readClaudeSettings` answers unparseable JSON with `{}` so a read-only caller + * can carry on. Reuse that for a write and the write lands on top of a file + * nobody understood — which is how a trailing comma, the commonest JSON typo + * there is, silently replaced a settings file with devtronic's two keys. + * + * Every path that modifies settings goes through here, so refusing is + * structural rather than something each caller has to remember. + */ +export function readClaudeSettingsForWriteAt(claudeDir: string): ClaudeSettings { + if (settingsFileStatus(claudeDir) === 'unparseable') { + throw new UnreadableSettingsError(join(claudeDir, 'settings.json')); + } + return readClaudeSettingsAt(claudeDir); +} + +/** Project-addressed counterpart of {@link readClaudeSettingsForWriteAt}. */ +export function readClaudeSettingsForWrite(targetDir: string): ClaudeSettings { + return readClaudeSettingsForWriteAt(join(targetDir, '.claude')); +} + /** * Hook entries devtronic itself wrote into `.claude/settings.json` back when a * standalone install carried its own hooks. @@ -185,7 +219,7 @@ export function registerPlugin( marketplaceName: string, marketplacePath: string ): void { - const settings = readClaudeSettings(targetDir); + const settings = readClaudeSettingsForWrite(targetDir); // Clean up legacy marketplaces and plugins if (settings.extraKnownMarketplaces) { @@ -262,7 +296,7 @@ export function registerGitHubPluginAt( // the user's own, and the signatures are loose enough to match somebody else's // work (`${CLAUDE_PLUGIN_ROOT}/scripts/…` names no plugin in particular). // Sweeping at user scope deletes hooks we did not write and cannot restore. - const raw = readClaudeSettingsAt(claudeDir); + const raw = readClaudeSettingsForWriteAt(claudeDir); const { settings, removed } = options.stripLegacyHooks ? stripDevtronicHooks(raw) : { settings: raw, removed: [] as string[] }; @@ -314,7 +348,7 @@ export function unregisterPlugin( pluginName: string, marketplaceName: string ): void { - const settings = readClaudeSettings(targetDir); + const settings = readClaudeSettingsForWrite(targetDir); const pluginKey = `${pluginName}@${marketplaceName}`; if (settings.enabledPlugins) { From 6917e3f1600a2dd947ced01579af37aed9b6985d Mon Sep 17 00:00:00 2001 From: Roberto Diaz Date: Wed, 26 Aug 2026 20:37:15 +0200 Subject: [PATCH 7/7] chore(release): 1.5.3 --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++ packages/cli/package-lock.json | 6 ++-- packages/cli/package.json | 2 +- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f576de..cc6ea6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.3] - 2026-08-26 + +### Added +- **`devtronic init --global`** — install for every project instead of one. It registers the + marketplace in `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR`) and writes nothing else: no + CLAUDE.md, no rules, no `thoughts/`. The plugin was always installable this way through Claude + Code's own UI — `/plugin install devtronic@devtronic` defaults to `--scope user` — and the CLI + was the one path that could not, because every command resolved its destination as + `resolve(options.path || '.')`. A project-level `init` stays the way to get personalized rules + and a CLAUDE.md, and now skips re-registering a plugin that user scope already enables. +- **`userConfig` on the published plugin** — `profile` and `mode` reach hooks as + `CLAUDE_PLUGIN_OPTION_`, giving them a home at user scope. `devtronic mode` writes into a + project, which means nothing for a globally installed plugin. + +### Fixed +- **`/devtronic-help` no longer tells a correctly installed project it has no skills.** It + enumerated `.claude/skills/` with a `find`, which marketplace mode leaves empty, and fell into + its "No devtronic skills detected" branch. It now reads its own `## Skill Categories` table and + scans `.claude/` only for the user's own skills. `doc-sync` counted the same empty directory and + reported documentation drift every time. +- **The architecture rules are read from where devtronic writes them.** + `architecture-checker` read `.claude/architecture-rules.md`, a path the CLI writes nowhere; what + `init` generates is `.claude/rules/architecture.md`. The personalized rule was never read by its + real name, and the agent's error message told the user to hand-write a file devtronic had + already generated elsewhere. +- **The session hooks no longer touch repos that do not use devtronic.** `checkpoint.sh` created + `thoughts/` on PreCompact in any repo it fired in; it now returns early unless `thoughts/` or + `.ai-template/` is already present. SessionStart orientation was a Haiku call on every startup + and is now `orient.sh`, which emits the same context on stdout and costs nothing where devtronic + is absent. Both matter far more once the plugin is installed at user scope, where the hooks run + in every repo you open. +- **The published README can no longer outlive the hooks it documents.** It advertised the `Stop` + gate for two releases after the gate was deleted, because the sync mirrored skills, agents, + hooks and scripts but never the README. Its hook list is now derived from the `hooks.json` that + ships. The release also validates both manifests with `claude plugin validate`, skipped where + the CLI is not on PATH. +- **`doctor` recognises a user-scope install** instead of reporting it as an unregistered plugin + and `--fix`-ing back the entry `init` deliberately left out. +- **A settings file that cannot be parsed is never written over.** `readClaudeSettings` answers + unparseable JSON with `{}` so a read-only caller can carry on; six call sites reused it for a + read-modify-**write**, so a trailing comma replaced `.claude/settings.json` with devtronic's two + keys — taking the project's permissions, hooks and env with it. `update` accounted for four of + them. `init` now stops before its first write, `update` and `regenerate` skip the registration + and say so, both marketplace migrations abort (the steps after them delete files), and `doctor` + reports the malformed file as its own failure rather than as an unregistered plugin. + +### Internal +- Parity tests for `checkpoint.sh` and `orient.sh`, and a whole-object comparison of the two + `hooks.json` copies. The mirrored pairs had drifted three times (the `Stop` gate, then + `version-check.sh`, and `checkpoint.sh` again — found while writing this); the scripts are now + generated from the template's own text, so they cannot drift by construction. + ## [1.5.2] - 2026-08-21 ### Removed diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json index 528aca5..6d4d733 100644 --- a/packages/cli/package-lock.json +++ b/packages/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "devtronic", - "version": "1.5.2", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devtronic", - "version": "1.5.2", + "version": "1.5.3", "license": "MIT", "dependencies": { "@clack/prompts": "^1.0.1", @@ -29,7 +29,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@babel/helper-string-parser": { diff --git a/packages/cli/package.json b/packages/cli/package.json index d0e9ddc..e0fffa8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "devtronic", - "version": "1.5.2", + "version": "1.5.3", "description": "AI-assisted development toolkit — skills, agents, quality gates, and rules for Claude Code, Cursor, Copilot, and Antigravity", "type": "module", "bin": {