@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.
@@ -5,11 +5,25 @@ export interface ClaudeModHookFile {
5
5
  matcher?: string;
6
6
  command: string;
7
7
  }
8
- export interface ClaudeMod {
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 {};
@@ -1,74 +1,102 @@
1
1
  "use strict";
2
- // Static registry of installable Claude Code function-hook "mods" — see
3
- // oprim/bets/pending/BET-051-spec-delta-drift-interceptor-mod/design.md. Both `oprim init`'s
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–066) is added here once rather than duplicated per entry point.
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
- // PostToolUse hook: on a Write/Edit to a bet's specs/<capability>/spec.md delta, shells out to
10
- // `oprim validate --json` (already runs checkSpecDeltaDrift()) and surfaces any MODIFIED/REMOVED
11
- // drift for that capability inline, at write time instead of only at validate/CI time. Silently
12
- // no-ops on any failure (oprim not resolvable, malformed hook input, etc.) — this is a
13
- // write-time convenience on top of `oprim validate`, never a second source of required failures.
14
- const SPEC_DELTA_DRIFT_INTERCEPTOR_SCRIPT = `#!/usr/bin/env node
15
- // PostToolUse hook (Write|Edit)see claude-mods.ts's spec-delta-drift-interceptor entry.
16
-
17
- let raw = '';
18
- process.stdin.on('data', (chunk) => { raw += chunk; });
19
- process.stdin.on('end', () => {
20
- try {
21
- const input = JSON.parse(raw || '{}');
22
- const filePath = input.tool_input && input.tool_input.file_path;
23
- if (!filePath) return;
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
- const match = String(filePath).replace(/\\\\/g, '/').match(
26
- /oprim\\/bets\\/pending\\/[^/]+\\/specs\\/([^/]+)\\/spec\\.md$/
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
- output = execSync('npx --no-install oprim validate --json', {
35
- encoding: 'utf-8',
36
- stdio: ['ignore', 'pipe', 'ignore'],
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
- const report = JSON.parse(output);
45
- const drift = (report.checks || []).filter(
46
- (c) => c.name && c.name.startsWith('spec-delta:') && c.name.includes(\`in \${capability} \`) && !c.pass
47
- );
48
- if (drift.length === 0) return;
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
- const reason = drift.map((c) => \`- \${c.name}\${c.note ? \` (\${c.note})\` : ''}\`).join('\\n');
51
- process.stdout.write(JSON.stringify({
52
- decision: 'block',
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
- hookFiles: [
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
- filename: 'spec-delta-drift-interceptor.js',
68
- content: SPEC_DELTA_DRIFT_INTERCEPTOR_SCRIPT,
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' hookFiles into .claude/settings.json (additive, non-clobbering —
13
- * same convention as mergeClaudeSettingsHooks) and removes deselected mods' entries, writing/
14
- * deleting each mod's hook script file(s) to match. Persists the resulting selection to
15
- * oprim/config.yaml's claude_mods key.
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' hookFiles into .claude/settings.json (additive, non-clobbering —
249
- * same convention as mergeClaudeSettingsHooks) and removes deselected mods' entries, writing/
250
- * deleting each mod's hook script file(s) to match. Persists the resulting selection to
251
- * oprim/config.yaml's claude_mods key.
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 (added.length > 0 || removed.length > 0) {
305
+ if (settingsChanged) {
285
306
  writeSettings(settingsPath, settings);
286
307
  }
287
308
  (0, detect_1.writeClaudeModsToConfig)(selectedIds, projectRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-product-primer/cli",
3
- "version": "2.11.0",
3
+ "version": "2.12.1",
4
4
  "description": "Open Product Primer CLI — product decisions, sequencing, and KPI tracking for repositories",
5
5
  "keywords": [
6
6
  "product",