Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>`, 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
Expand Down
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
245 changes: 245 additions & 0 deletions packages/cli/src/commands/__tests__/init-global.test.ts
Original file line number Diff line number Diff line change
@@ -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
* `<project>/.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<string, never> & {
enabledPlugins?: Record<string, boolean>;
extraKnownMarketplaces?: Record<string, { source: { repo?: string } }>;
} {
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);
});
});
Loading