@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.
Files changed (2) hide show
  1. package/dist/cli.js +158 -12
  2. 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.3.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 claude (or cursor) to configure explicitly.');
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 === 'claude' || name === 'cursor')
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
- status: 'failed',
336
- message: '[cursor] skipped Cursor does not currently expose a post-edit hook mechanism.\n' +
337
- ' Add a `.cursor/rules` entry pointing reviewers at `prodcycle scan .` until hook support lands.',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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": {