@bglocation/tune-context 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  // tune-context init — deterministic detect -> generate -> verify.
3
- // This is the CLI/plugin-bin twin of skills/tune-context/SKILL.md: same
4
- // managed-block rules, same "never clobber" guarantee, no model in the loop.
3
+ //
4
+ // This is the deterministic SUBSET of skills/tune-context/SKILL.md, not a full
5
+ // twin of it. What it shares: the same managed-block rules and the same "never
6
+ // clobber" guarantee. What it deliberately omits, because each needs judgment
7
+ // a no-model-in-the-loop CLI cannot have (TASK-075):
8
+ //
9
+ // • SKILL.md §2 step 4 — merging `.mcp.json` to add a semantic-search server
10
+ // when none was detected. Choosing *which* server to add for this repo is a
11
+ // judgment call; the CLI reads `.mcp.json` but never writes it.
12
+ // • SKILL.md §2 step 7 — wiring the wiki lever (RAG segment vs its own MCP vs
13
+ // a plain pointer). That choice depends on where the docs actually live and
14
+ // how they are hosted, which the CLI does not attempt to detect.
15
+ //
16
+ // "No model in the loop" is the feature here, not a gap — but the gap has to be
17
+ // stated, or a CLI user cannot know what they did not get. Run the skill
18
+ // (/tune-context) for the full pass.
5
19
  import { readFileSync } from 'node:fs';
6
20
  import { dirname, join, resolve } from 'node:path';
7
21
  import { fileURLToPath } from 'node:url';
8
- import { claudeDirs, detectExistingConfig, detectMcpServers, detectStack } from '../cli/detect.mjs';
22
+ import { claudeDirs, detectExistingConfig, detectMcpServers, detectRejectedMcpServers, detectStack } from '../cli/detect.mjs';
9
23
  import {
10
24
  buildPermissionsAllow,
11
25
  generateProjectClaudeMd,
12
26
  generateUserClaudeMd,
13
27
  installDoctrine,
28
+ loadSettingsTemplate,
14
29
  mergeHooks,
15
30
  mergePermissions,
16
31
  } from '../cli/generate.mjs';
@@ -21,33 +36,90 @@ const HOOK_EVENTS = [
21
36
  { event: 'SessionStart', scriptName: 'session-start-reinject.mjs' },
22
37
  { event: 'PostToolUse', scriptName: 'adoption-log.mjs' },
23
38
  { event: 'SubagentStart', scriptName: 'adoption-log.mjs' },
39
+ { event: 'UserPromptSubmit', scriptName: 'context-reminder.mjs' },
24
40
  ];
25
41
 
