@prodcycle/prodcycle 0.3.0 → 0.4.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/dist/cli.js +158 -12
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -98,7 +98,7 @@ const program = new commander_1.Command();
|
|
|
98
98
|
program
|
|
99
99
|
.name('prodcycle')
|
|
100
100
|
.description('Multi-framework policy-as-code compliance scanner for infrastructure and application code.')
|
|
101
|
-
.version('0.
|
|
101
|
+
.version('0.4.0');
|
|
102
102
|
// ── scan ────────────────────────────────────────────────────────────────────
|
|
103
103
|
program
|
|
104
104
|
.command('scan [repo_path]')
|
|
@@ -280,7 +280,7 @@ async function collectHookFiles(filePath) {
|
|
|
280
280
|
program
|
|
281
281
|
.command('init')
|
|
282
282
|
.description('Configure compliance hooks for coding agents')
|
|
283
|
-
.option('--agent <agents>', 'Comma-separated agents to configure (claude, cursor). Default: auto-detect.')
|
|
283
|
+
.option('--agent <agents>', 'Comma-separated agents to configure (claude, cursor, codex, opencode, github-copilot, gemini-cli). Use "all" to configure every agent. Default: auto-detect.')
|
|
284
284
|
.option('--force', 'Overwrite existing compliance hook entries')
|
|
285
285
|
.option('--dir <path>', 'Project directory to configure', '.')
|
|
286
286
|
.action((opts) => {
|
|
@@ -289,12 +289,14 @@ program
|
|
|
289
289
|
const agents = resolveAgents(opts.agent, dir);
|
|
290
290
|
if (agents.length === 0) {
|
|
291
291
|
console.error('init: no agents selected and none auto-detected. ' +
|
|
292
|
-
'Use --agent
|
|
292
|
+
'Use --agent <name> to configure explicitly (claude, cursor, codex, ' +
|
|
293
|
+
'opencode, github-copilot, gemini-cli, or "all").');
|
|
293
294
|
process.exit(2);
|
|
294
295
|
}
|
|
295
296
|
let anyFailed = false;
|
|
297
|
+
const writtenPaths = new Set();
|
|
296
298
|
for (const agent of agents) {
|
|
297
|
-
const result = configureAgent(agent, dir, !!opts.force);
|
|
299
|
+
const result = configureAgent(agent, dir, !!opts.force, writtenPaths);
|
|
298
300
|
process.stdout.write(result.message + '\n');
|
|
299
301
|
if (result.status === 'failed')
|
|
300
302
|
anyFailed = true;
|
|
@@ -306,36 +308,64 @@ program
|
|
|
306
308
|
process.exit(2);
|
|
307
309
|
}
|
|
308
310
|
});
|
|
311
|
+
const ALL_AGENTS = [
|
|
312
|
+
'claude',
|
|
313
|
+
'cursor',
|
|
314
|
+
'codex',
|
|
315
|
+
'opencode',
|
|
316
|
+
'github-copilot',
|
|
317
|
+
'gemini-cli',
|
|
318
|
+
];
|
|
319
|
+
function isAgentName(name) {
|
|
320
|
+
return ALL_AGENTS.includes(name);
|
|
321
|
+
}
|
|
309
322
|
function resolveAgents(userChoice, dir) {
|
|
310
323
|
if (userChoice) {
|
|
311
324
|
const list = parseList(userChoice) ?? [];
|
|
325
|
+
if (list.length === 1 && list[0] === 'all')
|
|
326
|
+
return ALL_AGENTS.slice();
|
|
312
327
|
const valid = [];
|
|
313
328
|
for (const name of list) {
|
|
314
|
-
if (name
|
|
329
|
+
if (isAgentName(name))
|
|
315
330
|
valid.push(name);
|
|
316
331
|
else
|
|
317
332
|
console.error(`init: unknown agent "${name}" — ignoring`);
|
|
318
333
|
}
|
|
319
334
|
return valid;
|
|
320
335
|
}
|
|
321
|
-
// Auto-detect
|
|
336
|
+
// Auto-detect: look for config dirs/files that indicate the agent is already in use.
|
|
322
337
|
const detected = [];
|
|
323
338
|
if (fs.existsSync(path.join(dir, '.claude')))
|
|
324
339
|
detected.push('claude');
|
|
325
340
|
if (fs.existsSync(path.join(dir, '.cursor')))
|
|
326
341
|
detected.push('cursor');
|
|
342
|
+
if (fs.existsSync(path.join(dir, '.codex')))
|
|
343
|
+
detected.push('codex');
|
|
344
|
+
if (fs.existsSync(path.join(dir, '.opencode')))
|
|
345
|
+
detected.push('opencode');
|
|
346
|
+
if (fs.existsSync(path.join(dir, '.github', 'copilot-instructions.md'))) {
|
|
347
|
+
detected.push('github-copilot');
|
|
348
|
+
}
|
|
349
|
+
if (fs.existsSync(path.join(dir, 'GEMINI.md')) ||
|
|
350
|
+
fs.existsSync(path.join(dir, '.gemini'))) {
|
|
351
|
+
detected.push('gemini-cli');
|
|
352
|
+
}
|
|
327
353
|
return detected;
|
|
328
354
|
}
|
|
329
|
-
function configureAgent(agent, dir, force) {
|
|
355
|
+
function configureAgent(agent, dir, force, writtenPaths) {
|
|
330
356
|
switch (agent) {
|
|
331
357
|
case 'claude':
|
|
332
358
|
return configureClaudeCode(dir, force);
|
|
333
359
|
case 'cursor':
|
|
334
|
-
return
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
360
|
+
return configureCursor(dir, force);
|
|
361
|
+
case 'codex':
|
|
362
|
+
return configureInstructionFile(agent, dir, 'AGENTS.md', force, writtenPaths);
|
|
363
|
+
case 'opencode':
|
|
364
|
+
return configureInstructionFile(agent, dir, 'AGENTS.md', force, writtenPaths);
|
|
365
|
+
case 'github-copilot':
|
|
366
|
+
return configureInstructionFile(agent, dir, path.join('.github', 'copilot-instructions.md'), force, writtenPaths);
|
|
367
|
+
case 'gemini-cli':
|
|
368
|
+
return configureInstructionFile(agent, dir, 'GEMINI.md', force, writtenPaths);
|
|
339
369
|
}
|
|
340
370
|
}
|
|
341
371
|
const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
|
|
@@ -390,6 +420,122 @@ function configureClaudeCode(dir, force) {
|
|
|
390
420
|
message: `[claude] wrote PostToolUse hook to ${settingsPath}. Requires PC_API_KEY in the environment when Claude Code runs.`,
|
|
391
421
|
};
|
|
392
422
|
}
|
|
423
|
+
const CURSOR_COMMAND = 'prodcycle hook';
|
|
424
|
+
function configureCursor(dir, force) {
|
|
425
|
+
const cursorDir = path.join(dir, '.cursor');
|
|
426
|
+
const hooksPath = path.join(cursorDir, 'hooks.json');
|
|
427
|
+
let config = { version: 1 };
|
|
428
|
+
if (fs.existsSync(hooksPath)) {
|
|
429
|
+
try {
|
|
430
|
+
const parsed = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
|
431
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
432
|
+
return {
|
|
433
|
+
status: 'failed',
|
|
434
|
+
message: `[cursor] ${hooksPath} is not a JSON object — refusing to overwrite. Fix the file manually.`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
config = parsed;
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
return {
|
|
441
|
+
status: 'failed',
|
|
442
|
+
message: `[cursor] could not parse ${hooksPath}: ${e.message}. Fix the file manually.`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (typeof config.version !== 'number')
|
|
447
|
+
config.version = 1;
|
|
448
|
+
const hooks = (config.hooks ??= {});
|
|
449
|
+
const afterFileEdit = (hooks.afterFileEdit ??= []);
|
|
450
|
+
const existing = afterFileEdit.find((h) => typeof h.command === 'string' && h.command.trim().startsWith('prodcycle hook'));
|
|
451
|
+
if (existing && !force) {
|
|
452
|
+
return {
|
|
453
|
+
status: 'already',
|
|
454
|
+
message: `[cursor] afterFileEdit hook for prodcycle already present in ${hooksPath}. Use --force to rewrite.`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (existing && force) {
|
|
458
|
+
existing.command = CURSOR_COMMAND;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
afterFileEdit.push({ command: CURSOR_COMMAND });
|
|
462
|
+
}
|
|
463
|
+
if (!fs.existsSync(cursorDir))
|
|
464
|
+
fs.mkdirSync(cursorDir, { recursive: true });
|
|
465
|
+
fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
|
|
466
|
+
return {
|
|
467
|
+
status: 'installed',
|
|
468
|
+
message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. Requires PC_API_KEY in the environment when Cursor runs.`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
// ── Instruction-file agents (codex, opencode, github-copilot, gemini-cli) ───
|
|
472
|
+
const INSTRUCTION_BEGIN = '<!-- prodcycle:begin -->';
|
|
473
|
+
const INSTRUCTION_END = '<!-- prodcycle:end -->';
|
|
474
|
+
function buildInstructionBlock() {
|
|
475
|
+
return [
|
|
476
|
+
INSTRUCTION_BEGIN,
|
|
477
|
+
'## ProdCycle Compliance',
|
|
478
|
+
'',
|
|
479
|
+
'This project enforces SOC 2, HIPAA, and NIST CSF policies on infrastructure',
|
|
480
|
+
'and application code. After creating or modifying any file, run the compliance',
|
|
481
|
+
'scanner and fix every violation before continuing:',
|
|
482
|
+
'',
|
|
483
|
+
'```bash',
|
|
484
|
+
'npx prodcycle scan . --format prompt',
|
|
485
|
+
'```',
|
|
486
|
+
'',
|
|
487
|
+
'Each finding includes a severity, rule ID, message, and remediation guidance.',
|
|
488
|
+
'Apply the fixes, then re-run the scan until every check passes.',
|
|
489
|
+
INSTRUCTION_END,
|
|
490
|
+
'',
|
|
491
|
+
].join('\n');
|
|
492
|
+
}
|
|
493
|
+
function configureInstructionFile(agent, dir, relPath, force, writtenPaths) {
|
|
494
|
+
const fullPath = path.join(dir, relPath);
|
|
495
|
+
// Multiple agents may share the same target file (codex + opencode → AGENTS.md).
|
|
496
|
+
// Skip the second agent silently if we already wrote to the same file in this run.
|
|
497
|
+
if (writtenPaths.has(fullPath)) {
|
|
498
|
+
return {
|
|
499
|
+
status: 'already',
|
|
500
|
+
message: `[${agent}] ${relPath} already written in this run (shared with another agent).`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
let existing = '';
|
|
504
|
+
if (fs.existsSync(fullPath)) {
|
|
505
|
+
existing = fs.readFileSync(fullPath, 'utf8');
|
|
506
|
+
}
|
|
507
|
+
const block = buildInstructionBlock();
|
|
508
|
+
const hasBlock = existing.includes(INSTRUCTION_BEGIN) && existing.includes(INSTRUCTION_END);
|
|
509
|
+
if (hasBlock && !force) {
|
|
510
|
+
return {
|
|
511
|
+
status: 'already',
|
|
512
|
+
message: `[${agent}] prodcycle instruction block already present in ${fullPath}. Use --force to rewrite.`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
let next;
|
|
516
|
+
if (hasBlock) {
|
|
517
|
+
const pattern = new RegExp(`${escapeRegExp(INSTRUCTION_BEGIN)}[\\s\\S]*?${escapeRegExp(INSTRUCTION_END)}\\n?`);
|
|
518
|
+
next = existing.replace(pattern, block);
|
|
519
|
+
}
|
|
520
|
+
else if (existing.trim().length === 0) {
|
|
521
|
+
next = block;
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
next = existing.replace(/\n*$/, '\n\n') + block;
|
|
525
|
+
}
|
|
526
|
+
const parent = path.dirname(fullPath);
|
|
527
|
+
if (!fs.existsSync(parent))
|
|
528
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
529
|
+
fs.writeFileSync(fullPath, next);
|
|
530
|
+
writtenPaths.add(fullPath);
|
|
531
|
+
return {
|
|
532
|
+
status: 'installed',
|
|
533
|
+
message: `[${agent}] wrote compliance instructions to ${fullPath}.`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function escapeRegExp(s) {
|
|
537
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
538
|
+
}
|
|
393
539
|
function readStdin() {
|
|
394
540
|
return new Promise((resolve, reject) => {
|
|
395
541
|
if (process.stdin.isTTY) {
|
package/package.json
CHANGED