@open-product-primer/cli 2.11.0 → 2.12.1
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/dist/lib/claude-mods.d.ts +15 -1
- package/dist/lib/claude-mods.js +82 -54
- package/dist/lib/install-agent.d.ts +6 -4
- package/dist/lib/install-agent.js +26 -5
- package/package.json +1 -1
|
@@ -5,11 +5,25 @@ export interface ClaudeModHookFile {
|
|
|
5
5
|
matcher?: string;
|
|
6
6
|
command: string;
|
|
7
7
|
}
|
|
8
|
-
export interface
|
|
8
|
+
export interface ClaudeModPluginFile {
|
|
9
|
+
path: string;
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
interface ClaudeModBase {
|
|
9
13
|
id: string;
|
|
10
14
|
title: string;
|
|
11
15
|
description: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ClaudeModClassic extends ClaudeModBase {
|
|
18
|
+
shape: 'classic';
|
|
12
19
|
hookFiles: ClaudeModHookFile[];
|
|
13
20
|
}
|
|
21
|
+
export interface ClaudeModPlugin extends ClaudeModBase {
|
|
22
|
+
shape: 'plugin';
|
|
23
|
+
pluginFiles: ClaudeModPluginFile[];
|
|
24
|
+
}
|
|
25
|
+
export type ClaudeMod = ClaudeModClassic | ClaudeModPlugin;
|
|
26
|
+
export declare function isPluginMod(mod: ClaudeMod): mod is ClaudeModPlugin;
|
|
14
27
|
export declare const CLAUDE_MODS_REGISTRY: ClaudeMod[];
|
|
15
28
|
export declare function getClaudeMod(id: string): ClaudeMod | undefined;
|
|
29
|
+
export {};
|
package/dist/lib/claude-mods.js
CHANGED
|
@@ -1,74 +1,102 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Static registry of installable Claude Code
|
|
3
|
-
// oprim/bets/pending/BET-
|
|
2
|
+
// Static registry of installable Claude Code "mods" — see
|
|
3
|
+
// oprim/bets/pending/BET-074-migrate-claude-mods-to-function-hooks/design.md. Both `oprim init`'s
|
|
4
4
|
// mod-selection prompt and the `oprim claude-mods` command read from this single registry, so a
|
|
5
|
-
// future mod (BET-052–
|
|
5
|
+
// future mod (BET-052–073) is added here once rather than duplicated per entry point.
|
|
6
|
+
//
|
|
7
|
+
// A registry entry declares one of two shapes:
|
|
8
|
+
// - 'classic': a shell-command hook merged into .claude/settings.json's `hooks` block (text-only
|
|
9
|
+
// {decision, reason} protocol — cannot draw UI).
|
|
10
|
+
// - 'plugin': a Claude Code function-hooks plugin (manifest + hooks.json + a register(on) module)
|
|
11
|
+
// installed at .claude/skills/<id>/, loaded via the project-scope skills-directory auto-load
|
|
12
|
+
// convention. Can draw UI ($.ui.log, $.ui.render) but requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS
|
|
13
|
+
// and is early access — its API may move between releases.
|
|
6
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
15
|
exports.CLAUDE_MODS_REGISTRY = void 0;
|
|
16
|
+
exports.isPluginMod = isPluginMod;
|
|
8
17
|
exports.getClaudeMod = getClaudeMod;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
function isPluginMod(mod) {
|
|
19
|
+
return mod.shape === 'plugin';
|
|
20
|
+
}
|
|
21
|
+
// register(on) module: on a Write/Edit tool.call to a bet's specs/<capability>/spec.md delta,
|
|
22
|
+
// shells out to `oprim validate --json` (already runs checkSpecDeltaDrift()) and surfaces any
|
|
23
|
+
// MODIFIED/REMOVED drift for that capability as a transcript line, at write time instead of only
|
|
24
|
+
// at validate/CI time. Never denies the call — this is a write-time convenience on top of
|
|
25
|
+
// `oprim validate`, never a second source of required failures. Silently no-ops on any failure
|
|
26
|
+
// (oprim not resolvable, malformed report, etc.).
|
|
27
|
+
const SPEC_DELTA_DRIFT_INTERCEPTOR_REGISTER = `// Function-hooks module for the spec-delta-drift-interceptor mod — see claude-mods.ts.
|
|
28
|
+
// EARLY ACCESS: register(on)/$.ui.log/$.process.run are gated behind
|
|
29
|
+
// CLAUDE_CODE_ENABLE_FUNCTION_HOOKS and may change shape between Claude Code releases. A hooks
|
|
30
|
+
// module may only import its own files by relative path and "claude-code" — no Node builtins — so
|
|
31
|
+
// shelling out goes through $.process.run, not node:child_process.
|
|
32
|
+
//
|
|
33
|
+
// Surfaces the drift as a transcript line via $.ui.log rather than $.ui.toast: a toast is a
|
|
34
|
+
// transient overlay whose rendering depends on the session's UI surface, while $.ui.log's default
|
|
35
|
+
// destination ("to: transcript") always lands as a plain line every surface shows — see the
|
|
36
|
+
// anthropics/claude-code \`mods/diff\` example, which uses the same call for its panel-toggle
|
|
37
|
+
// messages ("Diff panel shown"/"Diff panel hidden").
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
28
|
-
if (!match) return;
|
|
29
|
-
const capability = match[1];
|
|
39
|
+
export function register(on) {
|
|
40
|
+
on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
|
|
41
|
+
const result = await next(e);
|
|
30
42
|
|
|
31
|
-
const { execSync } = require('child_process');
|
|
32
|
-
let output;
|
|
33
43
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} catch (err) {
|
|
39
|
-
// validate exits non-zero when a required check fails — stdout still carries the report
|
|
40
|
-
output = err && err.stdout ? err.stdout.toString() : null;
|
|
41
|
-
}
|
|
42
|
-
if (!output) return;
|
|
44
|
+
const filePath = String(e.file_path || '').replace(/\\\\/g, '/');
|
|
45
|
+
const match = filePath.match(/oprim\\/bets\\/pending\\/[^/]+\\/specs\\/([^/]+)\\/spec\\.md$/);
|
|
46
|
+
if (!match) return result;
|
|
47
|
+
const capability = match[1];
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
let output;
|
|
50
|
+
try {
|
|
51
|
+
// validate exits non-zero when a required check fails — stdout still carries the report
|
|
52
|
+
const res = await $.process.run(['npx', '--no-install', 'oprim', 'validate', '--json']);
|
|
53
|
+
output = res.stdout;
|
|
54
|
+
} catch {
|
|
55
|
+
output = null;
|
|
56
|
+
}
|
|
57
|
+
if (!output) return result;
|
|
58
|
+
|
|
59
|
+
const report = JSON.parse(output);
|
|
60
|
+
const drift = (report.checks || []).filter(
|
|
61
|
+
(c) => c.name && c.name.startsWith('spec-delta:') && c.name.includes(\`in \${capability} \`) && !c.pass
|
|
62
|
+
);
|
|
63
|
+
if (drift.length === 0) return result;
|
|
64
|
+
|
|
65
|
+
const reason = drift.map((c) => \`\${c.name}\${c.note ? \` (\${c.note})\` : ''}\`).join('; ');
|
|
66
|
+
$.ui.log(\`⚠ Spec-delta drift in \${capability}: \${reason}\`);
|
|
67
|
+
} catch {
|
|
68
|
+
// Graceful degradation — never error or block on interceptor failure.
|
|
69
|
+
}
|
|
49
70
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
reason: \`Spec-delta drift detected in \${capability}:\\n\${reason}\`,
|
|
54
|
-
}) + '\\n');
|
|
55
|
-
} catch {
|
|
56
|
-
// Graceful degradation — never error or block on interceptor failure.
|
|
57
|
-
}
|
|
58
|
-
});
|
|
71
|
+
return result;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
59
74
|
`;
|
|
60
75
|
exports.CLAUDE_MODS_REGISTRY = [
|
|
61
76
|
{
|
|
62
77
|
id: 'spec-delta-drift-interceptor',
|
|
63
78
|
title: 'Spec-delta drift interceptor',
|
|
64
79
|
description: "Catches a bet's spec-delta MODIFIED/REMOVED requirement drifting from oprim/specs current truth at write time, instead of only at oprim validate/CI time.",
|
|
65
|
-
|
|
80
|
+
shape: 'plugin',
|
|
81
|
+
pluginFiles: [
|
|
82
|
+
{
|
|
83
|
+
path: '.claude-plugin/plugin.json',
|
|
84
|
+
content: JSON.stringify({
|
|
85
|
+
name: 'spec-delta-drift-interceptor',
|
|
86
|
+
description: "Catches a bet's spec-delta MODIFIED/REMOVED requirement drifting from oprim/specs current truth at write time.",
|
|
87
|
+
version: '1.0.0',
|
|
88
|
+
}, null, 2) + '\n',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
path: 'hooks/hooks.json',
|
|
92
|
+
content: JSON.stringify({
|
|
93
|
+
description: 'Runs checkSpecDeltaDrift() on a bet spec-delta write and surfaces drift via a toast, at write time.',
|
|
94
|
+
modules: ['./register.js'],
|
|
95
|
+
}, null, 2) + '\n',
|
|
96
|
+
},
|
|
66
97
|
{
|
|
67
|
-
|
|
68
|
-
content:
|
|
69
|
-
event: 'PostToolUse',
|
|
70
|
-
matcher: 'Write|Edit',
|
|
71
|
-
command: 'node ".claude/hooks/spec-delta-drift-interceptor.js"',
|
|
98
|
+
path: 'hooks/register.js',
|
|
99
|
+
content: SPEC_DELTA_DRIFT_INTERCEPTOR_REGISTER,
|
|
72
100
|
},
|
|
73
101
|
],
|
|
74
102
|
},
|
|
@@ -9,10 +9,12 @@ export declare function promptEnableFunctionHooks(): Promise<boolean>;
|
|
|
9
9
|
export declare function enableFunctionHooks(projectRoot: string): void;
|
|
10
10
|
export declare function printManualFunctionHooksActivation(): void;
|
|
11
11
|
/**
|
|
12
|
-
* Merges newly-selected mods
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Merges newly-selected mods into the project and removes deselected mods' entries, writing/
|
|
13
|
+
* deleting each mod's files to match. A classic mod's hookFiles merge into .claude/settings.json
|
|
14
|
+
* (additive, non-clobbering — same convention as mergeClaudeSettingsHooks); a plugin mod's
|
|
15
|
+
* pluginFiles are written under .claude/skills/<mod.id>/ instead, where the skills-directory
|
|
16
|
+
* auto-load convention picks it up as a function-hooks plugin — settings.json is untouched for
|
|
17
|
+
* these. Persists the resulting selection to oprim/config.yaml's claude_mods key.
|
|
16
18
|
*/
|
|
17
19
|
export declare function applyClaudeModsSelection(projectRoot: string, selectedIds: string[], previousIds: string[]): void;
|
|
18
20
|
export declare function promptPdrSurfacing(): Promise<boolean>;
|
|
@@ -245,27 +245,40 @@ function removeModHookFromSettings(settings, hookFile) {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
/**
|
|
248
|
-
* Merges newly-selected mods
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
248
|
+
* Merges newly-selected mods into the project and removes deselected mods' entries, writing/
|
|
249
|
+
* deleting each mod's files to match. A classic mod's hookFiles merge into .claude/settings.json
|
|
250
|
+
* (additive, non-clobbering — same convention as mergeClaudeSettingsHooks); a plugin mod's
|
|
251
|
+
* pluginFiles are written under .claude/skills/<mod.id>/ instead, where the skills-directory
|
|
252
|
+
* auto-load convention picks it up as a function-hooks plugin — settings.json is untouched for
|
|
253
|
+
* these. Persists the resulting selection to oprim/config.yaml's claude_mods key.
|
|
252
254
|
*/
|
|
253
255
|
function applyClaudeModsSelection(projectRoot, selectedIds, previousIds) {
|
|
254
256
|
const claudeDir = path.join(projectRoot, '.claude');
|
|
255
257
|
const hooksDir = path.join(claudeDir, 'hooks');
|
|
258
|
+
const skillsDir = path.join(claudeDir, 'skills');
|
|
256
259
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
257
260
|
const settings = readSettings(settingsPath);
|
|
261
|
+
let settingsChanged = false;
|
|
258
262
|
const added = selectedIds.filter((id) => !previousIds.includes(id));
|
|
259
263
|
const removed = previousIds.filter((id) => !selectedIds.includes(id));
|
|
260
264
|
for (const id of added) {
|
|
261
265
|
const mod = (0, claude_mods_1.getClaudeMod)(id);
|
|
262
266
|
if (!mod)
|
|
263
267
|
continue;
|
|
268
|
+
if ((0, claude_mods_1.isPluginMod)(mod)) {
|
|
269
|
+
const pluginDir = path.join(skillsDir, mod.id);
|
|
270
|
+
for (const file of mod.pluginFiles) {
|
|
271
|
+
(0, scaffold_1.writeFile)(path.join(pluginDir, file.path), file.content);
|
|
272
|
+
}
|
|
273
|
+
console.log(chalk_1.default.green('✓') + ` .claude/skills/${mod.id}/ (${mod.title})`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
264
276
|
for (const hookFile of mod.hookFiles) {
|
|
265
277
|
const scriptPath = path.join(hooksDir, hookFile.filename);
|
|
266
278
|
(0, scaffold_1.writeFile)(scriptPath, hookFile.content);
|
|
267
279
|
fs.chmodSync(scriptPath, 0o755);
|
|
268
280
|
addModHookToSettings(settings, hookFile);
|
|
281
|
+
settingsChanged = true;
|
|
269
282
|
console.log(chalk_1.default.green('✓') + ` .claude/hooks/${hookFile.filename} (${mod.title})`);
|
|
270
283
|
}
|
|
271
284
|
}
|
|
@@ -273,15 +286,23 @@ function applyClaudeModsSelection(projectRoot, selectedIds, previousIds) {
|
|
|
273
286
|
const mod = (0, claude_mods_1.getClaudeMod)(id);
|
|
274
287
|
if (!mod)
|
|
275
288
|
continue;
|
|
289
|
+
if ((0, claude_mods_1.isPluginMod)(mod)) {
|
|
290
|
+
const pluginDir = path.join(skillsDir, mod.id);
|
|
291
|
+
if (fs.existsSync(pluginDir))
|
|
292
|
+
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
293
|
+
console.log(chalk_1.default.dim(` removed .claude/skills/${mod.id}/ (${mod.title})`));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
276
296
|
for (const hookFile of mod.hookFiles) {
|
|
277
297
|
const scriptPath = path.join(hooksDir, hookFile.filename);
|
|
278
298
|
if (fs.existsSync(scriptPath))
|
|
279
299
|
fs.unlinkSync(scriptPath);
|
|
280
300
|
removeModHookFromSettings(settings, hookFile);
|
|
301
|
+
settingsChanged = true;
|
|
281
302
|
console.log(chalk_1.default.dim(` removed .claude/hooks/${hookFile.filename} (${mod.title})`));
|
|
282
303
|
}
|
|
283
304
|
}
|
|
284
|
-
if (
|
|
305
|
+
if (settingsChanged) {
|
|
285
306
|
writeSettings(settingsPath, settings);
|
|
286
307
|
}
|
|
287
308
|
(0, detect_1.writeClaudeModsToConfig)(selectedIds, projectRoot);
|