42
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
43
+
44
+ /**
45
+ * Version straight from the shipped package.json — one source of truth, the
46
+ * same file verify:plugin cross-checks against plugin.json/marketplace.json,
47
+ * so `--version` can never drift from what the manifests claim. Both channels
48
+ * ship it: npm always includes package.json in the tarball, and the plugin
49
+ * channel is a git clone of this repo (guarded in verify-plugin's REQUIRE).
50
+ */
51
+ export function readVersion() {
52
+ return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
53
+ }
54
+
26
55
  function usage() {
27
- console.log(`tune-context init — configure this repo for token-efficient Claude Code use.
56
+ return `tune-context init — configure this repo for token-efficient Claude Code use.
28
57
 
29
58
  Usage:
30
59
  tune-context init [--cwd <dir>]
31
60
  tune-context --help
32
- `);
61
+ tune-context --version
62
+ `;
63
+ }
64
+
65
+ // Pure so it is unit-testable without spawning a process. `init` is the ONLY
66
+ // accepted command — no bare-args shortcut (PO decision 2026-07-25, TASK-078):
67
+ // README never once documents calling this without it, and accepting anything
68
+ // un-checked is exactly how a subcommand typo used to silently run init
69
+ // against the wrong repo instead of failing loud.
70
+ export function parseArgs(argv) {
71
+ if (argv.includes('--help') || argv.includes('-h')) return { mode: 'help' };
72
+ // Before the command check, like --help: `--version` is a query about the
73
+ // tool, not an operation on a repo, so it must not require a subcommand.
74
+ if (argv.includes('--version') || argv.includes('-v')) return { mode: 'version' };
75
+ const [command, ...rest] = argv;
76
+ if (command !== 'init') {
77
+ return {
78
+ mode: 'error',
79
+ message: command === undefined ? 'Missing command: init' : `Unknown command: ${command}`,
80
+ };
81
+ }
82
+ const cwdFlagIdx = rest.indexOf('--cwd');
83
+ if (cwdFlagIdx === -1) return { mode: 'init', cwd: process.cwd() };
84
+ const value = rest[cwdFlagIdx + 1];
85
+ // Undefined (last arg) or another flag (e.g. `--cwd --foo`) both mean "no
86
+ // value was actually given" — resolve(undefined) used to throw a raw
87
+ // ERR_INVALID_ARG_TYPE with a Node stacktrace instead of a usage message.
88
+ if (value === undefined || value.startsWith('-')) {
89
+ return { mode: 'error', message: 'Missing value for --cwd' };
90
+ }
91
+ return { mode: 'init', cwd: resolve(value) };
33
92
  }
34
93
 
35
94
  function runInit(cwd) {
36
- const here = dirname(fileURLToPath(import.meta.url));
37
- const pkgRoot = resolve(here, '..');
38
95
  const templatesDir = join(pkgRoot, 'skills', 'tune-context', 'templates');
39
96
  const { claudeDir } = claudeDirs();
40
97
 
41
98
  const stack = detectStack(cwd);
42
99
  const existing = detectExistingConfig(cwd);
43
- const mcpServers = detectMcpServers(cwd);
100
+
101
+ // A server the user rejected out of .mcp.json must not get an
102
+ // `mcp__<server>__*` permission rule, nor a CLAUDE.md line advertising a
103
+ // lever this session does not have (TASK-075). Rejection applies ONLY to
104
+ // project-scope servers — see detectRejectedMcpServers for why user- and
105
+ // local-scope servers are out of its reach.
106
+ const allMcpServers = detectMcpServers(cwd);
107
+ const rejectedNames = detectRejectedMcpServers([
108
+ existing.userSettingsJson,
109
+ existing.projectSettingsJson,
110
+ existing.projectSettingsLocalJson,
111
+ ]);
112
+ const isRejected = (s) => s.scope === 'project' && rejectedNames.has(s.name);
113
+ const mcpServers = allMcpServers.filter((s) => !isRejected(s));
114
+ const rejectedServers = allMcpServers.filter(isRejected);
115
+
44
116
  const detection = { stack, mcpServers };
45
117
 
46
118
  const synced = installDoctrine(pkgRoot, claudeDir);
47
119
  const userClaudeMd = generateUserClaudeMd(claudeDir, templatesDir);
48
120
  const projectClaudeMd = generateProjectClaudeMd(cwd, detection, templatesDir);
49
121
 
50
- const settingsTemplate = JSON.parse(readFileSync(join(templatesDir, 'settings.json.tmpl'), 'utf8'));
122
+ const settingsTemplate = loadSettingsTemplate(templatesDir);
51
123
  const hooksAdded = mergeHooks(existing.userSettingsJson, { hooks: settingsTemplate.hooks });
52
124
  const permissionsAllow = buildPermissionsAllow(detection);
53
125
  const permissionsAdded = mergePermissions(existing.projectSettingsJson, permissionsAllow);
@@ -71,6 +143,10 @@ function runInit(cwd) {
71
143
  console.log(`Hooks merged into ${existing.userSettingsJson}: ${hooksAdded.length ? hooksAdded.join(', ') : 'already present'}`);
72
144
  console.log(`Permissions merged into ${existing.projectSettingsJson}: ${permissionsAdded.length ? permissionsAdded.join(', ') : 'already present'}`);
73
145
  console.log(`MCP servers detected: ${mcpServers.length ? mcpServers.map((s) => `${s.name} (${s.scope}, ${s.classification})`).join(', ') : 'none'}`);
146
+ // Surfaced, never silent: skipping a server is a decision the user should see.
147
+ if (rejectedServers.length) {
148
+ console.log(` skipped (rejected via disabledMcpjsonServers): ${rejectedServers.map((s) => s.name).join(', ')} — no permission rule, no CLAUDE.md line`);
149
+ }
74
150
 
75
151
  console.log(`\nVerify: ${result.ok ? 'OK' : 'FAILED'}`);
76
152
  for (const check of result.checks) {
@@ -79,11 +155,29 @@ function runInit(cwd) {
79
155
  if (!result.ok) process.exitCode = 1;
80
156
  }
81
157
 
82
- const args = process.argv.slice(2);
83
- if (args.includes('--help') || args.includes('-h')) {
84
- usage();
85
- } else {
86
- const cwdFlagIdx = args.indexOf('--cwd');
87
- const cwd = cwdFlagIdx !== -1 ? resolve(args[cwdFlagIdx + 1]) : process.cwd();
88
- runInit(cwd);
158
+ export function main() {
159
+ const parsed = parseArgs(process.argv.slice(2));
160
+ if (parsed.mode === 'help') {
161
+ console.log(usage());
162
+ } else if (parsed.mode === 'version') {
163
+ // Bare number, no prefix or banner this is what a script greps.
164
+ console.log(readVersion());
165
+ } else if (parsed.mode === 'error') {
166
+ // Usage on stderr + exit 1, never a stacktrace, and — because this whole
167
+ // branch returns before runInit() is ever called — no config file is
168
+ // touched on an invalid invocation (TASK-078).
169
+ process.stderr.write(`${parsed.message}\n\n${usage()}`);
170
+ process.exitCode = 1;
171
+ } else {
172
+ runInit(parsed.cwd);
173
+ }
174
+ }
175
+
176
+ // Guarded so importing this file for parseArgs/main (tests, or the
177
+ // extensionless bin/tune-context wrapper) never runs the CLI as a side
178
+ // effect of the import — only direct invocation of THIS file does. The
179
+ // wrapper has a different argv[1] (its own path), so it calls main()
180
+ // explicitly instead of relying on this check.
181
+ if (process.argv[1] && process.argv[1].endsWith('tune-context.mjs')) {
182
+ main();
89
183
  }
package/cli/detect.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
+ import { claudeBaseDir } from '../hooks/config-dir.mjs';
7
8
 
8
9
  function readJson(path) {
9
10
  try {
@@ -13,11 +14,46 @@ function readJson(path) {
13
14
  }
14
15
  }
15
16
 
16
- /** Resolve the base Claude config directory, honoring CLAUDE_CONFIG_DIR. */
17
+ // The actual command a user (and permissions.allow) would type for a
18
+ // package-manager script — single source of truth, consumed by detectStack
19
+ // below so a detected package manager can never be silently ignored when
20
+ // building buildCmd/testCmd/lintCmd (TASK-073).
21
+ //
22
+ // npm/pnpm: `test` has a built-in shorthand (no `run`); build/lint don't.
23
+ // yarn: short form for all three — PO decision 2026-07-25, TASK-073. Yarn
24
+ // doesn't require `run` for scripts that don't collide with a built-in
25
+ // command, and this is the form a user actually types, which is what has to
26
+ // match the permissions.allow rule.
27
+ const RUN_COMMANDS = {
28
+ npm: { build: 'npm run build', test: 'npm test', lint: 'npm run lint' },
29
+ pnpm: { build: 'pnpm run build', test: 'pnpm test', lint: 'pnpm run lint' },
30
+ yarn: { build: 'yarn build', test: 'yarn test', lint: 'yarn lint' },
31
+ };
32
+
33
+ /** @returns {string|null} the command for `script` under `packageManager`, or null if either is unrecognized. */
34
+ export function runCmd(packageManager, script) {
35
+ return RUN_COMMANDS[packageManager]?.[script] ?? null;
36
+ }
37
+
38
+ /**
39
+ * Resolve the base Claude config directory, honoring CLAUDE_CONFIG_DIR, plus
40
+ * the one piece of CLI-specific knowledge on top: `.claude.json`'s path.
41
+ * Thin wrapper — the actual `CLAUDE_CONFIG_DIR || ~/.claude` rule lives
42
+ * solely in claudeBaseDir() (hooks/config-dir.mjs), which also has to be
43
+ * reachable from installed hook scripts (syncDoctrine never copies cli/, so
44
+ * the rule can't live here and be imported the other way — TASK-074).
45
+ *
46
+ * claudeJsonPath is NOT simply `join(claudeDir, '.claude.json')`: under the
47
+ * default (no override), `.claude.json` sits next to `.claude/` as a
48
+ * *sibling* under $HOME, not inside it. An override redirects the whole
49
+ * config surface, `.claude.json` included, so there it lives *inside* the
50
+ * override dir instead. This asymmetry is existing, load-bearing behavior
51
+ * (predates this refactor) — preserved exactly, not something to "fix" here.
52
+ */
17
53
  export function claudeDirs() {
18
54
  const override = process.env.CLAUDE_CONFIG_DIR;
19
55
  return {
20
- claudeDir: override || join(homedir(), '.claude'),
56
+ claudeDir: claudeBaseDir(),
21
57
  claudeJsonPath: override ? join(override, '.claude.json') : join(homedir(), '.claude.json'),
22
58
  };
23
59
  }
@@ -31,17 +67,14 @@ export function detectStack(cwd) {
31
67
  const monorepo = Boolean(
32
68
  pkg.workspaces || existsSync(join(cwd, 'pnpm-workspace.yaml')) || existsSync(join(cwd, 'turbo.json')) || existsSync(join(cwd, 'nx.json')),
33
69
  );
70
+ const packageManager = existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(cwd, 'yarn.lock')) ? 'yarn' : 'npm';
34
71
  return {
35
72
  ecosystem: 'node',
36
73
  name: pkg.name || null,
37
- packageManager: existsSync(join(cwd, 'pnpm-lock.yaml'))
38
- ? 'pnpm'
39
- : existsSync(join(cwd, 'yarn.lock'))
40
- ? 'yarn'
41
- : 'npm',
42
- buildCmd: scripts.build ? 'npm run build' : null,
43
- testCmd: scripts.test ? 'npm test' : null,
44
- lintCmd: scripts.lint ? 'npm run lint' : null,
74
+ packageManager,
75
+ buildCmd: scripts.build ? runCmd(packageManager, 'build') : null,
76
+ testCmd: scripts.test ? runCmd(packageManager, 'test') : null,
77
+ lintCmd: scripts.lint ? runCmd(packageManager, 'lint') : null,
45
78
  monorepo,
46
79
  };
47
80
  }
@@ -65,6 +98,11 @@ export function detectExistingConfig(cwd) {
65
98
  userClaudeMd: existsSync(join(claudeDir, 'CLAUDE.md')) ? join(claudeDir, 'CLAUDE.md') : null,
66
99
  projectMcpJson: existsSync(join(cwd, '.mcp.json')) ? join(cwd, '.mcp.json') : null,
67
100
  projectSettingsJson: join(cwd, '.claude', 'settings.json'),
101
+ // Untracked per-developer overrides. Never written to — read only, because
102
+ // `disabledMcpjsonServers` counts from ANY settings file (see
103
+ // detectRejectedMcpServers) and this is where the docs say per-developer
104
+ // .mcp.json approvals and rejections usually land.
105
+ projectSettingsLocalJson: join(cwd, '.claude', 'settings.local.json'),
68
106
  userSettingsJson: join(claudeDir, 'settings.json'),
69
107
  };
70
108
  }
@@ -105,10 +143,48 @@ export function detectMcpServers(cwd) {
105
143
  return servers;
106
144
  }
107
145
 
108
- /** MCP gating keys from a settings.json — narrows which detected servers are actually reachable. */
109
- export function detectMcpGating(settingsJsonPath) {
110
- const settings = readJson(settingsJsonPath);
111
- if (!settings) return null;
112
- const { enabledMcpjsonServers, disabledMcpjsonServers, allowedMcpServers, deniedMcpServers } = settings;
113
- return { enabledMcpjsonServers, disabledMcpjsonServers, allowedMcpServers, deniedMcpServers };
146
+ /**
147
+ * Server names that settings files explicitly REJECT out of a project's
148
+ * `.mcp.json`. Deliberately narrow — one key, one meaning (TASK-075).
149
+ *
150
+ * Semantics verified against live Claude Code docs (code.claude.com/docs/en/settings
151
+ * and /docs/en/mcp, checked 2026-07-25), NOT inferred from the key names:
152
+ *
153
+ * `disabledMcpjsonServers` is a plain `string[]` of server names, applies
154
+ * ONLY to servers defined in a project `.mcp.json`, and genuinely means
155
+ * rejected — `claude mcp get <name>` renders such a server as
156
+ * "✘ Rejected (see disabledMcpjsonServers in settings)". Every settings file
157
+ * is read, not just one, because the docs are explicit: "A
158
+ * `disabledMcpjsonServers` entry in any settings file still rejects the server."
159
+ *
160
+ * Three neighbouring keys are deliberately NOT honoured here. Each would be a
161
+ * guess, which is the failure mode TASK-073 was about:
162
+ *
163
+ * • `enabledMcpjsonServers` — absence does NOT mean disabled. An unlisted
164
+ * `.mcp.json` server is "⏸ Pending approval": the user is asked and may
165
+ * well approve it. Treating unlisted as disabled would silently drop rules
166
+ * for servers that are about to start working.
167
+ * • `allowedMcpServers` / `deniedMcpServers` — arrays of OBJECTS
168
+ * (`{serverName}` | `{serverUrl}` | `{serverCommand}`), not names. They may
169
+ * appear in any settings file and merge from every source, but they are an
170
+ * organization-policy mechanism: enforcement rests on a managed tier
171
+ * (`managed-settings.json`, server-managed settings, MDM profile or
172
+ * registry) that this CLI does not read, and matching involves URL
173
+ * wildcards, `${VAR}` expansion and denylist-over-allowlist precedence.
174
+ * Honouring them by name alone would misjudge exactly the enterprise setups
175
+ * they exist for — the docs themselves warn that "a `serverName` entry, in
176
+ * either list, is not a security control."
177
+ *
178
+ * @param {string[]} settingsPaths - settings.json files to read, missing ones ignored
179
+ * @returns {Set<string>} rejected server names (empty set when nothing is rejected)
180
+ */
181
+ export function detectRejectedMcpServers(settingsPaths) {
182
+ const rejected = new Set();
183
+ for (const path of settingsPaths) {
184
+ if (!path) continue;
185
+ const list = readJson(path)?.disabledMcpjsonServers;
186
+ if (!Array.isArray(list)) continue;
187
+ for (const name of list) if (typeof name === 'string' && name) rejected.add(name);
188
+ }
189
+ return rejected;
114
190
  }
package/cli/generate.mjs CHANGED
@@ -7,6 +7,48 @@ import { dirname, join } from 'node:path';
7
7
  import { syncDoctrine } from './sync-doctrine.mjs';
8
8
  import { upsertManagedBlock } from './managed-block.mjs';
9
9
 
10
+ /**
11
+ * The hooks directory baked into settings.json commands.
12
+ *
13
+ * Default stays the *portable* `$HOME/.claude/hooks` form — `$HOME` is expanded
14
+ * by the shell when the hook fires, so settings.json can be carried between
15
+ * machines. This must be byte-identical to what shipped before {{HOOKS_DIR}}
16
+ * existed, or every current user sees a spurious diff.
17
+ *
18
+ * Under CLAUDE_CONFIG_DIR it must instead be the *literal* `<override>/hooks`
19
+ * path, because that is where syncDoctrine actually installs the scripts
20
+ * (claudeDir == the override). A hardcoded `$HOME/.claude/hooks` there points at
21
+ * a dir the scripts were never copied to, and the hooks silently never fire —
22
+ * the bug this task fixes.
23
+ */
24
+ export function hooksDirForTemplate(env = process.env) {
25
+ const override = env.CLAUDE_CONFIG_DIR;
26
+ return override ? join(override, 'hooks') : '$HOME/.claude/hooks';
27
+ }
28
+
29
+ /**
30
+ * Read settings.json.tmpl and fill the {{HOOKS_DIR}} slot in each hook command.
31
+ *
32
+ * Parse first, then substitute into the parsed command strings — never inject a
33
+ * filesystem path into JSON *source*. A CLAUDE_CONFIG_DIR containing a backslash
34
+ * or a quote (both legal in a posix dir name) would otherwise land inside the
35
+ * JSON text and throw an opaque `Bad escaped character in JSON` on parse. The
36
+ * `{{HOOKS_DIR}}` placeholder itself has no JSON-special chars, so the template
37
+ * parses cleanly and only the parsed strings ever see the real path.
38
+ */
39
+ export function loadSettingsTemplate(templatesDir, env = process.env) {
40
+ const template = JSON.parse(readFileSync(join(templatesDir, 'settings.json.tmpl'), 'utf8'));
41
+ const hooksDir = hooksDirForTemplate(env);
42
+ for (const groups of Object.values(template.hooks || {})) {
43
+ for (const group of groups) {
44
+ for (const hook of group.hooks || []) {
45
+ if (typeof hook.command === 'string') hook.command = hook.command.replaceAll('{{HOOKS_DIR}}', hooksDir);
46
+ }
47
+ }
48
+ }
49
+ return template;
50
+ }
51
+
10
52
  function readJson(path, fallback) {
11
53
  if (!existsSync(path)) return fallback;
12
54
  try {
@@ -34,6 +76,12 @@ function templateInnerBlock(templatePath) {
34
76
  const raw = readFileSync(templatePath, 'utf8');
35
77
  const start = raw.indexOf('<!-- >>> tune-context (managed) >>>');
36
78
  const end = raw.indexOf('<!-- <<< tune-context (managed) <<< -->');
79
+ // A missing marker makes `start`/`end` -1; indexOf('\n', -1) silently treats
80
+ // -1 as 0, which used to return garbage sliced from the top of the file
81
+ // instead of failing. A broken template must not become a broken CLAUDE.md.
82
+ if (start === -1 || end === -1 || end < start) {
83
+ throw new Error(`template ${templatePath} is missing the tune-context managed-block markers`);
84
+ }
37
85
  const startLineEnd = raw.indexOf('\n', start) + 1;
38
86
  return raw.slice(startLineEnd, end).trimEnd();
39
87
  }
@@ -1,6 +1,15 @@
1
1
  // Mirrors this package's skills/agents/hooks into a Claude Code config dir
2
- // (~/.claude by default). Idempotent removes each destination entry before
3
- // copying, so deletions at the source propagate too, not just an overlay.
2
+ // (~/.claude by default). Idempotent for content that still exists at the
3
+ // source: each entry is removed then recopied, so a changed file/directory
4
+ // shape can't leave old siblings behind inside THAT entry. This is an
5
+ // OVERLAY, not a mirror, across runs: the outer loop is `readdirSync(from)`,
6
+ // so an entry no longer present at the source is simply never visited again —
7
+ // it is NOT deleted from the destination (review finding, TASK-077; verified
8
+ // empirically, not assumed. See the "KNOWN GAP" test in
9
+ // test/cli-sync-doctrine.test.mjs). A prune pass would need to diff against
10
+ // what THIS tool previously wrote, since claudeDir may also hold a user's own
11
+ // unrelated skills/agents/hooks that must never be touched — deliberately not
12
+ // attempted here without that design decision.
4
13
  import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
5
14
  import { dirname, join } from 'node:path';
6
15
 
package/cli/verify.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // landed" per SKILL.md §3. Never trust that a write succeeded just because
3
3
  // the function returned; re-read the target file and parse it.
4
4
  import { existsSync, readFileSync, statSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { basename, join, resolve } from 'node:path';
6
7
 
7
8
  function readJson(path) {
8
9
  try {
@@ -12,17 +13,132 @@ function readJson(path) {
12
13
  }
13
14
  }
14
15
 
16
+ // Pull the script path out of a hook command string. Commands look like
17
+ // node "$HOME/.claude/hooks/pre-compact-snapshot.mjs"
18
+ // so the target is the first single- or double-quoted argument. Returns null
19
+ // when nothing is quoted — the caller must treat that as "cannot verify", never
20
+ // as a silent pass. A false alarm here is cheaper than a missed broken wire.
21
+ function extractHookPath(command) {
22
+ if (typeof command !== 'string') return null;
23
+ const m = command.match(/"([^"]+)"|'([^']+)'/);
24
+ return m ? (m[1] ?? m[2]) : null;
25
+ }
26
+
27
+ // Expand $HOME / ${HOME} inside a hook COMMAND STRING the way it resolves at
28
+ // run time (process.env.HOME, falling back to homedir() if HOME is somehow
29
+ // unset) — the literal shell variable baked into the *default*, non-override
30
+ // hook command form (hooksDirForTemplate() in cli/generate.mjs). UNRELATED to
31
+ // CLAUDE_CONFIG_DIR: that rule lives solely in claudeBaseDir()
32
+ // (hooks/config-dir.mjs, TASK-074) and governs which directory claudeDir
33
+ // itself resolves to, a different axis from $HOME shell-expansion. A
34
+ // different notion of "home" on the two sides of the comparison below would
35
+ // itself manufacture a mismatch and fire a false alarm on a correct install.
36
+ function expandHome(p) {
37
+ const home = process.env.HOME || homedir();
38
+ return p.replace(/\$\{HOME\}/g, home).replace(/\$HOME/g, home);
39
+ }
40
+
41
+ // TASK-072: signature of the PLUGIN channel's own hook registration turning up
42
+ // where a settings.json entry is being checked. `${CLAUDE_PLUGIN_ROOT}` is
43
+ // Claude Code's substitution for a plugin's OWN hooks.json — it is never
44
+ // expanded for a command sitting in settings.json, so resolving/existsSync-ing
45
+ // it here would always read as "missing", which would be a lie: the plugin
46
+ // channel may be working perfectly well from Claude Code's side. Commands
47
+ // matching this are pulled out of the resolvability check below and reported
48
+ // separately, always informational — we can tell the command SHAPE is
49
+ // present, never whether the plugin is actually enabled (Option B, TASK-072).
50
+ const PLUGIN_PATH_HINT = /\$\{CLAUDE_PLUGIN_ROOT\}|[\\/]plugins[\\/]/;
51
+
15
52
  /**
16
53
  * @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string}[]}}
17
54
  */
18
55
  export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions }) {
19
56
  const checks = [];
20
57
 
58
+ // Cross-check, not two independent presence checks. The old code asked
59
+ // "does some command contain the script name?" and, separately, "does the
60
+ // script exist in claudeDir?" — both could pass while pointing at different
61
+ // dirs (the CLAUDE_CONFIG_DIR bug: script installed under the override, but
62
+ // the command still hardcoded to $HOME/.claude/hooks). Here the registered
63
+ // command must resolve to *the same file* we installed.
21
64
  const settings = readJson(userSettingsPath);
22
65
  for (const { event, scriptName } of expectedHookCommands) {
23
66
  const entries = settings?.hooks?.[event] || [];
24
- const ok = entries.some((g) => g.hooks?.some((h) => h.command?.includes(scriptName)));
25
- checks.push({ name: `hooks.${event} registers ${scriptName}`, ok, detail: ok ? undefined : `not found in ${userSettingsPath}` });
67
+ const allCommands = entries.flatMap((g) => (g.hooks || []).map((h) => h.command)).filter(Boolean);
68
+ const installedPath = resolve(join(claudeDir, 'hooks', scriptName));
69
+
70
+ // Classify by DESTINATION, not by spelling: a command that resolves to
71
+ // the very file we installed is the CLI registration, whatever its path
72
+ // happens to look like. Without this, PLUGIN_PATH_HINT's loose
73
+ // `/plugins/` arm swallowed a correct install whose config dir merely
74
+ // sits under such a path (e.g. CLAUDE_CONFIG_DIR=/opt/plugins/claude) —
75
+ // it was dropped from `commands`, then reported "not found in
76
+ // settings.json", i.e. a false RED on a perfect install, plus a plugin
77
+ // note for a plugin that isn't there (review finding, EPIC-008).
78
+ // `${CLAUDE_PLUGIN_ROOT}` stays correctly classified: it is literal text
79
+ // in settings.json (Claude Code only expands it for a plugin's own
80
+ // hooks.json), so it can never resolve to the installed file.
81
+ const isInstalledTarget = (c) => {
82
+ const p = extractHookPath(c);
83
+ return p !== null && resolve(expandHome(p)) === installedPath;
84
+ };
85
+ const isPluginShaped = (c) => PLUGIN_PATH_HINT.test(c) && !isInstalledTarget(c);
86
+
87
+ // Report the plugin-channel shape separately and up front — see
88
+ // PLUGIN_PATH_HINT above — then exclude it from the resolvability check
89
+ // below so it can never be misread as a dead/stale leftover.
90
+ for (const pluginCmd of allCommands.filter((c) => c.includes(scriptName) && isPluginShaped(c))) {
91
+ checks.push({
92
+ name: `hooks.${event}: ${scriptName} also has a plugin-channel registration`,
93
+ ok: true,
94
+ detail: `${pluginCmd} — if the tune-context plugin is also enabled, every ${event} event logs twice; see README ("Measuring adoption") for the re-read-churn effect this has on adoption-report`,
95
+ });
96
+ }
97
+ const commands = allCommands.filter((c) => !isPluginShaped(c));
98
+
99
+ // Match by the basename of each command's target path — ALL of them, not
100
+ // just the first. mergeHooks is a set keyed by command, so a path change
101
+ // (e.g. adopting CLAUDE_CONFIG_DIR) appends a new entry next to the old
102
+ // one, and Claude Code runs every registered command: a leftover pointing
103
+ // at a missing file errors on every event. First-match-only had two holes:
104
+ // stale-first hid a correct entry behind the pre-fix error message, and
105
+ // correct-first passed silently over a dead leftover.
106
+ const matching = commands.filter((c) => basename(extractHookPath(c) || '') === scriptName);
107
+
108
+ if (matching.length === 0) {
109
+ // Fall back to a substring test so a present-but-unparseable command is
110
+ // reported loudly rather than mistaken for "not registered at all".
111
+ const fallback = commands.find((c) => c.includes(scriptName));
112
+ if (!fallback) {
113
+ checks.push({ name: `hooks.${event} registers ${scriptName}`, ok: false, detail: `not found in ${userSettingsPath}` });
114
+ } else {
115
+ checks.push({ name: `hooks.${event} → ${scriptName} points at installed file`, ok: false, detail: `could not parse a path from command: ${fallback}` });
116
+ }
117
+ continue;
118
+ }
119
+
120
+ const resolved = matching.map((c) => resolve(expandHome(extractHookPath(c))));
121
+ const strays = resolved.filter((p) => p !== installedPath);
122
+ const deadStrays = strays.filter((p) => !existsSync(p));
123
+
124
+ let ok;
125
+ let detail;
126
+ if (!resolved.includes(installedPath)) {
127
+ ok = false;
128
+ detail = `registered ${resolved[0]} != installed ${installedPath}`;
129
+ } else if (deadStrays.length) {
130
+ // The wiring we installed is correct, but a stale duplicate points at
131
+ // nothing — a dead wire that errors at run time. Red, with a message
132
+ // distinguishable from "the fix didn't land".
133
+ ok = false;
134
+ detail = `correct registration present, but stale entry points at missing file: ${deadStrays.join(', ')} — remove it from ${userSettingsPath}`;
135
+ } else {
136
+ // Duplicates that resolve to an existing file run twice but don't break;
137
+ // surface them so the choice is visible, not silent.
138
+ ok = true;
139
+ detail = strays.length ? `note: also registered at ${strays.join(', ')}` : undefined;
140
+ }
141
+ checks.push({ name: `hooks.${event} → ${scriptName} points at installed file`, ok, detail });
26
142
  }
27
143
 
28
144
  const scriptNames = [...new Set(expectedHookCommands.map((h) => h.scriptName))];