create-claude-cabinet 0.49.0 → 0.51.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/lib/CLAUDE.md +30 -1
- package/lib/cli.js +26 -1
- package/lib/engagement-server-setup.js +1 -21
- package/lib/installer-gate.js +56 -0
- package/lib/modules.js +15 -0
- package/lib/mux-setup.js +1 -22
- package/lib/output-register.js +629 -0
- package/lib/settings-merge.js +74 -52
- package/lib/watchtower-setup.js +1 -21
- package/package.json +1 -1
- package/templates/CLAUDE.md +160 -26
- package/templates/cabinet/worktree-invocation-contract.md +16 -9
- package/templates/engagement/__tests__/transport-token.test.mjs +103 -0
- package/templates/engagement/engagement-transport.mjs +35 -1
- package/templates/hooks/register-reinforce.sh +125 -0
- package/templates/output-styles/cabinet-register.md +114 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +2 -1
- package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +3 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +8 -1
- package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +69 -20
- package/templates/scripts/__tests__/qa-handoff-merged-by-construction.test.mjs +310 -0
- package/templates/scripts/__tests__/resolve-cli.test.mjs +6 -1
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +2 -0
- package/templates/scripts/__tests__/session-ack.test.mjs +61 -0
- package/templates/scripts/watchtower-build-context.mjs +24 -2
- package/templates/scripts/watchtower-queue.mjs +272 -2
- package/templates/skills/cc-publish/SKILL.md +20 -0
- package/templates/skills/collab-client/SKILL.md +13 -7
- package/templates/skills/collab-consultant/SKILL.md +9 -4
- package/templates/skills/execute/SKILL.md +60 -33
- package/templates/skills/qa-handoff/SKILL.md +112 -48
- package/templates/skills/validate/phases/validators.md +67 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* output-register.js — install the Cabinet Register output style (user-level)
|
|
3
|
+
* and report where the register stands (act:9a0af01a).
|
|
4
|
+
*
|
|
5
|
+
* WHY AN OUTPUT STYLE. Plain-English, concept-first communication is already
|
|
6
|
+
* written down in three places that agree with each other (~/.claude/CLAUDE.md
|
|
7
|
+
* "Communication Style", .claude/rules/plain-english-decisions.md,
|
|
8
|
+
* skill-output-conventions.md §9). What none of them has is RE-ASSERTION: each
|
|
9
|
+
* is stated once, then competes with everything else in a growing context. An
|
|
10
|
+
* output style lands the contract in the SYSTEM PROMPT — re-sent in full on
|
|
11
|
+
* every request, never decays, unaffected by compaction. That is the strongest
|
|
12
|
+
* placement the platform offers. It is still prompt context, not a hook: this
|
|
13
|
+
* is not a move up the compliance stack and does not claim to be.
|
|
14
|
+
*
|
|
15
|
+
* THE FOOTGUN THIS FILE EXISTS TO GUARD (§4 of the plan).
|
|
16
|
+
* `keep-coding-instructions` was added in Claude Code v2.0.37. Below that
|
|
17
|
+
* version the field is SILENTLY IGNORED, its `false` default applies, and
|
|
18
|
+
* Claude Code's own software-engineering instructions are STRIPPED from the
|
|
19
|
+
* system prompt with no error. Verified in the shipped 2.1.209 binary:
|
|
20
|
+
*
|
|
21
|
+
* p = TEt(o["keep-coding-instructions"]) // kebab, raw read
|
|
22
|
+
* function TEt(e){ if(e===!0||e==="true")return!0;
|
|
23
|
+
* if(e===!1||e==="false")return!1; return } // absent → undefined
|
|
24
|
+
* c===null || c.keepCodingInstructions===!0 ? Qh_() : null // strict === true
|
|
25
|
+
*
|
|
26
|
+
* A test that the field is PRESENT IN THE FILE passes on every version ever
|
|
27
|
+
* shipped and detects this never — it guards the author forgetting the field,
|
|
28
|
+
* not the platform refusing to honor it. Only a `claude --version` check can.
|
|
29
|
+
* Hence versionGate(), and hence validateStyleSource() runs against the
|
|
30
|
+
* INSTALLED copy in ~/.claude/output-styles/ — the file Claude Code actually
|
|
31
|
+
* reads — not just the template.
|
|
32
|
+
*
|
|
33
|
+
* OWNERSHIP: seed-if-absent, never overwrite, validate loudly.
|
|
34
|
+
* A prose register is the most edit-inviting artifact CC ships — the operator
|
|
35
|
+
* WILL hand-tune it. The two sibling engines are opposites and neither is right
|
|
36
|
+
* here: mux-setup compares a cached manifest hash (a hand edit is never
|
|
37
|
+
* re-examined, so a footgun persists forever), watchtower-setup compares the
|
|
38
|
+
* dest's on-disk bytes and reconverges (hand edits reverted every reinstall).
|
|
39
|
+
* We take the third path CC already built — isProjectOwnedSeed()'s semantics
|
|
40
|
+
* (lib/copy.js, act:2001bf54): seed if absent, NEVER overwrite, omit from the
|
|
41
|
+
* project manifest — then close the gap that opens by validating the installed
|
|
42
|
+
* copy on every install and FAILING LOUD rather than silently rewriting it.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const os = require('os');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const { execFileSync } = require('child_process');
|
|
51
|
+
const { sha256, compareVersions, readGlobalManifest, writeGlobalManifest, globalManifestPath } = require('./installer-gate');
|
|
52
|
+
|
|
53
|
+
const TEMPLATE_DIR = path.resolve(__dirname, '..', 'templates', 'output-styles');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The style's identity. Claude Code keys a custom style by its FRONTMATTER
|
|
57
|
+
* `name`, verbatim — the filename is only a fallback when `name` is absent.
|
|
58
|
+
* Verified in 2.1.209:
|
|
59
|
+
* c = basename(n).replace(/\.md$/,""),
|
|
60
|
+
* u = (o.name != null ? String(o.name) : void 0) || c
|
|
61
|
+
* ...then folded into a map keyed by `.name` and selected by exact lookup
|
|
62
|
+
* (`e[settings.outputStyle] ?? null`). So THIS STRING is what must land in the
|
|
63
|
+
* `outputStyle` settings key, and it must stay in step with the frontmatter of
|
|
64
|
+
* templates/output-styles/cabinet-register.md (asserted by the test suite).
|
|
65
|
+
*/
|
|
66
|
+
const REGISTER_STYLE_NAME = 'Cabinet Register';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The floor below which `keep-coding-instructions` does not exist. Changelog:
|
|
70
|
+
* v1.0.81 released output styles · v2.0.30 DEPRECATED them · v2.0.32
|
|
71
|
+
* un-deprecated on community feedback · v2.0.37 ADDED the field · v2.1.94 added
|
|
72
|
+
* it for plugin styles. That leaves a ~250-version window where output styles
|
|
73
|
+
* work and the field is silently ignored — the window this gate closes.
|
|
74
|
+
*/
|
|
75
|
+
const MIN_CLAUDE_VERSION = '2.0.37';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Anthropic's built-in style roster, so we never ship a name that collides
|
|
79
|
+
* (a collision would make which-style-wins depend on load order).
|
|
80
|
+
* SNAPSHOT, verified against 2.1.209's registry:
|
|
81
|
+
* Lle = { default: null, Proactive: {...}, Explanatory: {...}, Learning: {...} }
|
|
82
|
+
* This roster is Anthropic's and it GROWS — Proactive post-dates the release
|
|
83
|
+
* that introduced output styles, and the published schema is already stale
|
|
84
|
+
* against the binary. A future built-in named "Cabinet Register" is
|
|
85
|
+
* vanishingly unlikely, but if this list goes stale the failure is a silent
|
|
86
|
+
* shadow, so re-verify it against the binary rather than trusting the docs.
|
|
87
|
+
*/
|
|
88
|
+
const BUILT_IN_STYLE_NAMES = ['default', 'Proactive', 'Explanatory', 'Learning'];
|
|
89
|
+
|
|
90
|
+
/** Where Claude Code reads user-level custom styles from. */
|
|
91
|
+
function userStyleDir(home = os.homedir()) {
|
|
92
|
+
return path.join(home, '.claude', 'output-styles');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The shipped style set, discovered by GLOB — never hand-enumerated.
|
|
97
|
+
* The manifest-integrity orphan test only scans templates/skills/, so a style
|
|
98
|
+
* added under templates/output-styles/ but missing from a hardcoded list would
|
|
99
|
+
* ship to nobody with every test green. Globbing makes the engine's managed set
|
|
100
|
+
* equal to what is on disk by construction.
|
|
101
|
+
*/
|
|
102
|
+
function styleTemplates(templateDir = TEMPLATE_DIR) {
|
|
103
|
+
if (!fs.existsSync(templateDir)) return [];
|
|
104
|
+
return fs
|
|
105
|
+
.readdirSync(templateDir)
|
|
106
|
+
.filter((f) => f.endsWith('.md'))
|
|
107
|
+
.sort()
|
|
108
|
+
.map((f) => ({ file: f, src: path.join(templateDir, f) }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse the leading `---` frontmatter block. Deliberately minimal — the style
|
|
113
|
+
* frontmatter is flat `key: value` scalars and CC ships no YAML dependency
|
|
114
|
+
* (the two-dependency footprint is intentional). Returns
|
|
115
|
+
* `{ ok, frontmatter, error }`; a file with no frontmatter block is `ok:false`.
|
|
116
|
+
*/
|
|
117
|
+
function parseFrontmatter(text) {
|
|
118
|
+
const src = String(text || '');
|
|
119
|
+
if (!/^---\r?\n/.test(src)) return { ok: false, frontmatter: {}, error: 'no frontmatter block' };
|
|
120
|
+
const end = src.indexOf('\n---', 3);
|
|
121
|
+
if (end === -1) return { ok: false, frontmatter: {}, error: 'unterminated frontmatter block' };
|
|
122
|
+
const body = src.slice(4, end);
|
|
123
|
+
const frontmatter = {};
|
|
124
|
+
for (const rawLine of body.split('\n')) {
|
|
125
|
+
const line = rawLine.trim();
|
|
126
|
+
if (!line || line.startsWith('#')) continue;
|
|
127
|
+
const idx = line.indexOf(':');
|
|
128
|
+
if (idx === -1) continue;
|
|
129
|
+
const key = line.slice(0, idx).trim();
|
|
130
|
+
let value = line.slice(idx + 1).trim();
|
|
131
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
132
|
+
value = value.slice(1, -1);
|
|
133
|
+
}
|
|
134
|
+
frontmatter[key] = value;
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, frontmatter, error: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Claude Code's own coercion for `keep-coding-instructions`, mirrored exactly
|
|
141
|
+
* (function TEt in 2.1.209): true/"true" → true, false/"false" → false,
|
|
142
|
+
* ANYTHING ELSE → undefined, and the gate then requires a strict `=== true`.
|
|
143
|
+
* So `keep-coding-instructions: yes` — valid YAML truth, wrong here — reads as
|
|
144
|
+
* undefined and strips the coding instructions. We reject anything that does
|
|
145
|
+
* not coerce to exactly true.
|
|
146
|
+
*/
|
|
147
|
+
function coerceKeepCoding(value) {
|
|
148
|
+
if (value === true || value === 'true') return true;
|
|
149
|
+
if (value === false || value === 'false') return false;
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate one style file's SOURCE TEXT against everything that can silently
|
|
155
|
+
* break it. Used for BOTH the template and the installed copy — the installed
|
|
156
|
+
* copy is the one Claude Code reads, and validating only the template would be
|
|
157
|
+
* checking the one file that cannot cause the failure.
|
|
158
|
+
*
|
|
159
|
+
* @returns {{ ok: boolean, name: string|null, problems: string[] }}
|
|
160
|
+
*/
|
|
161
|
+
function validateStyleSource(text, { label = 'style' } = {}) {
|
|
162
|
+
const problems = [];
|
|
163
|
+
const { ok, frontmatter, error } = parseFrontmatter(text);
|
|
164
|
+
if (!ok) {
|
|
165
|
+
return { ok: false, name: null, problems: [`${label}: ${error}`] };
|
|
166
|
+
}
|
|
167
|
+
const name = typeof frontmatter.name === 'string' && frontmatter.name.trim() ? frontmatter.name.trim() : null;
|
|
168
|
+
if (!name) problems.push(`${label}: frontmatter is missing a \`name\` (the key Claude Code selects the style by)`);
|
|
169
|
+
if (!frontmatter.description || !String(frontmatter.description).trim()) {
|
|
170
|
+
problems.push(`${label}: frontmatter is missing a \`description\` (shown in the /config picker)`);
|
|
171
|
+
}
|
|
172
|
+
if (coerceKeepCoding(frontmatter['keep-coding-instructions']) !== true) {
|
|
173
|
+
problems.push(
|
|
174
|
+
`${label}: \`keep-coding-instructions: true\` is missing or not literally true — ` +
|
|
175
|
+
`without it Claude Code STRIPS its software-engineering instructions from the system prompt`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (name && BUILT_IN_STYLE_NAMES.includes(name)) {
|
|
179
|
+
problems.push(`${label}: name "${name}" collides with a built-in output style (${BUILT_IN_STYLE_NAMES.join(', ')})`);
|
|
180
|
+
}
|
|
181
|
+
return { ok: problems.length === 0, name, problems };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── The version gate ────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Read the installed Claude Code version. `exec` is injectable so tests can
|
|
188
|
+
* drive every arm without a real binary.
|
|
189
|
+
* @returns {{ status: 'ok'|'absent'|'unparseable', version: string|null, raw: string|null }}
|
|
190
|
+
*/
|
|
191
|
+
function detectClaudeVersion({ exec } = {}) {
|
|
192
|
+
const run =
|
|
193
|
+
exec ||
|
|
194
|
+
(() =>
|
|
195
|
+
execFileSync('claude', ['--version'], {
|
|
196
|
+
encoding: 'utf8',
|
|
197
|
+
timeout: 5000,
|
|
198
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
199
|
+
}));
|
|
200
|
+
let raw;
|
|
201
|
+
try {
|
|
202
|
+
raw = run();
|
|
203
|
+
} catch {
|
|
204
|
+
// No binary on PATH, non-zero exit, timeout — absence is NOT an old
|
|
205
|
+
// version, so this fails OPEN (see versionGate).
|
|
206
|
+
return { status: 'absent', version: null, raw: null };
|
|
207
|
+
}
|
|
208
|
+
const m = /(\d+\.\d+\.\d+)/.exec(String(raw || ''));
|
|
209
|
+
if (!m) return { status: 'unparseable', version: null, raw: String(raw || '').trim() };
|
|
210
|
+
return { status: 'ok', version: m[1], raw: String(raw).trim() };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Decide whether it is safe to install the style on this Claude Code.
|
|
215
|
+
*
|
|
216
|
+
* Below MIN_CLAUDE_VERSION we REFUSE: installing here would work — the style
|
|
217
|
+
* loads fine — and silently strip CC's software-engineering instructions. The
|
|
218
|
+
* failure is invisible from inside the file, which is why refusing is the only
|
|
219
|
+
* honest option.
|
|
220
|
+
*
|
|
221
|
+
* An ABSENT or UNPARSEABLE version fails OPEN: absence is not evidence of an
|
|
222
|
+
* old version, and refusing to install because we couldn't find a binary would
|
|
223
|
+
* break every CI/container install for a hazard we have no reason to believe
|
|
224
|
+
* exists there.
|
|
225
|
+
*
|
|
226
|
+
* @returns {{ allowed: boolean, reason: string, version: string|null, status: string }}
|
|
227
|
+
*/
|
|
228
|
+
function versionGate(detected) {
|
|
229
|
+
const { status, version } = detected;
|
|
230
|
+
if (status === 'absent') {
|
|
231
|
+
return {
|
|
232
|
+
allowed: true,
|
|
233
|
+
status,
|
|
234
|
+
version: null,
|
|
235
|
+
reason: `could not run \`claude --version\` — installing anyway (absence is not an old version). ` +
|
|
236
|
+
`If this Claude Code is older than v${MIN_CLAUDE_VERSION}, \`keep-coding-instructions\` is ignored there.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (status === 'unparseable') {
|
|
240
|
+
return {
|
|
241
|
+
allowed: true,
|
|
242
|
+
status,
|
|
243
|
+
version: null,
|
|
244
|
+
reason: `could not parse a version out of \`claude --version\` — installing anyway (fail-open).`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (compareVersions(version, MIN_CLAUDE_VERSION) < 0) {
|
|
248
|
+
return {
|
|
249
|
+
allowed: false,
|
|
250
|
+
status,
|
|
251
|
+
version,
|
|
252
|
+
reason:
|
|
253
|
+
`Claude Code v${version} is older than v${MIN_CLAUDE_VERSION}, which is when ` +
|
|
254
|
+
`\`keep-coding-instructions\` was added. On this version the field is silently ignored, its ` +
|
|
255
|
+
`\`false\` default applies, and Claude Code's software-engineering instructions are STRIPPED ` +
|
|
256
|
+
`from the system prompt with no error. Upgrade Claude Code, then re-run the installer.`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return { allowed: true, status, version, reason: `Claude Code v${version} honors \`keep-coding-instructions\`.` };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── The enumerator ──────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The settings layers we can actually READ, low → high precedence. Verified in
|
|
266
|
+
* 2.1.209, whose own comment on the precedence array reads "Ordered low-to-high
|
|
267
|
+
* priority — later entries override earlier ones":
|
|
268
|
+
* ["userSettings","projectSettings","localSettings","flagSettings","policySettings"]
|
|
269
|
+
* The last two are deliberately absent here — see resolveRegisterLevel.
|
|
270
|
+
*/
|
|
271
|
+
const SETTINGS_LAYERS = [
|
|
272
|
+
{ layer: 'userSettings', label: 'User settings', rel: null },
|
|
273
|
+
{ layer: 'projectSettings', label: 'Project settings', rel: path.join('.claude', 'settings.json') },
|
|
274
|
+
{ layer: 'localSettings', label: 'Local settings', rel: path.join('.claude', 'settings.local.json') },
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
function layerPaths({ projectDir = process.cwd(), home = os.homedir() } = {}) {
|
|
278
|
+
return SETTINGS_LAYERS.map((l) => ({
|
|
279
|
+
...l,
|
|
280
|
+
path: l.rel === null ? path.join(home, '.claude', 'settings.json') : path.join(projectDir, l.rel),
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Read one settings layer's `outputStyle`, distinguishing every way it can be
|
|
286
|
+
* non-informative. "Unparseable" must NEVER read as "absent" — that would turn
|
|
287
|
+
* a broken settings file into a confident "the register is off".
|
|
288
|
+
*
|
|
289
|
+
* state ∈ 'absent' — no such file
|
|
290
|
+
* | 'unparseable' — file exists, JSON.parse threw
|
|
291
|
+
* | 'not-an-object' — parsed to [] / null / a scalar
|
|
292
|
+
* | 'no-key' — parsed fine, no outputStyle key
|
|
293
|
+
* | 'null' — outputStyle: null
|
|
294
|
+
* | 'empty' — outputStyle: ""
|
|
295
|
+
* | 'non-string' — outputStyle: 7 / {} / []
|
|
296
|
+
* | 'set' — outputStyle names a style
|
|
297
|
+
*/
|
|
298
|
+
function readLayerOutputStyle(filePath) {
|
|
299
|
+
if (!fs.existsSync(filePath)) return { state: 'absent', value: null };
|
|
300
|
+
let parsed;
|
|
301
|
+
try {
|
|
302
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
303
|
+
} catch {
|
|
304
|
+
return { state: 'unparseable', value: null };
|
|
305
|
+
}
|
|
306
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
307
|
+
return { state: 'not-an-object', value: null };
|
|
308
|
+
}
|
|
309
|
+
if (!Object.prototype.hasOwnProperty.call(parsed, 'outputStyle')) return { state: 'no-key', value: null };
|
|
310
|
+
const v = parsed.outputStyle;
|
|
311
|
+
if (v === null) return { state: 'null', value: null };
|
|
312
|
+
if (typeof v !== 'string') return { state: 'non-string', value: v };
|
|
313
|
+
if (v.trim() === '') return { state: 'empty', value: v };
|
|
314
|
+
return { state: 'set', value: v };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Resolve what the NEXT session's output style will be, as best as a file
|
|
319
|
+
* reader can know it.
|
|
320
|
+
*
|
|
321
|
+
* THIS ENUMERATES; IT DOES NOT ADJUDICATE. Three layers are invisible to it:
|
|
322
|
+
* a plugin's `force-for-plugin` (which overrides `outputStyle` entirely), CLI
|
|
323
|
+
* `--settings`, and enterprise managed policy — all of which outrank everything
|
|
324
|
+
* below. A tool rendering a verdict it cannot back is exactly the fact-shaped
|
|
325
|
+
* guess verify-before-asserting.md exists to prevent, shipped as a feature. So
|
|
326
|
+
* the contract is honest about its own blind spots and points at `/status` and
|
|
327
|
+
* `/context` for ground truth.
|
|
328
|
+
*
|
|
329
|
+
* THREE-VALUED, deliberately: a foreign style reports its literal name with
|
|
330
|
+
* `ours:false`, which is NOT the same as `off`. Collapsing "Explanatory" into
|
|
331
|
+
* "off" is what would let a never-clobber guard clobber it.
|
|
332
|
+
*
|
|
333
|
+
* @returns {{ level: string, ours: boolean, source: string|null, uncertain: boolean, sources: object[] }}
|
|
334
|
+
* level: the style name, or 'off' (no layer names one), or 'unknown' (a layer
|
|
335
|
+
* we could not read sits ABOVE whatever we could).
|
|
336
|
+
*/
|
|
337
|
+
function resolveRegisterLevel({ projectDir = process.cwd(), home = os.homedir() } = {}) {
|
|
338
|
+
const sources = [];
|
|
339
|
+
let winner = null;
|
|
340
|
+
let unreadableAboveWinner = false;
|
|
341
|
+
|
|
342
|
+
for (const l of layerPaths({ projectDir, home })) {
|
|
343
|
+
const read = readLayerOutputStyle(l.path);
|
|
344
|
+
sources.push({ layer: l.layer, label: l.label, path: l.path, state: read.state, value: read.value });
|
|
345
|
+
if (read.state === 'set') {
|
|
346
|
+
winner = { layer: l.layer, value: read.value };
|
|
347
|
+
unreadableAboveWinner = false; // this layer outranks anything unreadable below it
|
|
348
|
+
} else if (read.state === 'unparseable' || read.state === 'not-an-object' || read.state === 'non-string') {
|
|
349
|
+
unreadableAboveWinner = true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (unreadableAboveWinner) {
|
|
354
|
+
return { level: 'unknown', ours: false, source: null, uncertain: true, sources };
|
|
355
|
+
}
|
|
356
|
+
if (!winner) {
|
|
357
|
+
return { level: 'off', ours: false, source: null, uncertain: false, sources };
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
level: winner.value,
|
|
361
|
+
ours: winner.value === REGISTER_STYLE_NAME,
|
|
362
|
+
source: winner.layer,
|
|
363
|
+
uncertain: false,
|
|
364
|
+
sources,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const STATE_PROSE = {
|
|
369
|
+
absent: 'no such file',
|
|
370
|
+
unparseable: 'FILE EXISTS BUT IS NOT VALID JSON — cannot tell what it says',
|
|
371
|
+
'not-an-object': 'FILE EXISTS BUT IS NOT A JSON OBJECT — cannot tell what it says',
|
|
372
|
+
'no-key': 'no outputStyle key',
|
|
373
|
+
null: 'outputStyle: null (present, names nothing)',
|
|
374
|
+
empty: 'outputStyle: "" (present, names nothing)',
|
|
375
|
+
'non-string': 'outputStyle is not a string — Claude Code will not resolve it',
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Render `--register`: every settings file that mentions outputStyle, what each
|
|
380
|
+
* says, the layers we could NOT consult, and where the real answer lives.
|
|
381
|
+
*/
|
|
382
|
+
function renderRegisterReport({ projectDir = process.cwd(), home = os.homedir() } = {}) {
|
|
383
|
+
const lines = [];
|
|
384
|
+
const res = resolveRegisterLevel({ projectDir, home });
|
|
385
|
+
|
|
386
|
+
if (res.level === 'off') {
|
|
387
|
+
lines.push(' Register: OFF — no settings file names an output style.');
|
|
388
|
+
} else if (res.level === 'unknown') {
|
|
389
|
+
lines.push(' Register: UNKNOWN — a settings file could not be read (see below).');
|
|
390
|
+
} else if (res.ours) {
|
|
391
|
+
lines.push(` Register: ON — "${res.level}" (set in ${res.source}).`);
|
|
392
|
+
} else {
|
|
393
|
+
lines.push(` Register: a DIFFERENT style is active — "${res.level}" (set in ${res.source}). Not ours; left alone.`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
lines.push('');
|
|
397
|
+
lines.push(' What each settings file says (low → high precedence):');
|
|
398
|
+
for (const s of res.sources) {
|
|
399
|
+
const said = s.state === 'set' ? `outputStyle: "${s.value}"` : STATE_PROSE[s.state] || s.state;
|
|
400
|
+
lines.push(` ${s.label.padEnd(16)} ${said}`);
|
|
401
|
+
lines.push(` ${''.padEnd(16)} ${s.path}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const styleDir = userStyleDir(home);
|
|
405
|
+
const installed = fs.existsSync(styleDir) ? fs.readdirSync(styleDir).filter((f) => f.endsWith('.md')) : [];
|
|
406
|
+
lines.push('');
|
|
407
|
+
lines.push(` Installed styles in ${styleDir}: ${installed.length ? installed.join(', ') : '(none)'}`);
|
|
408
|
+
|
|
409
|
+
lines.push('');
|
|
410
|
+
lines.push(' This is an ENUMERATOR, not a verdict. It cannot see:');
|
|
411
|
+
lines.push(' - a plugin\'s force-for-plugin style, which overrides outputStyle entirely');
|
|
412
|
+
lines.push(' - command-line --settings, which outranks every file above');
|
|
413
|
+
lines.push(' - enterprise managed policy, which outranks everything');
|
|
414
|
+
lines.push(' For ground truth: `/status` shows the active settings sources, and');
|
|
415
|
+
lines.push(' `/context` shows the system prompt — the only place that proves the style landed.');
|
|
416
|
+
lines.push('');
|
|
417
|
+
lines.push(' Note: this reports what the NEXT session will start with. Output style is');
|
|
418
|
+
lines.push(' fixed at session start (for prompt caching); changing it mid-session does');
|
|
419
|
+
lines.push(' nothing until /clear or a new session.');
|
|
420
|
+
|
|
421
|
+
return lines;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── The installer ───────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Seed `outputStyle` into the PROJECT's .claude/settings.json.
|
|
428
|
+
*
|
|
429
|
+
* Project-level, not user-level, on purpose: this module is selected per
|
|
430
|
+
* project, so turning the register on portfolio-wide as a side effect of a
|
|
431
|
+
* single project's install would be a surprise. A project install changes the
|
|
432
|
+
* project. (The operator's $HOME CC install is the global layer — installing
|
|
433
|
+
* there sets it everywhere, which is exactly what that layer is for.)
|
|
434
|
+
*
|
|
435
|
+
* Seed-if-absent, never clobber: an incumbent style — ours or foreign, at ANY
|
|
436
|
+
* layer — is reported and left alone.
|
|
437
|
+
*
|
|
438
|
+
* Atomic + read-back: re-read immediately before writing and verify after, so a
|
|
439
|
+
* concurrent writer (healUserSettings and mergeSettings are both non-atomic
|
|
440
|
+
* read-modify-writes elsewhere in this install) can't leave us announcing a
|
|
441
|
+
* write that didn't land.
|
|
442
|
+
*/
|
|
443
|
+
function seedOutputStyleSetting({ projectDir, dryRun = false, home = os.homedir(), results }) {
|
|
444
|
+
const res = resolveRegisterLevel({ projectDir, home });
|
|
445
|
+
|
|
446
|
+
if (res.level === 'unknown') {
|
|
447
|
+
results.push(` ⚠ Not setting outputStyle: a settings file is unreadable, so the incumbent is unknown.`);
|
|
448
|
+
results.push(` Run \`npx create-claude-cabinet --register\` to see which file.`);
|
|
449
|
+
return { status: 'skipped-uncertain' };
|
|
450
|
+
}
|
|
451
|
+
if (res.ours) {
|
|
452
|
+
results.push(` ✓ Register already active — "${REGISTER_STYLE_NAME}" (set in ${res.source}).`);
|
|
453
|
+
return { status: 'already-set' };
|
|
454
|
+
}
|
|
455
|
+
if (res.level !== 'off') {
|
|
456
|
+
results.push(` ⚠ Leaving the existing output style alone: "${res.level}" is set in ${res.source}.`);
|
|
457
|
+
results.push(` Only one style can be active. To switch, set outputStyle to "${REGISTER_STYLE_NAME}" there, or use /config.`);
|
|
458
|
+
return { status: 'incumbent' };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const settingsPath = path.join(projectDir, '.claude', 'settings.json');
|
|
462
|
+
if (dryRun) {
|
|
463
|
+
results.push(` [dry-run] would set outputStyle: "${REGISTER_STYLE_NAME}" in ${settingsPath}`);
|
|
464
|
+
return { status: 'would-set' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Read immediately before writing — not from the resolve above — so a
|
|
468
|
+
// concurrent writer's changes are preserved rather than reverted.
|
|
469
|
+
let settings = {};
|
|
470
|
+
if (fs.existsSync(settingsPath)) {
|
|
471
|
+
try {
|
|
472
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
473
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) settings = parsed;
|
|
474
|
+
} catch {
|
|
475
|
+
results.push(` ⚠ ${settingsPath} is not valid JSON — not touching it. Set outputStyle by hand or via /config.`);
|
|
476
|
+
return { status: 'skipped-unparseable' };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
settings.outputStyle = REGISTER_STYLE_NAME;
|
|
480
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
481
|
+
const tmp = settingsPath + '.tmp';
|
|
482
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
483
|
+
fs.renameSync(tmp, settingsPath);
|
|
484
|
+
|
|
485
|
+
// Verify the write, don't announce it from the call's success.
|
|
486
|
+
const after = readLayerOutputStyle(settingsPath);
|
|
487
|
+
if (after.state !== 'set' || after.value !== REGISTER_STYLE_NAME) {
|
|
488
|
+
results.push(` ⚠ Wrote outputStyle to ${settingsPath} but reading it back did not confirm it (${after.state}).`);
|
|
489
|
+
return { status: 'write-unconfirmed' };
|
|
490
|
+
}
|
|
491
|
+
results.push(` ✓ Set outputStyle: "${REGISTER_STYLE_NAME}" in ${path.relative(projectDir, settingsPath)} (takes effect next session).`);
|
|
492
|
+
return { status: 'set' };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* postInstall handler for the `output-register` module.
|
|
497
|
+
*
|
|
498
|
+
* @param {object} opts
|
|
499
|
+
* @param {boolean} [opts.dryRun]
|
|
500
|
+
* @param {string} [opts.projectDir]
|
|
501
|
+
* @param {string} [opts.home] — injectable for tests
|
|
502
|
+
* @param {Function} [opts.exec] — injectable `claude --version` runner
|
|
503
|
+
* @returns {{ results: string[], status: 'refused'|'installed'|'unchanged'|'failed' }}
|
|
504
|
+
*/
|
|
505
|
+
function setupOutputRegister(opts = {}) {
|
|
506
|
+
const dryRun = !!opts.dryRun;
|
|
507
|
+
const projectDir = opts.projectDir || process.cwd();
|
|
508
|
+
const home = opts.home || os.homedir();
|
|
509
|
+
const results = opts.results || [];
|
|
510
|
+
|
|
511
|
+
results.push('Installing the Cabinet Register output style');
|
|
512
|
+
|
|
513
|
+
// 1. The version gate — the only guard that can catch the real hazard.
|
|
514
|
+
const detected = detectClaudeVersion({ exec: opts.exec });
|
|
515
|
+
const gate = versionGate(detected);
|
|
516
|
+
if (!gate.allowed) {
|
|
517
|
+
results.push(` ✗ REFUSED: ${gate.reason}`);
|
|
518
|
+
return { results, status: 'refused' };
|
|
519
|
+
}
|
|
520
|
+
results.push(` ${gate.status === 'ok' ? '✓' : '⚠'} ${gate.reason}`);
|
|
521
|
+
|
|
522
|
+
const templates = styleTemplates();
|
|
523
|
+
if (templates.length === 0) {
|
|
524
|
+
results.push(' ⚠ No styles found under templates/output-styles/ — nothing to install.');
|
|
525
|
+
return { results, status: 'failed' };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const styleDir = userStyleDir(home);
|
|
529
|
+
// Resolve the manifest against THIS run's home, not os.homedir() — `home` is
|
|
530
|
+
// injectable, and a bare readGlobalManifest() would escape the sandbox.
|
|
531
|
+
const manifestPath = globalManifestPath(home);
|
|
532
|
+
const manifest = readGlobalManifest(manifestPath);
|
|
533
|
+
let seeded = 0;
|
|
534
|
+
let failures = 0;
|
|
535
|
+
|
|
536
|
+
for (const t of templates) {
|
|
537
|
+
const content = fs.readFileSync(t.src, 'utf8');
|
|
538
|
+
|
|
539
|
+
// 2. Validate the TEMPLATE (author error — catch it before it ships).
|
|
540
|
+
const srcCheck = validateStyleSource(content, { label: `template ${t.file}` });
|
|
541
|
+
if (!srcCheck.ok) {
|
|
542
|
+
for (const p of srcCheck.problems) results.push(` ✗ ${p}`);
|
|
543
|
+
failures++;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const dest = path.join(styleDir, t.file);
|
|
548
|
+
|
|
549
|
+
if (fs.existsSync(dest)) {
|
|
550
|
+
// 3. NEVER overwrite — but validate the INSTALLED copy, the one Claude
|
|
551
|
+
// Code actually reads. A copy missing the flag fails LOUD rather than
|
|
552
|
+
// being silently rewritten (which would eat the operator's tuning).
|
|
553
|
+
let installedText = '';
|
|
554
|
+
try {
|
|
555
|
+
installedText = fs.readFileSync(dest, 'utf8');
|
|
556
|
+
} catch (err) {
|
|
557
|
+
results.push(` ✗ Installed style ${dest} is unreadable: ${err.message}`);
|
|
558
|
+
failures++;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const check = validateStyleSource(installedText, { label: `installed ${t.file}` });
|
|
562
|
+
if (!check.ok) {
|
|
563
|
+
for (const p of check.problems) results.push(` ✗ ${p}`);
|
|
564
|
+
results.push(` Not overwriting your copy — fix ${dest} by hand, or delete it and re-run to reseed.`);
|
|
565
|
+
failures++;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const edited = sha256(installedText) !== sha256(content);
|
|
569
|
+
results.push(` ✓ ${t.file} already installed${edited ? ' (your edits preserved — never overwritten)' : ''}`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 4. Seed.
|
|
574
|
+
if (dryRun) {
|
|
575
|
+
results.push(` [dry-run] ${t.file} → ${dest}`);
|
|
576
|
+
seeded++;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
fs.mkdirSync(styleDir, { recursive: true });
|
|
580
|
+
fs.writeFileSync(dest, content);
|
|
581
|
+
|
|
582
|
+
// Verify the seed by re-reading the file Claude Code will read.
|
|
583
|
+
const after = validateStyleSource(fs.readFileSync(dest, 'utf8'), { label: `installed ${t.file}` });
|
|
584
|
+
if (!after.ok) {
|
|
585
|
+
for (const p of after.problems) results.push(` ✗ ${p}`);
|
|
586
|
+
failures++;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
// Global-manifest bookkeeping only: the style is project-owned the moment
|
|
590
|
+
// it lands, so this records WHAT WE SEEDED (so --reset can find it), never
|
|
591
|
+
// a claim we may overwrite it.
|
|
592
|
+
manifest.files[dest] = sha256(content);
|
|
593
|
+
results.push(` ✓ Seeded ${t.file} → ${dest}`);
|
|
594
|
+
seeded++;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!dryRun && seeded > 0) {
|
|
598
|
+
manifest.installedAt = new Date().toISOString();
|
|
599
|
+
writeGlobalManifest(manifest, manifestPath);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (failures > 0) return { results, status: 'failed' };
|
|
603
|
+
|
|
604
|
+
// 5. Turn it on — offering, never clobbering.
|
|
605
|
+
seedOutputStyleSetting({ projectDir, dryRun, home, results });
|
|
606
|
+
|
|
607
|
+
results.push(' Check where the register stands any time: npx create-claude-cabinet --register');
|
|
608
|
+
return { results, status: seeded > 0 ? 'installed' : 'unchanged' };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = {
|
|
612
|
+
setupOutputRegister,
|
|
613
|
+
resolveRegisterLevel,
|
|
614
|
+
renderRegisterReport,
|
|
615
|
+
detectClaudeVersion,
|
|
616
|
+
versionGate,
|
|
617
|
+
validateStyleSource,
|
|
618
|
+
parseFrontmatter,
|
|
619
|
+
coerceKeepCoding,
|
|
620
|
+
styleTemplates,
|
|
621
|
+
userStyleDir,
|
|
622
|
+
seedOutputStyleSetting,
|
|
623
|
+
readLayerOutputStyle,
|
|
624
|
+
REGISTER_STYLE_NAME,
|
|
625
|
+
MIN_CLAUDE_VERSION,
|
|
626
|
+
BUILT_IN_STYLE_NAMES,
|
|
627
|
+
SETTINGS_LAYERS,
|
|
628
|
+
TEMPLATE_DIR,
|
|
629
|
+
};
|