@prodcycle/prodcycle 0.6.6 → 0.6.7

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/cli.d.ts CHANGED
@@ -50,3 +50,30 @@ export declare function resolveHookFileKey(inputPath: string, realpathFile: stri
50
50
  ok: false;
51
51
  error: string;
52
52
  };
53
+ /**
54
+ * True when a parsed `hook` stdin payload is a Claude Code hook event.
55
+ *
56
+ * Claude Code stamps every hook payload with a `hook_event_name` field
57
+ * (e.g. `"PostToolUse"`); no other supported input shape carries it. We
58
+ * key on its presence to switch `hook` into Claude-Code-native JSON
59
+ * output — see `formatClaudeHookOutput`. Pure so it's unit-testable.
60
+ */
61
+ export declare function isClaudeCodeHookPayload(payload: unknown): boolean;
62
+ /**
63
+ * Render a `hook` scan result as Claude Code PostToolUse hook JSON.
64
+ *
65
+ * Returns `''` when the scan passed — a clean result needs no agent
66
+ * feedback, so the hook stays silent. On violations it returns
67
+ * `{"decision":"block","reason":...}`: Claude Code feeds `reason` back to
68
+ * the agent as actionable feedback, which is what closes the compliance
69
+ * self-healing loop.
70
+ *
71
+ * The caller MUST exit 0 for Claude Code to parse this — Claude Code
72
+ * ignores hook JSON on any non-zero exit. Pure so it's unit-testable.
73
+ */
74
+ export declare function formatClaudeHookOutput(response: {
75
+ exitCode: number;
76
+ passed?: boolean;
77
+ prompt?: string;
78
+ findings?: unknown[];
79
+ }): string;
package/dist/cli.js CHANGED
@@ -36,6 +36,8 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.isCiEnvironment = isCiEnvironment;
38
38
  exports.resolveHookFileKey = resolveHookFileKey;
39
+ exports.isClaudeCodeHookPayload = isClaudeCodeHookPayload;
40
+ exports.formatClaudeHookOutput = formatClaudeHookOutput;
39
41
  const commander_1 = require("commander");
40
42
  const child_process_1 = require("child_process");
41
43
  const fs = __importStar(require("fs"));
@@ -343,18 +345,36 @@ program
343
345
  try {
344
346
  const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
345
347
  const format = (opts.format ?? 'prompt');
346
- const files = await collectHookFiles(opts.file);
347
- if (!files || Object.keys(files).length === 0) {
348
+ const collected = await collectHookFiles(opts.file);
349
+ if (!collected || Object.keys(collected.files).length === 0) {
348
350
  // No files to check — exit clean so the agent proceeds.
349
351
  process.exit(0);
350
352
  return;
351
353
  }
352
354
  const response = await (0, index_1.gate)({
353
- files,
355
+ files: collected.files,
354
356
  frameworks,
355
357
  apiUrl: opts.apiUrl,
356
358
  apiKey: opts.apiKey,
357
359
  });
360
+ if (collected.claudeHook && response.exitCode !== 2) {
361
+ // Invoked as a Claude Code PostToolUse hook. Emit Claude Code's
362
+ // native hook JSON on stdout so a violation is fed back to the
363
+ // agent as actionable feedback — this is what closes the compliance
364
+ // self-healing loop. Claude Code only parses hook JSON when the
365
+ // command exits 0, so we exit 0 here even on violations and let
366
+ // `decision: "block"` carry the signal.
367
+ //
368
+ // exitCode 2 (scanner unavailable) deliberately falls through to
369
+ // the generic path below: it exits 2, and Claude Code surfaces a
370
+ // hook's stderr on a 2 — gate() already wrote the scanner-error
371
+ // warning there, so the agent still learns the scan was inconclusive.
372
+ const claudeJson = formatClaudeHookOutput(response);
373
+ if (claudeJson)
374
+ process.stdout.write(claudeJson + '\n');
375
+ process.exit(0);
376
+ return;
377
+ }
358
378
  writeOutput(renderReport(response, format), opts.output);
359
379
  process.exit(response.exitCode);
360
380
  }
@@ -409,6 +429,39 @@ function resolveHookFileKey(inputPath, realpathFile, realpathCwd) {
409
429
  }
410
430
  return { ok: true, key: relative };
411
431
  }
