@prodcycle/prodcycle 0.6.5 → 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/api-client.js +14 -1
- package/dist/cli.d.ts +27 -0
- package/dist/cli.js +82 -9
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -399,7 +399,20 @@ class ComplianceApiClient {
|
|
|
399
399
|
const retryAfterSeconds = parseRetryAfter(response.headers.get('retry-after'));
|
|
400
400
|
const errorBody = parsed ?? null;
|
|
401
401
|
const errorMessage = errorBody?.error?.message ?? `API request failed with status ${response.status}`;
|
|
402
|
-
|
|
402
|
+
// 429 (rate limit) and 503 (service unavailable) honor Retry-After.
|
|
403
|
+
// 502 (bad gateway) and 504 (gateway timeout) are transient ALB-layer
|
|
404
|
+
// failures — the backend wasn't reached / didn't respond in time, so
|
|
405
|
+
// the request was not processed and a fresh attempt has a clean
|
|
406
|
+
// chance of succeeding. Concrete case: openbao-openbao got an
|
|
407
|
+
// instantaneous 502 during the 2026-05-13 GA-validation sweep and
|
|
408
|
+
// the CLI bailed without retry, even though the very next repo
|
|
409
|
+
// scanned cleanly. 500 is deliberately NOT retried — that's an
|
|
410
|
+
// application-level error and retrying could double-process or
|
|
411
|
+
// just deterministically refail.
|
|
412
|
+
const isRetryable = response.status === 429 ||
|
|
413
|
+
response.status === 502 ||
|
|
414
|
+
response.status === 503 ||
|
|
415
|
+
response.status === 504;
|
|
403
416
|
if (isRetryable && attempt < MAX_RETRY_ATTEMPTS - 1) {
|
|
404
417
|
const delayMs = retryAfterSeconds != null ? retryAfterSeconds * 1000 : retryBackoffMs(attempt);
|
|
405
418
|
const cappedDelayMs = Math.min(delayMs, MAX_RETRY_AFTER_SECONDS * 1000);
|
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
|
|
347
|
-
if (!
|
|
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
|
-
|
|
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}.
|
|
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}.
|
|
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