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/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** 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": { 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..a1c9663 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,14 @@ 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(() => ({})), + // 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/__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..6b33bce 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -5,7 +5,14 @@ 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, + settingsFileStatus, + 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,7 +247,24 @@ 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 { + // 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') { @@ -249,6 +273,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..72e6794 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,42 @@ 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. + // 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.' + ); + } 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 +676,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/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/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-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/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/__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/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/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/__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/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..d98d1d3 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,75 @@ export function readClaudeSettings(targetDir: 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. + * + * `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)); +} + +/** + * 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')); } /** @@ -148,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) { @@ -197,9 +268,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 = readClaudeSettingsForWriteAt(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 +336,7 @@ export function registerGitHubPlugin( settings.enabledPlugins[pluginKey] = true; } - writeClaudeSettings(targetDir, settings); + writeClaudeSettingsAt(claudeDir, settings); return removed; } @@ -248,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) { 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 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 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"