432
+ /**
433
+ * True when a parsed `hook` stdin payload is a Claude Code hook event.
434
+ *
435
+ * Claude Code stamps every hook payload with a `hook_event_name` field
436
+ * (e.g. `"PostToolUse"`); no other supported input shape carries it. We
437
+ * key on its presence to switch `hook` into Claude-Code-native JSON
438
+ * output — see `formatClaudeHookOutput`. Pure so it's unit-testable.
439
+ */
440
+ function isClaudeCodeHookPayload(payload) {
441
+ return (typeof payload === 'object' &&
442
+ payload !== null &&
443
+ typeof payload.hook_event_name === 'string');
444
+ }
445
+ /**
446
+ * Render a `hook` scan result as Claude Code PostToolUse hook JSON.
447
+ *
448
+ * Returns `''` when the scan passed — a clean result needs no agent
449
+ * feedback, so the hook stays silent. On violations it returns
450
+ * `{"decision":"block","reason":...}`: Claude Code feeds `reason` back to
451
+ * the agent as actionable feedback, which is what closes the compliance
452
+ * self-healing loop.
453
+ *
454
+ * The caller MUST exit 0 for Claude Code to parse this — Claude Code
455
+ * ignores hook JSON on any non-zero exit. Pure so it's unit-testable.
456
+ */
457
+ function formatClaudeHookOutput(response) {
458
+ if (response.exitCode === 0)
459
+ return '';
460
+ const reason = typeof response.prompt === 'string' && response.prompt.trim()
461
+ ? response.prompt
462
+ : (0, prompt_1.formatPrompt)(response);
463
+ return JSON.stringify({ decision: 'block', reason });
464
+ }
412
465
  async function collectHookFiles(filePath) {
413
466
  if (filePath) {
414
467
  const absolute = path.resolve(filePath);
@@ -427,7 +480,8 @@ async function collectHookFiles(filePath) {
427
480
  console.error(resolved.error);
428
481
  process.exit(2);
429
482
  }
430
- return { [resolved.key]: content };
483
+ // `--file` is a manual / non-agent invocation — never Claude Code.
484
+ return { files: { [resolved.key]: content }, claudeHook: false };
431
485
  }
432
486
  const stdin = await readStdin();
433
487
  if (!stdin.trim()) {
@@ -442,22 +496,25 @@ async function collectHookFiles(filePath) {
442
496
  console.error(`hook: invalid JSON on stdin: ${e.message}`);
443
497
  process.exit(2);
444
498
  }
499
+ // Claude Code stamps its hook payloads with `hook_event_name`; detect it
500
+ // once here so every return below can flag a Claude Code invocation.
501
+ const claudeHook = isClaudeCodeHookPayload(payload);
445
502
  // Shape 1: {"files": {path: content}} — gate-compatible
446
503
  if (payload && typeof payload.files === 'object' && payload.files !== null) {
447
- return payload.files;
504
+ return { files: payload.files, claudeHook };
448
505
  }
449
506
  // Shape 2: top-level single file. Shape 3: Claude Code tool_input nesting.
450
507
  const candidate = payload?.tool_input ?? payload;
451
508
  const hookFilePath = candidate?.file_path ?? candidate?.path;
452
509
  const hookContent = candidate?.content ?? candidate?.new_string;
453
510
  if (hookFilePath && typeof hookContent === 'string') {
454
- return { [hookFilePath]: hookContent };
511
+ return { files: { [hookFilePath]: hookContent }, claudeHook };
455
512
  }
456
513
  if (hookFilePath && fs.existsSync(hookFilePath)) {
457
514
  // Only a path was given — read from disk so post-edit hooks still work
458
515
  // when the agent doesn't ship the content inline.
459
516
  const content = fs.readFileSync(hookFilePath, 'utf8');
460
- return { [hookFilePath]: content };
517
+ return { files: { [hookFilePath]: content }, claudeHook };
461
518
  }
462
519
  console.error('hook: stdin payload not recognized. Expected one of:\n' +
463
520
  ' {"files": {"path": "content"}}\n' +
@@ -570,6 +627,22 @@ function configureAgent(agent, dir, force, writtenPaths) {
570
627
  }
571
628
  const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
572
629
  const CLAUDE_COMMAND = 'prodcycle hook';
630
+ /**
631
+ * Build the post-install hint about `PC_API_KEY`. The hook calls the hosted
632
+ * API, which needs the key in the environment the agent runs in — the most
633
+ * common reason a freshly-installed hook fails. We can only inspect the shell
634
+ * running `init`, which is NOT necessarily the environment the agent runs in
635
+ * (e.g. a macOS GUI-launched editor does not inherit shell exports), so the
636
+ * "set" branch points the user at making the key available there too.
637
+ */
638
+ function apiKeyHint(agentLabel) {
639
+ return process.env.PC_API_KEY
640
+ ? `PC_API_KEY is set in this shell ✓ — make sure it is also available in ` +
641
+ `the environment ${agentLabel} runs in (e.g. add it to your shell profile).`
642
+ : `⚠ Set PC_API_KEY before launching ${agentLabel} — run ` +
643
+ '`export PC_API_KEY=pc_...` in that shell (or add it to your shell ' +
644
+ 'profile). The hook calls the hosted API and fails without it.';
645
+ }
573
646
  function configureClaudeCode(dir, force) {
574
647
  const claudeDir = path.join(dir, '.claude');
575
648
  const settingsPath = path.join(claudeDir, 'settings.json');
@@ -617,7 +690,7 @@ function configureClaudeCode(dir, force) {
617
690
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
618
691
  return {
619
692
  status: 'installed',
620
- message: `[claude] wrote PostToolUse hook to ${settingsPath}. Requires PC_API_KEY in the environment when Claude Code runs.`,
693
+ message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint('Claude Code')}`,
621
694
  };
622
695
  }
623
696
  const CURSOR_COMMAND = 'prodcycle hook';
@@ -665,7 +738,7 @@ function configureCursor(dir, force) {
665
738
  fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
666
739
  return {
667
740
  status: 'installed',
668
- message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. Requires PC_API_KEY in the environment when Cursor runs.`,
741
+ message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. ${apiKeyHint('Cursor')}`,
669
742
  };
670
743
  }
671
744
  // ── Instruction-file agents (codex, opencode, github-copilot, gemini-cli) ───
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
5
5
  "homepage": "https://docs.prodcycle.com",
6
6
  "repository": {