@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.
- package/.claude-plugin/marketplace.json +2 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +252 -0
- package/README.md +129 -16
- package/bin/tune-context +11 -0
- package/bin/tune-context.mjs +110 -16
- package/cli/detect.mjs +92 -16
- package/cli/generate.mjs +48 -0
- package/cli/sync-doctrine.mjs +11 -2
- package/cli/verify.mjs +119 -3
- package/eval/adoption-report.mjs +211 -33
- package/hooks/adoption-log.mjs +2 -2
- package/hooks/config-dir.mjs +12 -0
- package/hooks/context-reminder.mjs +212 -0
- package/hooks/hooks.json +11 -0
- package/hooks/pre-compact-snapshot.mjs +69 -7
- package/package.json +4 -2
- package/skills/token-efficiency/SKILL.md +3 -1
- package/skills/tune-context/SKILL.md +94 -19
- package/skills/tune-context/templates/settings.json.tmpl +15 -14
package/bin/tune-context.mjs
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// tune-context init — deterministic detect -> generate -> verify.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
}
|
package/cli/sync-doctrine.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// Mirrors this package's skills/agents/hooks into a Claude Code config dir
|
|
2
|
-
// (~/.claude by default). Idempotent
|
|
3
|
-
//
|
|
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 {
|
|
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
|
|
25
|
-
|
|
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))];
|