@imdeadpool/guardex 5.0.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.
@@ -0,0 +1,2478 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const cp = require('node:child_process');
6
+
7
+ const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
8
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
9
+
10
+ const TOOL_NAME = 'guardex';
11
+ const SHORT_TOOL_NAME = 'gx';
12
+ const LEGACY_NAMES = ['musafety', 'multiagent-safety'];
13
+ const GLOBAL_TOOLCHAIN_PACKAGES = ['oh-my-codex', '@fission-ai/openspec'];
14
+ const MAINTAINER_RELEASE_REPO = path.resolve(
15
+ process.env.MUSAFETY_RELEASE_REPO || '/tmp/multiagent-safety',
16
+ );
17
+ const NPM_BIN = process.env.MUSAFETY_NPM_BIN || 'npm';
18
+ const SCORECARD_BIN = process.env.MUSAFETY_SCORECARD_BIN || 'scorecard';
19
+ const GIT_PROTECTED_BRANCHES_KEY = 'multiagent.protectedBranches';
20
+ const GIT_BASE_BRANCH_KEY = 'multiagent.baseBranch';
21
+ const GIT_SYNC_STRATEGY_KEY = 'multiagent.sync.strategy';
22
+ const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master'];
23
+ const DEFAULT_BASE_BRANCH = 'dev';
24
+ const DEFAULT_SYNC_STRATEGY = 'rebase';
25
+
26
+ const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');
27
+
28
+ const TEMPLATE_FILES = [
29
+ 'scripts/agent-branch-start.sh',
30
+ 'scripts/agent-branch-finish.sh',
31
+ 'scripts/codex-agent.sh',
32
+ 'scripts/agent-worktree-prune.sh',
33
+ 'scripts/agent-file-locks.py',
34
+ 'scripts/install-agent-git-hooks.sh',
35
+ 'scripts/openspec/init-plan-workspace.sh',
36
+ 'githooks/pre-commit',
37
+ 'codex/skills/guardex/SKILL.md',
38
+ 'claude/commands/guardex.md',
39
+ ];
40
+
41
+ const EXECUTABLE_RELATIVE_PATHS = new Set([
42
+ 'scripts/agent-branch-start.sh',
43
+ 'scripts/agent-branch-finish.sh',
44
+ 'scripts/codex-agent.sh',
45
+ 'scripts/agent-worktree-prune.sh',
46
+ 'scripts/agent-file-locks.py',
47
+ 'scripts/install-agent-git-hooks.sh',
48
+ 'scripts/openspec/init-plan-workspace.sh',
49
+ '.githooks/pre-commit',
50
+ ]);
51
+
52
+ const CRITICAL_GUARDRAIL_PATHS = new Set([
53
+ 'AGENTS.md',
54
+ '.githooks/pre-commit',
55
+ 'scripts/agent-branch-start.sh',
56
+ 'scripts/agent-branch-finish.sh',
57
+ 'scripts/agent-file-locks.py',
58
+ ]);
59
+
60
+ const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json';
61
+ const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
62
+ const GITIGNORE_MARKER_START = '# multiagent-safety:START';
63
+ const GITIGNORE_MARKER_END = '# multiagent-safety:END';
64
+ const MANAGED_GITIGNORE_PATHS = [
65
+ 'scripts/agent-branch-start.sh',
66
+ 'scripts/agent-branch-finish.sh',
67
+ 'scripts/codex-agent.sh',
68
+ 'scripts/agent-worktree-prune.sh',
69
+ 'scripts/agent-file-locks.py',
70
+ 'scripts/install-agent-git-hooks.sh',
71
+ 'scripts/openspec/init-plan-workspace.sh',
72
+ '.githooks/pre-commit',
73
+ 'oh-my-codex/',
74
+ '.codex/skills/guardex/SKILL.md',
75
+ '.claude/commands/guardex.md',
76
+ LOCK_FILE_RELATIVE,
77
+ ];
78
+ const COMMAND_TYPO_ALIASES = new Map([
79
+ ['relaese', 'release'],
80
+ ['realaese', 'release'],
81
+ ['relase', 'release'],
82
+ ['setpu', 'setup'],
83
+ ['intsall', 'install'],
84
+ ['docter', 'doctor'],
85
+ ['doctro', 'doctor'],
86
+ ['scna', 'scan'],
87
+ ]);
88
+ const SUGGESTIBLE_COMMANDS = [
89
+ 'status',
90
+ 'setup',
91
+ 'doctor',
92
+ 'report',
93
+ 'copy-prompt',
94
+ 'copy-commands',
95
+ 'protect',
96
+ 'sync',
97
+ 'release',
98
+ 'install',
99
+ 'fix',
100
+ 'scan',
101
+ 'print-agents-snippet',
102
+ 'help',
103
+ 'version',
104
+ ];
105
+ const CLI_COMMAND_DESCRIPTIONS = [
106
+ ['status', 'Show GuardeX CLI + service health without modifying files'],
107
+ ['setup', 'Install + repair guardrails in a git repo (supports --no-gitignore)'],
108
+ ['doctor', 'Repair safety setup drift, then verify repo safety'],
109
+ ['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
110
+ ['copy-prompt', 'Print the AI-ready setup checklist'],
111
+ ['copy-commands', 'Print setup checklist as executable commands only'],
112
+ ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
113
+ ['sync', 'Check or sync agent branches with origin/<base>'],
114
+ ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
115
+ ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
116
+ ['scan', 'Report safety issues and exit non-zero on findings'],
117
+ ['print-agents-snippet', 'Print the AGENTS.md snippet template'],
118
+ ['release', 'Publish GuardeX from maintainer release repo'],
119
+ ['help', 'Show this help output'],
120
+ ['version', 'Print GuardeX version'],
121
+ ];
122
+
123
+ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-Rex for your repo) in this repository for Codex or Claude.
124
+
125
+ 1) Install (if missing):
126
+ npm i -g @imdeadpool/guardex
127
+
128
+ 2) Bootstrap safety in this repo:
129
+ gx setup
130
+
131
+ - Setup detects global OMX/OpenSpec first.
132
+ - If one is missing and setup asks for approval, reply explicitly:
133
+ - y = run: npm i -g oh-my-codex @fission-ai/openspec (missing ones only)
134
+ - n = skip global installs
135
+
136
+ 3) If setup reports warnings/errors, repair + re-check:
137
+ gx doctor
138
+
139
+ 4) Confirm next safe agent workflow commands:
140
+ bash scripts/codex-agent.sh "task" "agent-name"
141
+ bash scripts/agent-branch-start.sh "task" "agent-name"
142
+ python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
143
+ bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
144
+
145
+ 5) Optional: create OpenSpec planning workspace:
146
+ bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
147
+
148
+ 6) Optional: protect extra branches:
149
+ gx protect add release staging
150
+
151
+ 7) Optional: sync your current agent branch with latest base branch:
152
+ gx sync --check
153
+ gx sync
154
+ `;
155
+
156
+ const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
157
+ gx setup
158
+ gx doctor
159
+ bash scripts/codex-agent.sh "task" "agent-name"
160
+ bash scripts/agent-branch-start.sh "task" "agent-name"
161
+ python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
162
+ bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
163
+ bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
164
+ gx protect add release staging
165
+ gx sync --check
166
+ gx sync
167
+ `;
168
+
169
+ const SCORECARD_RISK_BY_CHECK = {
170
+ 'Dangerous-Workflow': 'Critical',
171
+ 'Code-Review': 'High',
172
+ Maintained: 'High',
173
+ 'Binary-Artifacts': 'High',
174
+ 'Dependency-Update-Tool': 'High',
175
+ 'Token-Permissions': 'High',
176
+ Vulnerabilities: 'High',
177
+ 'Branch-Protection': 'High',
178
+ Fuzzing: 'Medium',
179
+ 'Pinned-Dependencies': 'Medium',
180
+ SAST: 'Medium',
181
+ 'Security-Policy': 'Medium',
182
+ 'CII-Best-Practices': 'Low',
183
+ Contributors: 'Low',
184
+ License: 'Low',
185
+ };
186
+
187
+ function runtimeVersion() {
188
+ return `${packageJson.name}/${packageJson.version} ${process.platform}-${process.arch} node-${process.version}`;
189
+ }
190
+
191
+ function supportsAnsiColors() {
192
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
193
+ }
194
+
195
+ function colorize(text, colorCode) {
196
+ if (!supportsAnsiColors()) {
197
+ return text;
198
+ }
199
+ return `\u001B[${colorCode}m${text}\u001B[0m`;
200
+ }
201
+
202
+ function statusDot(status) {
203
+ if (status === 'active') {
204
+ return colorize('●', '32'); // green
205
+ }
206
+ if (status === 'inactive') {
207
+ return colorize('●', '31'); // red
208
+ }
209
+ return colorize('●', '33'); // yellow for degraded/unknown
210
+ }
211
+
212
+ function commandCatalogLines(indent = ' ') {
213
+ const maxCommandLength = CLI_COMMAND_DESCRIPTIONS.reduce(
214
+ (max, [command]) => Math.max(max, command.length),
215
+ 0,
216
+ );
217
+ return CLI_COMMAND_DESCRIPTIONS.map(
218
+ ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`,
219
+ );
220
+ }
221
+
222
+ function printToolLogsSummary() {
223
+ const usageLine = ` $ ${SHORT_TOOL_NAME} <command> [options]`;
224
+ const commandDetails = commandCatalogLines(' ');
225
+
226
+ if (!supportsAnsiColors()) {
227
+ console.log(`${TOOL_NAME}-tools logs:`);
228
+ console.log(' USAGE');
229
+ console.log(usageLine);
230
+ console.log(' COMMANDS');
231
+ for (const line of commandDetails) {
232
+ console.log(line);
233
+ }
234
+ return;
235
+ }
236
+
237
+ const title = colorize(`${TOOL_NAME}-tools logs`, '1;36');
238
+ const usageHeader = colorize('USAGE', '1');
239
+ const commandsHeader = colorize('COMMANDS', '1');
240
+ const pipe = colorize('│', '90');
241
+ const tee = colorize('├', '90');
242
+ const corner = colorize('└', '90');
243
+
244
+ console.log(`${title}:`);
245
+ console.log(` ${tee}─ ${usageHeader}`);
246
+ console.log(` ${pipe}${usageLine}`);
247
+ console.log(` ${tee}─ ${commandsHeader}`);
248
+ for (const line of commandDetails) {
249
+ if (!line) {
250
+ console.log(` ${pipe}`);
251
+ continue;
252
+ }
253
+ console.log(` ${pipe}${line.slice(2)}`);
254
+ }
255
+ console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`);
256
+ }
257
+
258
+ function usage(options = {}) {
259
+ const { outsideGitRepo = false } = options;
260
+
261
+ console.log(`A command-line tool that sets up hardened multi-agent safety for git repositories.
262
+
263
+ VERSION
264
+ ${runtimeVersion()}
265
+
266
+ USAGE
267
+ $ ${SHORT_TOOL_NAME} <command> [options]
268
+
269
+ COMMANDS
270
+ ${commandCatalogLines().join('\n')}
271
+
272
+ NOTES
273
+ - Running ${TOOL_NAME} with no command defaults to: ${SHORT_TOOL_NAME} status
274
+ - Short alias: ${SHORT_TOOL_NAME}
275
+ - ${TOOL_NAME} setup asks for Y/N approval before global installs
276
+ - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`);
277
+
278
+ if (outsideGitRepo) {
279
+ console.log(`
280
+ [${TOOL_NAME}] No git repository detected in current directory.
281
+ [${TOOL_NAME}] Start from a repo root, or pass an explicit target:
282
+ ${TOOL_NAME} setup --target <path-to-git-repo>`);
283
+ }
284
+ }
285
+
286
+ function run(cmd, args, options = {}) {
287
+ return cp.spawnSync(cmd, args, {
288
+ encoding: 'utf8',
289
+ stdio: options.stdio || 'pipe',
290
+ cwd: options.cwd,
291
+ timeout: options.timeout,
292
+ });
293
+ }
294
+
295
+ function gitRun(repoRoot, args, { allowFailure = false } = {}) {
296
+ const result = run('git', ['-C', repoRoot, ...args]);
297
+ if (!allowFailure && result.status !== 0) {
298
+ throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
299
+ }
300
+ return result;
301
+ }
302
+
303
+ function resolveRepoRoot(targetPath) {
304
+ const resolvedTarget = path.resolve(targetPath || process.cwd());
305
+ const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
306
+ if (result.status !== 0) {
307
+ const stderr = (result.stderr || '').trim();
308
+ throw new Error(
309
+ `Target is not inside a git repository: ${resolvedTarget}${stderr ? `\n${stderr}` : ''}`,
310
+ );
311
+ }
312
+ return result.stdout.trim();
313
+ }
314
+
315
+ function isGitRepo(targetPath) {
316
+ const resolvedTarget = path.resolve(targetPath || process.cwd());
317
+ const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
318
+ return result.status === 0;
319
+ }
320
+
321
+ function toDestinationPath(relativeTemplatePath) {
322
+ if (relativeTemplatePath.startsWith('scripts/')) {
323
+ return relativeTemplatePath;
324
+ }
325
+ if (relativeTemplatePath.startsWith('githooks/')) {
326
+ return `.${relativeTemplatePath}`;
327
+ }
328
+ if (relativeTemplatePath.startsWith('codex/')) {
329
+ return `.${relativeTemplatePath}`;
330
+ }
331
+ if (relativeTemplatePath.startsWith('claude/')) {
332
+ return `.${relativeTemplatePath}`;
333
+ }
334
+ throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
335
+ }
336
+
337
+ function ensureParentDir(filePath, dryRun) {
338
+ if (dryRun) return;
339
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
340
+ }
341
+
342
+ function ensureExecutable(destinationPath, relativePath, dryRun) {
343
+ if (dryRun) return;
344
+ if (EXECUTABLE_RELATIVE_PATHS.has(relativePath)) {
345
+ fs.chmodSync(destinationPath, 0o755);
346
+ }
347
+ }
348
+
349
+ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
350
+ const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
351
+ const destinationRelativePath = toDestinationPath(relativeTemplatePath);
352
+ const destinationPath = path.join(repoRoot, destinationRelativePath);
353
+
354
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
355
+ const destinationExists = fs.existsSync(destinationPath);
356
+
357
+ if (destinationExists) {
358
+ const existingContent = fs.readFileSync(destinationPath, 'utf8');
359
+ if (existingContent === sourceContent) {
360
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
361
+ return { status: 'unchanged', file: destinationRelativePath };
362
+ }
363
+ if (!force) {
364
+ throw new Error(
365
+ `Refusing to overwrite existing file without --force: ${destinationRelativePath}`,
366
+ );
367
+ }
368
+ }
369
+
370
+ ensureParentDir(destinationPath, dryRun);
371
+ if (!dryRun) {
372
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
373
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
374
+ }
375
+
376
+ return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
377
+ }
378
+
379
+ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
380
+ const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
381
+ const destinationRelativePath = toDestinationPath(relativeTemplatePath);
382
+ const destinationPath = path.join(repoRoot, destinationRelativePath);
383
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
384
+
385
+ if (fs.existsSync(destinationPath)) {
386
+ const existingContent = fs.readFileSync(destinationPath, 'utf8');
387
+ if (existingContent === sourceContent) {
388
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
389
+ return { status: 'unchanged', file: destinationRelativePath };
390
+ }
391
+
392
+ // In fix mode, avoid silently replacing local customizations.
393
+ return { status: 'skipped-conflict', file: destinationRelativePath };
394
+ }
395
+
396
+ ensureParentDir(destinationPath, dryRun);
397
+ if (!dryRun) {
398
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
399
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
400
+ }
401
+
402
+ return { status: 'created', file: destinationRelativePath };
403
+ }
404
+
405
+ function lockFilePath(repoRoot) {
406
+ return path.join(repoRoot, LOCK_FILE_RELATIVE);
407
+ }
408
+
409
+ function ensureLockRegistry(repoRoot, dryRun) {
410
+ const absolutePath = lockFilePath(repoRoot);
411
+ if (fs.existsSync(absolutePath)) {
412
+ return { status: 'unchanged', file: LOCK_FILE_RELATIVE };
413
+ }
414
+
415
+ if (!dryRun) {
416
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
417
+ fs.writeFileSync(absolutePath, JSON.stringify({ locks: {} }, null, 2) + '\n', 'utf8');
418
+ }
419
+
420
+ return { status: 'created', file: LOCK_FILE_RELATIVE };
421
+ }
422
+
423
+ function lockStateOrError(repoRoot) {
424
+ const lockPath = lockFilePath(repoRoot);
425
+ if (!fs.existsSync(lockPath)) {
426
+ return { ok: false, error: `${LOCK_FILE_RELATIVE} is missing` };
427
+ }
428
+
429
+ try {
430
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
431
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.locks !== 'object' || parsed.locks === null) {
432
+ return { ok: false, error: `${LOCK_FILE_RELATIVE} has invalid schema (expected { locks: {} })` };
433
+ }
434
+
435
+ // Normalize older schema entries.
436
+ for (const [filePath, entry] of Object.entries(parsed.locks)) {
437
+ if (!entry || typeof entry !== 'object') {
438
+ parsed.locks[filePath] = { branch: '', claimed_at: '', allow_delete: false };
439
+ continue;
440
+ }
441
+ if (!Object.prototype.hasOwnProperty.call(entry, 'allow_delete')) {
442
+ entry.allow_delete = false;
443
+ }
444
+ }
445
+
446
+ return { ok: true, raw: parsed, locks: parsed.locks };
447
+ } catch (error) {
448
+ return { ok: false, error: `${LOCK_FILE_RELATIVE} is invalid JSON: ${error.message}` };
449
+ }
450
+ }
451
+
452
+ function writeLockState(repoRoot, payload, dryRun) {
453
+ if (dryRun) return;
454
+ const lockPath = lockFilePath(repoRoot);
455
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
456
+ fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
457
+ }
458
+
459
+ function ensurePackageScripts(repoRoot, dryRun) {
460
+ const packagePath = path.join(repoRoot, 'package.json');
461
+ if (!fs.existsSync(packagePath)) {
462
+ return { status: 'skipped', file: 'package.json', note: 'package.json not found' };
463
+ }
464
+
465
+ let pkg;
466
+ try {
467
+ pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
468
+ } catch (error) {
469
+ throw new Error(`Unable to parse package.json in target repo: ${error.message}`);
470
+ }
471
+
472
+ const wantedScripts = {
473
+ 'agent:codex': 'bash ./scripts/codex-agent.sh',
474
+ 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
475
+ 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
476
+ 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh --base dev',
477
+ 'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
478
+ 'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
479
+ 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
480
+ 'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release',
481
+ 'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status',
482
+ 'agent:plan:init': 'bash ./scripts/openspec/init-plan-workspace.sh',
483
+ 'agent:protect:list': `${SHORT_TOOL_NAME} protect list`,
484
+ 'agent:branch:sync': `${SHORT_TOOL_NAME} sync`,
485
+ 'agent:branch:sync:check': `${SHORT_TOOL_NAME} sync --check`,
486
+ 'agent:safety:setup': `${SHORT_TOOL_NAME} setup`,
487
+ 'agent:safety:scan': `${SHORT_TOOL_NAME} scan`,
488
+ 'agent:safety:fix': `${SHORT_TOOL_NAME} fix`,
489
+ 'agent:safety:doctor': `${SHORT_TOOL_NAME} doctor`,
490
+ };
491
+
492
+ pkg.scripts = pkg.scripts || {};
493
+ let changed = false;
494
+ for (const [key, value] of Object.entries(wantedScripts)) {
495
+ if (pkg.scripts[key] !== value) {
496
+ pkg.scripts[key] = value;
497
+ changed = true;
498
+ }
499
+ }
500
+
501
+ if (!changed) {
502
+ return { status: 'unchanged', file: 'package.json' };
503
+ }
504
+
505
+ if (!dryRun) {
506
+ fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
507
+ }
508
+
509
+ return { status: 'updated', file: 'package.json' };
510
+ }
511
+
512
+ function ensureAgentsSnippet(repoRoot, dryRun) {
513
+ const agentsPath = path.join(repoRoot, 'AGENTS.md');
514
+ const snippet = fs.readFileSync(path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'), 'utf8').trimEnd();
515
+
516
+ if (!fs.existsSync(agentsPath)) {
517
+ if (!dryRun) {
518
+ fs.writeFileSync(agentsPath, `# AGENTS\n\n${snippet}\n`, 'utf8');
519
+ }
520
+ return { status: 'created', file: 'AGENTS.md' };
521
+ }
522
+
523
+ const existing = fs.readFileSync(agentsPath, 'utf8');
524
+ if (existing.includes(AGENTS_MARKER_START)) {
525
+ return { status: 'unchanged', file: 'AGENTS.md' };
526
+ }
527
+
528
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
529
+ if (!dryRun) {
530
+ fs.writeFileSync(agentsPath, `${existing}${separator}${snippet}\n`, 'utf8');
531
+ }
532
+
533
+ return { status: 'updated', file: 'AGENTS.md' };
534
+ }
535
+
536
+ function ensureManagedGitignore(repoRoot, dryRun) {
537
+ const gitignorePath = path.join(repoRoot, '.gitignore');
538
+ const managedBlock = [
539
+ GITIGNORE_MARKER_START,
540
+ ...MANAGED_GITIGNORE_PATHS,
541
+ GITIGNORE_MARKER_END,
542
+ ].join('\n');
543
+ const managedRegex = new RegExp(
544
+ `${GITIGNORE_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${GITIGNORE_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
545
+ 'm',
546
+ );
547
+
548
+ if (!fs.existsSync(gitignorePath)) {
549
+ if (!dryRun) {
550
+ fs.writeFileSync(gitignorePath, `${managedBlock}\n`, 'utf8');
551
+ }
552
+ return { status: 'created', file: '.gitignore', note: 'added guardex-managed entries' };
553
+ }
554
+
555
+ const existing = fs.readFileSync(gitignorePath, 'utf8');
556
+ if (managedRegex.test(existing)) {
557
+ const next = existing.replace(managedRegex, managedBlock);
558
+ if (next === existing) {
559
+ return { status: 'unchanged', file: '.gitignore' };
560
+ }
561
+ if (!dryRun) {
562
+ fs.writeFileSync(gitignorePath, next, 'utf8');
563
+ }
564
+ return { status: 'updated', file: '.gitignore', note: 'refreshed guardex-managed entries' };
565
+ }
566
+
567
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
568
+ if (!dryRun) {
569
+ fs.writeFileSync(gitignorePath, `${existing}${separator}${managedBlock}\n`, 'utf8');
570
+ }
571
+ return { status: 'updated', file: '.gitignore', note: 'appended guardex-managed entries' };
572
+ }
573
+
574
+ function configureHooks(repoRoot, dryRun) {
575
+ if (dryRun) {
576
+ return { status: 'would-set', key: 'core.hooksPath', value: '.githooks' };
577
+ }
578
+
579
+ const result = run('git', ['-C', repoRoot, 'config', 'core.hooksPath', '.githooks']);
580
+ if (result.status !== 0) {
581
+ throw new Error(`Failed to set git hooksPath: ${(result.stderr || '').trim()}`);
582
+ }
583
+
584
+ return { status: 'set', key: 'core.hooksPath', value: '.githooks' };
585
+ }
586
+
587
+ function parseCommonArgs(rawArgs, defaults) {
588
+ const options = { ...defaults };
589
+
590
+ for (let index = 0; index < rawArgs.length; index += 1) {
591
+ const arg = rawArgs[index];
592
+ if (arg === '--target') {
593
+ options.target = rawArgs[index + 1];
594
+ index += 1;
595
+ continue;
596
+ }
597
+ if (arg === '--dry-run') {
598
+ options.dryRun = true;
599
+ continue;
600
+ }
601
+ if (arg === '--skip-agents') {
602
+ options.skipAgents = true;
603
+ continue;
604
+ }
605
+ if (arg === '--skip-package-json') {
606
+ options.skipPackageJson = true;
607
+ continue;
608
+ }
609
+ if (arg === '--force') {
610
+ options.force = true;
611
+ continue;
612
+ }
613
+ if (arg === '--keep-stale-locks') {
614
+ options.dropStaleLocks = false;
615
+ continue;
616
+ }
617
+ if (arg === '--json') {
618
+ options.json = true;
619
+ continue;
620
+ }
621
+ if (arg === '--yes-global-install') {
622
+ options.yesGlobalInstall = true;
623
+ continue;
624
+ }
625
+ if (arg === '--no-global-install') {
626
+ options.noGlobalInstall = true;
627
+ continue;
628
+ }
629
+ if (arg === '--no-gitignore') {
630
+ options.skipGitignore = true;
631
+ continue;
632
+ }
633
+
634
+ throw new Error(`Unknown option: ${arg}`);
635
+ }
636
+
637
+ if (!options.target) {
638
+ throw new Error('--target requires a path value');
639
+ }
640
+
641
+ return options;
642
+ }
643
+
644
+ function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
645
+ const remaining = [];
646
+ let target = defaultTarget;
647
+
648
+ for (let index = 0; index < rawArgs.length; index += 1) {
649
+ const arg = rawArgs[index];
650
+ if (arg === '--target') {
651
+ const next = rawArgs[index + 1];
652
+ if (!next) {
653
+ throw new Error('--target requires a path value');
654
+ }
655
+ target = next;
656
+ index += 1;
657
+ continue;
658
+ }
659
+ remaining.push(arg);
660
+ }
661
+
662
+ return { target, args: remaining };
663
+ }
664
+
665
+ function parseReportArgs(rawArgs) {
666
+ const options = {
667
+ target: process.cwd(),
668
+ subcommand: '',
669
+ repo: '',
670
+ scorecardJson: '',
671
+ outputDir: '',
672
+ date: '',
673
+ dryRun: false,
674
+ json: false,
675
+ };
676
+
677
+ for (let index = 0; index < rawArgs.length; index += 1) {
678
+ const arg = rawArgs[index];
679
+ if (arg === '--target') {
680
+ const next = rawArgs[index + 1];
681
+ if (!next) throw new Error('--target requires a path value');
682
+ options.target = next;
683
+ index += 1;
684
+ continue;
685
+ }
686
+ if (arg === '--repo') {
687
+ const next = rawArgs[index + 1];
688
+ if (!next) throw new Error('--repo requires a value like github.com/owner/repo');
689
+ options.repo = next;
690
+ index += 1;
691
+ continue;
692
+ }
693
+ if (arg === '--scorecard-json') {
694
+ const next = rawArgs[index + 1];
695
+ if (!next) throw new Error('--scorecard-json requires a path value');
696
+ options.scorecardJson = next;
697
+ index += 1;
698
+ continue;
699
+ }
700
+ if (arg === '--output-dir') {
701
+ const next = rawArgs[index + 1];
702
+ if (!next) throw new Error('--output-dir requires a path value');
703
+ options.outputDir = next;
704
+ index += 1;
705
+ continue;
706
+ }
707
+ if (arg === '--date') {
708
+ const next = rawArgs[index + 1];
709
+ if (!next) throw new Error('--date requires a YYYY-MM-DD value');
710
+ options.date = next;
711
+ index += 1;
712
+ continue;
713
+ }
714
+ if (arg === '--dry-run') {
715
+ options.dryRun = true;
716
+ continue;
717
+ }
718
+ if (arg === '--json') {
719
+ options.json = true;
720
+ continue;
721
+ }
722
+ if (arg.startsWith('-')) {
723
+ throw new Error(`Unknown option: ${arg}`);
724
+ }
725
+ if (!options.subcommand) {
726
+ options.subcommand = arg;
727
+ continue;
728
+ }
729
+ throw new Error(`Unexpected argument: ${arg}`);
730
+ }
731
+
732
+ return options;
733
+ }
734
+
735
+ function todayDateStamp() {
736
+ return new Date().toISOString().slice(0, 10);
737
+ }
738
+
739
+ function inferGithubRepoFromOrigin(repoRoot) {
740
+ const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url');
741
+ if (!rawOrigin) return '';
742
+
743
+ const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i);
744
+ if (!httpsMatch) return '';
745
+ const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim();
746
+ if (!slug || !slug.includes('/')) return '';
747
+ return `github.com/${slug}`;
748
+ }
749
+
750
+ function resolveScorecardRepo(repoRoot, explicitRepo) {
751
+ if (explicitRepo) {
752
+ return explicitRepo.trim();
753
+ }
754
+ const inferred = inferGithubRepoFromOrigin(repoRoot);
755
+ if (inferred) return inferred;
756
+ throw new Error(
757
+ 'Unable to infer GitHub repo from origin remote. Pass --repo github.com/<owner>/<repo>.',
758
+ );
759
+ }
760
+
761
+ function runScorecardJson(repo) {
762
+ const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true });
763
+ if (result.status !== 0) {
764
+ const details = (result.stderr || result.stdout || '').trim();
765
+ throw new Error(
766
+ `Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`,
767
+ );
768
+ }
769
+
770
+ try {
771
+ return JSON.parse(result.stdout || '{}');
772
+ } catch (error) {
773
+ throw new Error(`Unable to parse scorecard JSON output: ${error.message}`);
774
+ }
775
+ }
776
+
777
+ function readScorecardJsonFile(filePath) {
778
+ const absolute = path.resolve(filePath);
779
+ if (!fs.existsSync(absolute)) {
780
+ throw new Error(`scorecard JSON file not found: ${absolute}`);
781
+ }
782
+ try {
783
+ return JSON.parse(fs.readFileSync(absolute, 'utf8'));
784
+ } catch (error) {
785
+ throw new Error(`Unable to parse scorecard JSON file: ${error.message}`);
786
+ }
787
+ }
788
+
789
+ function normalizeScorecardChecks(payload) {
790
+ const rawChecks = Array.isArray(payload?.checks) ? payload.checks : [];
791
+ return rawChecks.map((check) => {
792
+ const name = String(check?.name || 'Unknown');
793
+ const rawScore = Number(check?.score);
794
+ const score = Number.isFinite(rawScore) ? rawScore : 0;
795
+ return {
796
+ name,
797
+ score,
798
+ risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown',
799
+ };
800
+ });
801
+ }
802
+
803
+ function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) {
804
+ const rows = checks
805
+ .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
806
+ .join('\n');
807
+
808
+ return [
809
+ '# OpenSSF Scorecard Baseline Report',
810
+ '',
811
+ `- **Repository:** \`${repo}\``,
812
+ '- **Source:** generated by `gx report scorecard`',
813
+ `- **Captured at:** ${capturedAt}`,
814
+ `- **Scorecard version:** \`${scorecardVersion}\``,
815
+ `- **Overall score:** **${score} / 10**`,
816
+ '',
817
+ '## Check breakdown',
818
+ '',
819
+ '| Check | Score | Risk |',
820
+ '|---|---:|---|',
821
+ rows || '| (none) | 0 | Unknown |',
822
+ '',
823
+ `## Report date`,
824
+ '',
825
+ `- ${reportDate}`,
826
+ '',
827
+ ].join('\n');
828
+ }
829
+
830
+ function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) {
831
+ const failing = checks.filter((item) => item.score < 10);
832
+ const failingRows = failing
833
+ .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
834
+ .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`)
835
+ .join('\n');
836
+
837
+ return [
838
+ '# OpenSSF Scorecard Remediation Plan',
839
+ '',
840
+ `Based on baseline report: \`${baselineRelativePath}\`.`,
841
+ '',
842
+ '## Failing checks',
843
+ '',
844
+ '| Check | Score | Risk |',
845
+ '|---|---:|---|',
846
+ (failingRows || '| None | 10 | N/A |'),
847
+ '',
848
+ '## Priority order',
849
+ '',
850
+ '1. Fix **High** risk checks first (especially score 0 items).',
851
+ '2. Then close **Medium** risk checks with score < 10.',
852
+ '3. Finally address **Low** risk ecosystem/process checks.',
853
+ '',
854
+ '## Verification loop',
855
+ '',
856
+ '1. Run scorecard again.',
857
+ '2. Re-generate baseline + remediation files.',
858
+ '3. Compare score deltas and track improved checks.',
859
+ '',
860
+ ].join('\n');
861
+ }
862
+
863
+ function parseBranchList(rawValue) {
864
+ return String(rawValue || '')
865
+ .split(/[\s,]+/)
866
+ .map((item) => item.trim())
867
+ .filter(Boolean);
868
+ }
869
+
870
+ function uniquePreserveOrder(items) {
871
+ const seen = new Set();
872
+ const result = [];
873
+ for (const item of items) {
874
+ if (seen.has(item)) continue;
875
+ seen.add(item);
876
+ result.push(item);
877
+ }
878
+ return result;
879
+ }
880
+
881
+ function readProtectedBranches(repoRoot) {
882
+ const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
883
+ if (result.status !== 0) {
884
+ return [...DEFAULT_PROTECTED_BRANCHES];
885
+ }
886
+
887
+ const parsed = uniquePreserveOrder(parseBranchList(result.stdout.trim()));
888
+ if (parsed.length === 0) {
889
+ return [...DEFAULT_PROTECTED_BRANCHES];
890
+ }
891
+ return parsed;
892
+ }
893
+
894
+ function writeProtectedBranches(repoRoot, branches) {
895
+ if (branches.length === 0) {
896
+ gitRun(repoRoot, ['config', '--unset-all', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
897
+ return;
898
+ }
899
+ gitRun(repoRoot, ['config', GIT_PROTECTED_BRANCHES_KEY, branches.join(' ')]);
900
+ }
901
+
902
+ function readGitConfig(repoRoot, key) {
903
+ const result = gitRun(repoRoot, ['config', '--get', key], { allowFailure: true });
904
+ if (result.status !== 0) {
905
+ return '';
906
+ }
907
+ return (result.stdout || '').trim();
908
+ }
909
+
910
+ function resolveBaseBranch(repoRoot, explicitBase) {
911
+ if (explicitBase) {
912
+ return explicitBase;
913
+ }
914
+ const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
915
+ return configured || DEFAULT_BASE_BRANCH;
916
+ }
917
+
918
+ function resolveSyncStrategy(repoRoot, explicitStrategy) {
919
+ const strategy = (explicitStrategy || readGitConfig(repoRoot, GIT_SYNC_STRATEGY_KEY) || DEFAULT_SYNC_STRATEGY)
920
+ .trim()
921
+ .toLowerCase();
922
+ if (strategy !== 'rebase' && strategy !== 'merge') {
923
+ throw new Error(`Invalid sync strategy '${strategy}' (expected: rebase or merge)`);
924
+ }
925
+ return strategy;
926
+ }
927
+
928
+ function currentBranchName(repoRoot) {
929
+ const result = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
930
+ if (result.status !== 0) {
931
+ throw new Error('Unable to detect current branch');
932
+ }
933
+ const branch = (result.stdout || '').trim();
934
+ if (!branch) {
935
+ throw new Error('Detached HEAD is not supported for sync operations');
936
+ }
937
+ return branch;
938
+ }
939
+
940
+ function workingTreeIsDirty(repoRoot) {
941
+ const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
942
+ if (result.status !== 0) {
943
+ throw new Error('Unable to inspect git working tree status');
944
+ }
945
+ const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
946
+ const significant = lines.filter((line) => {
947
+ const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
948
+ if (!pathPart) return false;
949
+ if (pathPart === LOCK_FILE_RELATIVE) return false;
950
+ if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) return false;
951
+ if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) return false;
952
+ return true;
953
+ });
954
+ return significant.length > 0;
955
+ }
956
+
957
+ function ensureOriginBaseRef(repoRoot, baseBranch) {
958
+ const fetch = gitRun(repoRoot, ['fetch', 'origin', baseBranch, '--quiet'], { allowFailure: true });
959
+ if (fetch.status !== 0) {
960
+ throw new Error(
961
+ `Unable to fetch origin/${baseBranch}. Ensure remote 'origin' exists and branch '${baseBranch}' is available.`,
962
+ );
963
+ }
964
+ const hasRemoteBase = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${baseBranch}`], {
965
+ allowFailure: true,
966
+ });
967
+ if (hasRemoteBase.status !== 0) {
968
+ throw new Error(`Remote base branch not found: origin/${baseBranch}`);
969
+ }
970
+ }
971
+
972
+ function aheadBehind(repoRoot, branchRef, baseRef) {
973
+ const result = gitRun(repoRoot, ['rev-list', '--left-right', '--count', `${branchRef}...${baseRef}`], {
974
+ allowFailure: true,
975
+ });
976
+ if (result.status !== 0) {
977
+ throw new Error(`Unable to compute ahead/behind for ${branchRef} vs ${baseRef}`);
978
+ }
979
+ const parts = (result.stdout || '').trim().split(/\s+/).filter(Boolean);
980
+ const ahead = Number.parseInt(parts[0] || '0', 10);
981
+ const behind = Number.parseInt(parts[1] || '0', 10);
982
+ return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 };
983
+ }
984
+
985
+ function lockRegistryStatus(repoRoot) {
986
+ const result = gitRun(repoRoot, ['status', '--porcelain', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
987
+ if (result.status !== 0) {
988
+ return { dirty: false, untracked: false };
989
+ }
990
+ const lines = (result.stdout || '').split('\n').filter((line) => line.length > 0);
991
+ if (lines.length === 0) {
992
+ return { dirty: false, untracked: false };
993
+ }
994
+ const untracked = lines.some((line) => line.startsWith('??'));
995
+ return { dirty: true, untracked };
996
+ }
997
+
998
+ function parseSyncArgs(rawArgs) {
999
+ const options = {
1000
+ target: process.cwd(),
1001
+ check: false,
1002
+ base: '',
1003
+ strategy: '',
1004
+ ffOnly: false,
1005
+ dryRun: false,
1006
+ json: false,
1007
+ allAgentBranches: false,
1008
+ allowNonAgent: false,
1009
+ allowDirty: false,
1010
+ };
1011
+
1012
+ for (let index = 0; index < rawArgs.length; index += 1) {
1013
+ const arg = rawArgs[index];
1014
+ if (arg === '--target') {
1015
+ const next = rawArgs[index + 1];
1016
+ if (!next) {
1017
+ throw new Error('--target requires a path value');
1018
+ }
1019
+ options.target = next;
1020
+ index += 1;
1021
+ continue;
1022
+ }
1023
+ if (arg === '--base') {
1024
+ const next = rawArgs[index + 1];
1025
+ if (!next) {
1026
+ throw new Error('--base requires a branch value');
1027
+ }
1028
+ options.base = next;
1029
+ index += 1;
1030
+ continue;
1031
+ }
1032
+ if (arg === '--strategy') {
1033
+ const next = rawArgs[index + 1];
1034
+ if (!next) {
1035
+ throw new Error('--strategy requires a value (rebase|merge)');
1036
+ }
1037
+ options.strategy = next;
1038
+ index += 1;
1039
+ continue;
1040
+ }
1041
+ if (arg === '--check') {
1042
+ options.check = true;
1043
+ continue;
1044
+ }
1045
+ if (arg === '--ff-only') {
1046
+ options.ffOnly = true;
1047
+ continue;
1048
+ }
1049
+ if (arg === '--dry-run') {
1050
+ options.dryRun = true;
1051
+ continue;
1052
+ }
1053
+ if (arg === '--json') {
1054
+ options.json = true;
1055
+ continue;
1056
+ }
1057
+ if (arg === '--all-agent-branches') {
1058
+ options.allAgentBranches = true;
1059
+ continue;
1060
+ }
1061
+ if (arg === '--allow-non-agent') {
1062
+ options.allowNonAgent = true;
1063
+ continue;
1064
+ }
1065
+ if (arg === '--allow-dirty') {
1066
+ options.allowDirty = true;
1067
+ continue;
1068
+ }
1069
+ throw new Error(`Unknown option: ${arg}`);
1070
+ }
1071
+
1072
+ if (!options.target) {
1073
+ throw new Error('--target requires a path value');
1074
+ }
1075
+
1076
+ return options;
1077
+ }
1078
+
1079
+ function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
1080
+ if (strategy === 'rebase') {
1081
+ if (ffOnly) {
1082
+ throw new Error('--ff-only is only supported with --strategy merge');
1083
+ }
1084
+ const rebased = run('git', ['-C', repoRoot, 'rebase', baseRef], { stdio: 'pipe' });
1085
+ if (rebased.status !== 0) {
1086
+ const details = (rebased.stderr || rebased.stdout || '').trim();
1087
+ const gitDir = path.join(repoRoot, '.git');
1088
+ const rebaseActive = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
1089
+ const help = rebaseActive
1090
+ ? '\nResolve conflicts, then run: git rebase --continue\nOr abort: git rebase --abort'
1091
+ : '';
1092
+ throw new Error(`Sync failed during rebase onto ${baseRef}.${details ? `\n${details}` : ''}${help}`);
1093
+ }
1094
+ return;
1095
+ }
1096
+
1097
+ const mergeArgs = ['-C', repoRoot, 'merge', '--no-edit'];
1098
+ if (ffOnly) {
1099
+ mergeArgs.push('--ff-only');
1100
+ }
1101
+ mergeArgs.push(baseRef);
1102
+ const merged = run('git', mergeArgs, { stdio: 'pipe' });
1103
+ if (merged.status !== 0) {
1104
+ const details = (merged.stderr || merged.stdout || '').trim();
1105
+ const gitDir = path.join(repoRoot, '.git');
1106
+ const mergeActive = fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
1107
+ const help = mergeActive ? '\nResolve conflicts, then run: git commit\nOr abort: git merge --abort' : '';
1108
+ throw new Error(`Sync failed during merge from ${baseRef}.${details ? `\n${details}` : ''}${help}`);
1109
+ }
1110
+ }
1111
+
1112
+ function isInteractiveTerminal() {
1113
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1114
+ }
1115
+
1116
+ function readSingleLineFromStdin() {
1117
+ let input = '';
1118
+ const buffer = Buffer.alloc(1);
1119
+
1120
+ while (true) {
1121
+ let bytesRead = 0;
1122
+ try {
1123
+ bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 1);
1124
+ } catch {
1125
+ return input;
1126
+ }
1127
+
1128
+ if (bytesRead === 0) {
1129
+ return input;
1130
+ }
1131
+
1132
+ const char = buffer.toString('utf8', 0, bytesRead);
1133
+ if (char === '\n' || char === '\r') {
1134
+ return input;
1135
+ }
1136
+ input += char;
1137
+ }
1138
+ }
1139
+
1140
+ function promptYesNo(question, defaultYes = true) {
1141
+ const hint = defaultYes ? '[Y/n]' : '[y/N]';
1142
+ while (true) {
1143
+ process.stdout.write(`${question} ${hint} `);
1144
+ const answer = readSingleLineFromStdin().trim().toLowerCase();
1145
+
1146
+ if (!answer) {
1147
+ return defaultYes;
1148
+ }
1149
+ if (answer === 'y' || answer === 'yes') {
1150
+ return true;
1151
+ }
1152
+ if (answer === 'n' || answer === 'no') {
1153
+ return false;
1154
+ }
1155
+ process.stdout.write('Please answer with y or n.\n');
1156
+ }
1157
+ }
1158
+
1159
+ function envFlagEnabled(name) {
1160
+ const raw = process.env[name];
1161
+ if (raw == null) return false;
1162
+ return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
1163
+ }
1164
+
1165
+ function parseAutoApproval(name) {
1166
+ const raw = process.env[name];
1167
+ if (raw == null) return null;
1168
+ const normalized = String(raw).trim().toLowerCase();
1169
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
1170
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
1171
+ return null;
1172
+ }
1173
+
1174
+ function parseVersionString(version) {
1175
+ const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
1176
+ if (!match) return null;
1177
+ return [
1178
+ Number.parseInt(match[1], 10),
1179
+ Number.parseInt(match[2], 10),
1180
+ Number.parseInt(match[3], 10),
1181
+ ];
1182
+ }
1183
+
1184
+ function isNewerVersion(latest, current) {
1185
+ const latestParts = parseVersionString(latest);
1186
+ const currentParts = parseVersionString(current);
1187
+
1188
+ if (!latestParts || !currentParts) {
1189
+ return String(latest || '').trim() !== String(current || '').trim();
1190
+ }
1191
+
1192
+ for (let index = 0; index < latestParts.length; index += 1) {
1193
+ if (latestParts[index] > currentParts[index]) return true;
1194
+ if (latestParts[index] < currentParts[index]) return false;
1195
+ }
1196
+ return false;
1197
+ }
1198
+
1199
+ function parseNpmVersionOutput(stdout) {
1200
+ const trimmed = String(stdout || '').trim();
1201
+ if (!trimmed) return '';
1202
+
1203
+ try {
1204
+ const parsed = JSON.parse(trimmed);
1205
+ if (Array.isArray(parsed)) {
1206
+ return String(parsed[parsed.length - 1] || '').trim();
1207
+ }
1208
+ return String(parsed || '').trim();
1209
+ } catch {
1210
+ const firstLine = trimmed.split('\n').map((line) => line.trim()).find(Boolean);
1211
+ return firstLine || '';
1212
+ }
1213
+ }
1214
+
1215
+ function checkForMusafetyUpdate() {
1216
+ if (envFlagEnabled('MUSAFETY_SKIP_UPDATE_CHECK')) {
1217
+ return { checked: false, reason: 'disabled' };
1218
+ }
1219
+
1220
+ const forceCheck = envFlagEnabled('MUSAFETY_FORCE_UPDATE_CHECK');
1221
+ if (!forceCheck && !isInteractiveTerminal()) {
1222
+ return { checked: false, reason: 'non-interactive' };
1223
+ }
1224
+
1225
+ const result = run(NPM_BIN, ['view', packageJson.name, 'version', '--json'], { timeout: 5000 });
1226
+ if (result.status !== 0) {
1227
+ return { checked: false, reason: 'lookup-failed' };
1228
+ }
1229
+
1230
+ const latest = parseNpmVersionOutput(result.stdout);
1231
+ if (!latest) {
1232
+ return { checked: false, reason: 'invalid-latest-version' };
1233
+ }
1234
+
1235
+ return {
1236
+ checked: true,
1237
+ current: packageJson.version,
1238
+ latest,
1239
+ updateAvailable: isNewerVersion(latest, packageJson.version),
1240
+ };
1241
+ }
1242
+
1243
+ function printUpdateAvailableBanner(current, latest) {
1244
+ const title = colorize('UPDATE AVAILABLE', '1;33');
1245
+ console.log(`[${TOOL_NAME}] ${title}`);
1246
+ console.log(`[${TOOL_NAME}] Current: ${current}`);
1247
+ console.log(`[${TOOL_NAME}] Latest : ${latest}`);
1248
+ console.log(`[${TOOL_NAME}] Command: ${NPM_BIN} i -g ${packageJson.name}@latest`);
1249
+ }
1250
+
1251
+ function maybeSelfUpdateBeforeStatus() {
1252
+ const check = checkForMusafetyUpdate();
1253
+ if (!check.checked || !check.updateAvailable) {
1254
+ return;
1255
+ }
1256
+
1257
+ printUpdateAvailableBanner(check.current, check.latest);
1258
+
1259
+ const autoApproval = parseAutoApproval('MUSAFETY_AUTO_UPDATE_APPROVAL');
1260
+ const interactive = isInteractiveTerminal();
1261
+
1262
+ if (!interactive && autoApproval == null) {
1263
+ console.log(`[${TOOL_NAME}] Non-interactive shell; skipping auto-update prompt.`);
1264
+ return;
1265
+ }
1266
+
1267
+ const shouldUpdate = interactive
1268
+ ? promptYesNo(
1269
+ `Update now? (${NPM_BIN} i -g ${packageJson.name}@latest)`,
1270
+ false,
1271
+ )
1272
+ : autoApproval;
1273
+
1274
+ if (!shouldUpdate) {
1275
+ console.log(`[${TOOL_NAME}] Skipped update.`);
1276
+ return;
1277
+ }
1278
+
1279
+ const installResult = run(NPM_BIN, ['i', '-g', `${packageJson.name}@latest`], { stdio: 'inherit' });
1280
+ if (installResult.status !== 0) {
1281
+ console.log(`[${TOOL_NAME}] ⚠️ Update failed. You can retry manually.`);
1282
+ return;
1283
+ }
1284
+
1285
+ console.log(`[${TOOL_NAME}] ✅ Updated to latest published version.`);
1286
+ }
1287
+
1288
+ function promptYesNoStrict(question) {
1289
+ while (true) {
1290
+ process.stdout.write(`${question} [y/n] `);
1291
+ const answer = readSingleLineFromStdin().trim().toLowerCase();
1292
+
1293
+ if (answer === 'y' || answer === 'yes') {
1294
+ process.stdout.write('\n');
1295
+ return true;
1296
+ }
1297
+ if (answer === 'n' || answer === 'no') {
1298
+ process.stdout.write('\n');
1299
+ return false;
1300
+ }
1301
+
1302
+ process.stdout.write('Please answer with y or n.\n');
1303
+ }
1304
+ }
1305
+
1306
+ function resolveGlobalInstallApproval(options) {
1307
+ if (options.yesGlobalInstall && options.noGlobalInstall) {
1308
+ throw new Error('Cannot use both --yes-global-install and --no-global-install');
1309
+ }
1310
+
1311
+ if (options.yesGlobalInstall) {
1312
+ return { approved: true, source: 'flag' };
1313
+ }
1314
+
1315
+ if (options.noGlobalInstall) {
1316
+ return { approved: false, source: 'flag' };
1317
+ }
1318
+
1319
+ if (!isInteractiveTerminal()) {
1320
+ return { approved: false, source: 'non-interactive-default' };
1321
+ }
1322
+ return { approved: true, source: 'prompt' };
1323
+ }
1324
+
1325
+ function detectGlobalToolchainPackages() {
1326
+ const result = run(NPM_BIN, ['list', '-g', '--depth=0', '--json']);
1327
+ if (result.status !== 0) {
1328
+ const stderr = (result.stderr || '').trim();
1329
+ return {
1330
+ ok: false,
1331
+ error: stderr || 'Unable to detect globally installed npm packages',
1332
+ };
1333
+ }
1334
+
1335
+ let parsed;
1336
+ try {
1337
+ parsed = JSON.parse(result.stdout || '{}');
1338
+ } catch (error) {
1339
+ return {
1340
+ ok: false,
1341
+ error: `Failed to parse npm list output: ${error.message}`,
1342
+ };
1343
+ }
1344
+
1345
+ const dependencyMap = parsed && parsed.dependencies && typeof parsed.dependencies === 'object'
1346
+ ? parsed.dependencies
1347
+ : {};
1348
+ const installedSet = new Set(Object.keys(dependencyMap));
1349
+
1350
+ const installed = [];
1351
+ const missing = [];
1352
+ for (const pkg of GLOBAL_TOOLCHAIN_PACKAGES) {
1353
+ if (installedSet.has(pkg)) {
1354
+ installed.push(pkg);
1355
+ } else {
1356
+ missing.push(pkg);
1357
+ }
1358
+ }
1359
+
1360
+ return { ok: true, installed, missing };
1361
+ }
1362
+
1363
+ function askGlobalInstallForMissing(options, missingPackages) {
1364
+ const approval = resolveGlobalInstallApproval(options);
1365
+ if (!approval.approved) {
1366
+ return approval;
1367
+ }
1368
+
1369
+ if (approval.source === 'prompt') {
1370
+ const approved = promptYesNoStrict(
1371
+ `Install missing global tools now? (npm i -g ${missingPackages.join(' ')})`,
1372
+ );
1373
+ return { approved, source: 'prompt' };
1374
+ }
1375
+
1376
+ return approval;
1377
+ }
1378
+
1379
+ function installGlobalToolchain(options) {
1380
+ if (options.dryRun) {
1381
+ return { status: 'dry-run-skip' };
1382
+ }
1383
+
1384
+ const detection = detectGlobalToolchainPackages();
1385
+ if (!detection.ok) {
1386
+ console.log(`[${TOOL_NAME}] ⚠️ Could not detect global packages: ${detection.error}`);
1387
+ } else {
1388
+ if (detection.installed.length > 0) {
1389
+ console.log(`[${TOOL_NAME}] Already installed globally: ${detection.installed.join(', ')}`);
1390
+ }
1391
+ if (detection.missing.length === 0) {
1392
+ return { status: 'already-installed' };
1393
+ }
1394
+ }
1395
+
1396
+ const missingPackages = detection.ok ? detection.missing : [...GLOBAL_TOOLCHAIN_PACKAGES];
1397
+ const approval = askGlobalInstallForMissing(options, missingPackages);
1398
+ if (!approval.approved) {
1399
+ return { status: 'skipped', reason: approval.source };
1400
+ }
1401
+
1402
+ console.log(
1403
+ `[${TOOL_NAME}] Installing global toolchain: npm i -g ${missingPackages.join(' ')}`,
1404
+ );
1405
+ const result = run(NPM_BIN, ['i', '-g', ...missingPackages], { stdio: 'inherit' });
1406
+ if (result.status !== 0) {
1407
+ const stderr = (result.stderr || '').trim();
1408
+ return {
1409
+ status: 'failed',
1410
+ reason: stderr || 'npm global install failed',
1411
+ };
1412
+ }
1413
+
1414
+ return { status: 'installed', packages: missingPackages };
1415
+ }
1416
+
1417
+ function gitRefExists(repoRoot, refName) {
1418
+ return gitRun(repoRoot, ['show-ref', '--verify', '--quiet', refName], { allowFailure: true }).status === 0;
1419
+ }
1420
+
1421
+ function findStaleLockPaths(repoRoot, locks) {
1422
+ const stale = [];
1423
+
1424
+ for (const [filePath, rawEntry] of Object.entries(locks)) {
1425
+ const entry = rawEntry && typeof rawEntry === 'object' ? rawEntry : {};
1426
+ const ownerBranch = String(entry.branch || '');
1427
+
1428
+ const hasOwner = ownerBranch.length > 0;
1429
+ const localRef = hasOwner ? `refs/heads/${ownerBranch}` : null;
1430
+ const remoteRef = hasOwner ? `refs/remotes/origin/${ownerBranch}` : null;
1431
+ const branchExists = hasOwner
1432
+ ? gitRefExists(repoRoot, localRef) || gitRefExists(repoRoot, remoteRef)
1433
+ : false;
1434
+
1435
+ const pathExists = fs.existsSync(path.join(repoRoot, filePath));
1436
+
1437
+ if (!hasOwner || !branchExists || !pathExists) {
1438
+ stale.push(filePath);
1439
+ }
1440
+ }
1441
+
1442
+ return stale;
1443
+ }
1444
+
1445
+ function runInstallInternal(options) {
1446
+ const repoRoot = resolveRepoRoot(options.target);
1447
+ const operations = [];
1448
+
1449
+ for (const templateFile of TEMPLATE_FILES) {
1450
+ operations.push(copyTemplateFile(repoRoot, templateFile, Boolean(options.force), Boolean(options.dryRun)));
1451
+ }
1452
+
1453
+ operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
1454
+ if (!options.skipGitignore) {
1455
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
1456
+ }
1457
+
1458
+ if (!options.skipPackageJson) {
1459
+ operations.push(ensurePackageScripts(repoRoot, Boolean(options.dryRun)));
1460
+ }
1461
+
1462
+ if (!options.skipAgents) {
1463
+ operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun)));
1464
+ }
1465
+
1466
+ const hookResult = configureHooks(repoRoot, Boolean(options.dryRun));
1467
+
1468
+ return { repoRoot, operations, hookResult };
1469
+ }
1470
+
1471
+ function runFixInternal(options) {
1472
+ const repoRoot = resolveRepoRoot(options.target);
1473
+ const operations = [];
1474
+
1475
+ for (const templateFile of TEMPLATE_FILES) {
1476
+ operations.push(ensureTemplateFilePresent(repoRoot, templateFile, Boolean(options.dryRun)));
1477
+ }
1478
+
1479
+ operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
1480
+ if (!options.skipGitignore) {
1481
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
1482
+ }
1483
+
1484
+ const lockState = lockStateOrError(repoRoot);
1485
+ if (!lockState.ok) {
1486
+ if (!options.dryRun) {
1487
+ writeLockState(repoRoot, { locks: {} }, false);
1488
+ }
1489
+ operations.push({
1490
+ status: options.dryRun ? 'would-reset' : 'reset',
1491
+ file: LOCK_FILE_RELATIVE,
1492
+ note: 'invalid lock state reset to empty',
1493
+ });
1494
+ } else {
1495
+ const staleLockPaths = options.dropStaleLocks ? findStaleLockPaths(repoRoot, lockState.locks) : [];
1496
+ if (staleLockPaths.length > 0) {
1497
+ const updated = { ...lockState.raw, locks: { ...lockState.locks } };
1498
+ for (const filePath of staleLockPaths) {
1499
+ delete updated.locks[filePath];
1500
+ }
1501
+ writeLockState(repoRoot, updated, Boolean(options.dryRun));
1502
+ operations.push({
1503
+ status: options.dryRun ? 'would-prune' : 'pruned',
1504
+ file: LOCK_FILE_RELATIVE,
1505
+ note: `removed ${staleLockPaths.length} stale lock(s)`,
1506
+ });
1507
+ }
1508
+ }
1509
+
1510
+ if (!options.skipPackageJson) {
1511
+ operations.push(ensurePackageScripts(repoRoot, Boolean(options.dryRun)));
1512
+ }
1513
+
1514
+ if (!options.skipAgents) {
1515
+ operations.push(ensureAgentsSnippet(repoRoot, Boolean(options.dryRun)));
1516
+ }
1517
+
1518
+ const hookResult = configureHooks(repoRoot, Boolean(options.dryRun));
1519
+
1520
+ return { repoRoot, operations, hookResult };
1521
+ }
1522
+
1523
+ function runScanInternal(options) {
1524
+ const repoRoot = resolveRepoRoot(options.target);
1525
+ const findings = [];
1526
+
1527
+ const requiredPaths = [
1528
+ ...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
1529
+ LOCK_FILE_RELATIVE,
1530
+ ];
1531
+
1532
+ for (const relativePath of requiredPaths) {
1533
+ const absolutePath = path.join(repoRoot, relativePath);
1534
+ if (!fs.existsSync(absolutePath)) {
1535
+ findings.push({
1536
+ level: 'error',
1537
+ code: 'missing-managed-file',
1538
+ path: relativePath,
1539
+ message: `Missing managed workflow file: ${relativePath}`,
1540
+ });
1541
+ }
1542
+ }
1543
+
1544
+ const hooksPathResult = gitRun(repoRoot, ['config', '--get', 'core.hooksPath'], { allowFailure: true });
1545
+ const hooksPath = hooksPathResult.status === 0 ? hooksPathResult.stdout.trim() : '';
1546
+ if (hooksPath !== '.githooks') {
1547
+ findings.push({
1548
+ level: 'warn',
1549
+ code: 'hooks-path-mismatch',
1550
+ message: `git core.hooksPath is '${hooksPath || '(unset)'}' (expected '.githooks')`,
1551
+ });
1552
+ }
1553
+
1554
+ const lockState = lockStateOrError(repoRoot);
1555
+ if (!lockState.ok) {
1556
+ findings.push({
1557
+ level: 'error',
1558
+ code: 'lock-state-invalid',
1559
+ message: lockState.error,
1560
+ });
1561
+ } else {
1562
+ for (const [filePath, rawEntry] of Object.entries(lockState.locks)) {
1563
+ const entry = rawEntry && typeof rawEntry === 'object' ? rawEntry : {};
1564
+ const ownerBranch = String(entry.branch || '');
1565
+ const allowDelete = Boolean(entry.allow_delete);
1566
+
1567
+ if (!ownerBranch) {
1568
+ findings.push({
1569
+ level: 'warn',
1570
+ code: 'lock-missing-owner',
1571
+ path: filePath,
1572
+ message: `Lock entry has no owner branch: ${filePath}`,
1573
+ });
1574
+ }
1575
+
1576
+ const absolutePath = path.join(repoRoot, filePath);
1577
+ if (!fs.existsSync(absolutePath)) {
1578
+ findings.push({
1579
+ level: 'warn',
1580
+ code: 'lock-target-missing',
1581
+ path: filePath,
1582
+ message: `Locked path is missing from disk: ${filePath}`,
1583
+ });
1584
+ }
1585
+
1586
+ if (ownerBranch) {
1587
+ const localRef = `refs/heads/${ownerBranch}`;
1588
+ const remoteRef = `refs/remotes/origin/${ownerBranch}`;
1589
+ if (!gitRefExists(repoRoot, localRef) && !gitRefExists(repoRoot, remoteRef)) {
1590
+ findings.push({
1591
+ level: 'warn',
1592
+ code: 'stale-branch-lock',
1593
+ path: filePath,
1594
+ message: `Lock owner branch not found locally/remotely: ${ownerBranch} (${filePath})`,
1595
+ });
1596
+ }
1597
+ }
1598
+
1599
+ if (allowDelete && CRITICAL_GUARDRAIL_PATHS.has(filePath)) {
1600
+ findings.push({
1601
+ level: 'error',
1602
+ code: 'guardrail-delete-approved',
1603
+ path: filePath,
1604
+ message: `Critical guardrail file is delete-approved: ${filePath}`,
1605
+ });
1606
+ }
1607
+ }
1608
+ }
1609
+
1610
+ const errors = findings.filter((item) => item.level === 'error');
1611
+ const warnings = findings.filter((item) => item.level === 'warn');
1612
+
1613
+ const currentBranchResult = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
1614
+ const branch = currentBranchResult.status === 0 ? currentBranchResult.stdout.trim() : '(unknown)';
1615
+
1616
+ return {
1617
+ repoRoot,
1618
+ branch,
1619
+ findings,
1620
+ errors: errors.length,
1621
+ warnings: warnings.length,
1622
+ };
1623
+ }
1624
+
1625
+ function printOperations(title, payload, dryRun = false) {
1626
+ console.log(`[${TOOL_NAME}] ${title}: ${payload.repoRoot}`);
1627
+ for (const operation of payload.operations) {
1628
+ const note = operation.note ? ` (${operation.note})` : '';
1629
+ console.log(` - ${operation.status.padEnd(12)} ${operation.file}${note}`);
1630
+ }
1631
+ console.log(
1632
+ ` - hooksPath ${payload.hookResult.status} ${payload.hookResult.key}=${payload.hookResult.value}`,
1633
+ );
1634
+
1635
+ if (dryRun) {
1636
+ console.log(`[${TOOL_NAME}] Dry run complete. No files were modified.`);
1637
+ }
1638
+ }
1639
+
1640
+ function printScanResult(scan, json = false) {
1641
+ if (json) {
1642
+ process.stdout.write(
1643
+ JSON.stringify(
1644
+ {
1645
+ repoRoot: scan.repoRoot,
1646
+ branch: scan.branch,
1647
+ errors: scan.errors,
1648
+ warnings: scan.warnings,
1649
+ findings: scan.findings,
1650
+ },
1651
+ null,
1652
+ 2,
1653
+ ) + '\n',
1654
+ );
1655
+ return;
1656
+ }
1657
+
1658
+ console.log(`[${TOOL_NAME}] Scan target: ${scan.repoRoot}`);
1659
+ console.log(`[${TOOL_NAME}] Branch: ${scan.branch}`);
1660
+
1661
+ if (scan.findings.length === 0) {
1662
+ console.log(`[${TOOL_NAME}] ✅ No safety issues detected.`);
1663
+ return;
1664
+ }
1665
+
1666
+ for (const item of scan.findings) {
1667
+ const target = item.path ? ` (${item.path})` : '';
1668
+ console.log(`[${item.level.toUpperCase()}] ${item.code}${target}: ${item.message}`);
1669
+ }
1670
+ console.log(`[${TOOL_NAME}] Summary: ${scan.errors} error(s), ${scan.warnings} warning(s).`);
1671
+ }
1672
+
1673
+ function setExitCodeFromScan(scan) {
1674
+ if (scan.errors > 0) {
1675
+ process.exitCode = 2;
1676
+ return;
1677
+ }
1678
+ if (scan.warnings > 0) {
1679
+ process.exitCode = 1;
1680
+ return;
1681
+ }
1682
+ process.exitCode = 0;
1683
+ }
1684
+
1685
+ function status(rawArgs) {
1686
+ const options = parseCommonArgs(rawArgs, {
1687
+ target: process.cwd(),
1688
+ json: false,
1689
+ });
1690
+
1691
+ const toolchain = detectGlobalToolchainPackages();
1692
+ const services = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
1693
+ if (!toolchain.ok) {
1694
+ return { name: pkg, status: 'unknown' };
1695
+ }
1696
+ return {
1697
+ name: pkg,
1698
+ status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
1699
+ };
1700
+ });
1701
+
1702
+ const targetPath = path.resolve(options.target);
1703
+ const inGitRepo = isGitRepo(targetPath);
1704
+ const scanResult = inGitRepo ? runScanInternal({ target: targetPath, json: false }) : null;
1705
+ const repoServiceStatus = scanResult
1706
+ ? (scanResult.errors === 0 && scanResult.warnings === 0 ? 'active' : 'degraded')
1707
+ : 'inactive';
1708
+
1709
+ const payload = {
1710
+ cli: {
1711
+ name: packageJson.name,
1712
+ version: packageJson.version,
1713
+ runtime: runtimeVersion(),
1714
+ },
1715
+ services,
1716
+ repo: {
1717
+ target: targetPath,
1718
+ inGitRepo,
1719
+ serviceStatus: repoServiceStatus,
1720
+ scan: scanResult
1721
+ ? {
1722
+ repoRoot: scanResult.repoRoot,
1723
+ branch: scanResult.branch,
1724
+ errors: scanResult.errors,
1725
+ warnings: scanResult.warnings,
1726
+ findings: scanResult.findings.length,
1727
+ }
1728
+ : null,
1729
+ },
1730
+ detectionError: toolchain.ok ? null : toolchain.error,
1731
+ };
1732
+
1733
+ if (options.json) {
1734
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1735
+ process.exitCode = 0;
1736
+ return;
1737
+ }
1738
+
1739
+ console.log(`[${TOOL_NAME}] CLI: ${payload.cli.runtime}`);
1740
+ if (!toolchain.ok) {
1741
+ console.log(`[${TOOL_NAME}] ⚠️ Could not detect global services: ${toolchain.error}`);
1742
+ }
1743
+
1744
+ console.log(`[${TOOL_NAME}] Global services:`);
1745
+ for (const service of services) {
1746
+ console.log(` - ${statusDot(service.status)} ${service.name}: ${service.status}`);
1747
+ }
1748
+
1749
+ if (!scanResult) {
1750
+ console.log(
1751
+ `[${TOOL_NAME}] Repo safety service: ${statusDot('inactive')} inactive (no git repository at target).`,
1752
+ );
1753
+ process.exitCode = 0;
1754
+ return;
1755
+ }
1756
+
1757
+ if (scanResult.errors === 0 && scanResult.warnings === 0) {
1758
+ console.log(`[${TOOL_NAME}] Repo safety service: ${statusDot('active')} active.`);
1759
+ } else {
1760
+ console.log(
1761
+ `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
1762
+ );
1763
+ console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' for detailed findings.`);
1764
+ }
1765
+ console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`);
1766
+ console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`);
1767
+ printToolLogsSummary();
1768
+
1769
+ process.exitCode = 0;
1770
+ }
1771
+
1772
+ function install(rawArgs) {
1773
+ const options = parseCommonArgs(rawArgs, {
1774
+ target: process.cwd(),
1775
+ force: false,
1776
+ skipAgents: false,
1777
+ skipPackageJson: false,
1778
+ skipGitignore: false,
1779
+ dryRun: false,
1780
+ });
1781
+
1782
+ const payload = runInstallInternal(options);
1783
+ printOperations('Install target', payload, options.dryRun);
1784
+
1785
+ if (!options.dryRun) {
1786
+ console.log(`[${TOOL_NAME}] Installed. Next step: ${TOOL_NAME} setup`);
1787
+ }
1788
+
1789
+ process.exitCode = 0;
1790
+ }
1791
+
1792
+ function fix(rawArgs) {
1793
+ const options = parseCommonArgs(rawArgs, {
1794
+ target: process.cwd(),
1795
+ dropStaleLocks: true,
1796
+ skipAgents: false,
1797
+ skipPackageJson: false,
1798
+ skipGitignore: false,
1799
+ dryRun: false,
1800
+ });
1801
+
1802
+ const payload = runFixInternal(options);
1803
+ printOperations('Fix target', payload, options.dryRun);
1804
+
1805
+ if (!options.dryRun) {
1806
+ console.log(`[${TOOL_NAME}] Repair complete. Next step: ${TOOL_NAME} scan`);
1807
+ }
1808
+
1809
+ process.exitCode = 0;
1810
+ }
1811
+
1812
+ function scan(rawArgs) {
1813
+ const options = parseCommonArgs(rawArgs, {
1814
+ target: process.cwd(),
1815
+ json: false,
1816
+ });
1817
+
1818
+ const result = runScanInternal(options);
1819
+ printScanResult(result, options.json);
1820
+ setExitCodeFromScan(result);
1821
+ }
1822
+
1823
+ function doctor(rawArgs) {
1824
+ const options = parseCommonArgs(rawArgs, {
1825
+ target: process.cwd(),
1826
+ dropStaleLocks: true,
1827
+ skipAgents: false,
1828
+ skipPackageJson: false,
1829
+ skipGitignore: false,
1830
+ dryRun: false,
1831
+ json: false,
1832
+ });
1833
+
1834
+ const fixPayload = runFixInternal(options);
1835
+ const scanResult = runScanInternal({ target: options.target, json: false });
1836
+ const musafe = scanResult.errors === 0 && scanResult.warnings === 0;
1837
+
1838
+ if (options.json) {
1839
+ process.stdout.write(
1840
+ JSON.stringify(
1841
+ {
1842
+ repoRoot: scanResult.repoRoot,
1843
+ branch: scanResult.branch,
1844
+ musafe,
1845
+ fix: {
1846
+ operations: fixPayload.operations,
1847
+ hookResult: fixPayload.hookResult,
1848
+ dryRun: Boolean(options.dryRun),
1849
+ },
1850
+ scan: {
1851
+ errors: scanResult.errors,
1852
+ warnings: scanResult.warnings,
1853
+ findings: scanResult.findings,
1854
+ },
1855
+ },
1856
+ null,
1857
+ 2,
1858
+ ) + '\n',
1859
+ );
1860
+ setExitCodeFromScan(scanResult);
1861
+ return;
1862
+ }
1863
+
1864
+ printOperations('Doctor/fix', fixPayload, options.dryRun);
1865
+ printScanResult(scanResult, false);
1866
+ if (musafe) {
1867
+ console.log(`[${TOOL_NAME}] ✅ Repo is correctly musafe.`);
1868
+ } else {
1869
+ console.log(
1870
+ `[${TOOL_NAME}] ⚠️ Repo is not fully musafe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
1871
+ );
1872
+ }
1873
+ setExitCodeFromScan(scanResult);
1874
+ }
1875
+
1876
+ function report(rawArgs) {
1877
+ const options = parseReportArgs(rawArgs);
1878
+ const subcommand = options.subcommand || 'help';
1879
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
1880
+ console.log(
1881
+ `${TOOL_NAME} report commands:\n` +
1882
+ ` ${TOOL_NAME} report scorecard [--target <path>] [--repo github.com/<owner>/<repo>] [--scorecard-json <file>] [--output-dir <path>] [--date YYYY-MM-DD] [--dry-run] [--json]\n` +
1883
+ `\n` +
1884
+ `Examples:\n` +
1885
+ ` ${TOOL_NAME} report scorecard --repo github.com/recodeecom/multiagent-safety\n` +
1886
+ ` ${TOOL_NAME} report scorecard --scorecard-json ./scorecard.json --date 2026-04-10`,
1887
+ );
1888
+ process.exitCode = 0;
1889
+ return;
1890
+ }
1891
+
1892
+ if (subcommand !== 'scorecard') {
1893
+ throw new Error(`Unknown report subcommand: ${subcommand}`);
1894
+ }
1895
+
1896
+ const repoRoot = resolveRepoRoot(options.target);
1897
+ const repo = resolveScorecardRepo(repoRoot, options.repo);
1898
+ const payload = options.scorecardJson
1899
+ ? readScorecardJsonFile(options.scorecardJson)
1900
+ : runScorecardJson(repo);
1901
+
1902
+ const reportDate = options.date || todayDateStamp();
1903
+ const outputDir = path.resolve(options.outputDir || path.join(repoRoot, 'docs', 'reports'));
1904
+ const baselinePath = path.join(outputDir, `openssf-scorecard-baseline-${reportDate}.md`);
1905
+ const remediationPath = path.join(outputDir, `openssf-scorecard-remediation-plan-${reportDate}.md`);
1906
+
1907
+ const checks = normalizeScorecardChecks(payload);
1908
+ const rawScore = Number(payload?.score);
1909
+ const score = Number.isFinite(rawScore) ? rawScore : 0;
1910
+ const capturedAt = String(payload?.date || new Date().toISOString());
1911
+ const scorecardVersion = String(payload?.scorecard?.version || payload?.version || 'unknown');
1912
+
1913
+ const baselineMarkdown = renderScorecardBaselineMarkdown({
1914
+ repo,
1915
+ score,
1916
+ checks,
1917
+ capturedAt,
1918
+ scorecardVersion,
1919
+ reportDate,
1920
+ });
1921
+
1922
+ const remediationMarkdown = renderScorecardRemediationPlanMarkdown({
1923
+ baselineRelativePath: path.relative(repoRoot, baselinePath) || path.basename(baselinePath),
1924
+ checks,
1925
+ });
1926
+
1927
+ if (!options.dryRun) {
1928
+ fs.mkdirSync(outputDir, { recursive: true });
1929
+ fs.writeFileSync(baselinePath, baselineMarkdown, 'utf8');
1930
+ fs.writeFileSync(remediationPath, remediationMarkdown, 'utf8');
1931
+ }
1932
+
1933
+ if (options.json) {
1934
+ process.stdout.write(
1935
+ JSON.stringify(
1936
+ {
1937
+ repoRoot,
1938
+ repo,
1939
+ score,
1940
+ checks: checks.length,
1941
+ outputDir,
1942
+ baselinePath,
1943
+ remediationPath,
1944
+ dryRun: Boolean(options.dryRun),
1945
+ },
1946
+ null,
1947
+ 2,
1948
+ ) + '\n',
1949
+ );
1950
+ process.exitCode = 0;
1951
+ return;
1952
+ }
1953
+
1954
+ console.log(`[${TOOL_NAME}] Report target: ${repoRoot}`);
1955
+ console.log(`[${TOOL_NAME}] Scorecard repo: ${repo}`);
1956
+ console.log(`[${TOOL_NAME}] Score: ${score}/10`);
1957
+ if (options.dryRun) {
1958
+ console.log(`[${TOOL_NAME}] Dry run report paths:`);
1959
+ } else {
1960
+ console.log(`[${TOOL_NAME}] Generated reports:`);
1961
+ }
1962
+ console.log(` - ${baselinePath}`);
1963
+ console.log(` - ${remediationPath}`);
1964
+ process.exitCode = 0;
1965
+ }
1966
+
1967
+ function setup(rawArgs) {
1968
+ const options = parseCommonArgs(rawArgs, {
1969
+ target: process.cwd(),
1970
+ force: false,
1971
+ skipAgents: false,
1972
+ skipPackageJson: false,
1973
+ skipGitignore: false,
1974
+ dryRun: false,
1975
+ yesGlobalInstall: false,
1976
+ noGlobalInstall: false,
1977
+ });
1978
+
1979
+ const globalInstallStatus = installGlobalToolchain(options);
1980
+ if (globalInstallStatus.status === 'installed') {
1981
+ console.log(
1982
+ `[${TOOL_NAME}] ✅ Global tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
1983
+ );
1984
+ } else if (globalInstallStatus.status === 'already-installed') {
1985
+ console.log(`[${TOOL_NAME}] ✅ OMX/OpenSpec global tools already installed. Skipping.`);
1986
+ } else if (globalInstallStatus.status === 'failed') {
1987
+ console.log(
1988
+ `[${TOOL_NAME}] ⚠️ Global install failed: ${globalInstallStatus.reason}\n` +
1989
+ `[${TOOL_NAME}] Continue with local safety setup. You can retry later with:\n` +
1990
+ ` ${NPM_BIN} i -g ${GLOBAL_TOOLCHAIN_PACKAGES.join(' ')}`,
1991
+ );
1992
+ } else if (globalInstallStatus.status === 'skipped' && globalInstallStatus.reason === 'non-interactive-default') {
1993
+ console.log(
1994
+ `[${TOOL_NAME}] Skipping global installs (non-interactive mode). ` +
1995
+ `Use --yes-global-install to force or run interactively for Y/N prompt.`,
1996
+ );
1997
+ }
1998
+
1999
+ const installPayload = runInstallInternal(options);
2000
+ printOperations('Setup/install', installPayload, options.dryRun);
2001
+
2002
+ const fixPayload = runFixInternal({
2003
+ target: options.target,
2004
+ dryRun: options.dryRun,
2005
+ dropStaleLocks: true,
2006
+ skipAgents: options.skipAgents,
2007
+ skipPackageJson: options.skipPackageJson,
2008
+ skipGitignore: options.skipGitignore,
2009
+ });
2010
+ printOperations('Setup/fix', fixPayload, options.dryRun);
2011
+
2012
+ if (options.dryRun) {
2013
+ console.log(`[${TOOL_NAME}] Dry run setup done.`);
2014
+ process.exitCode = 0;
2015
+ return;
2016
+ }
2017
+
2018
+ const scanResult = runScanInternal({ target: options.target, json: false });
2019
+ printScanResult(scanResult, false);
2020
+
2021
+ if (scanResult.errors === 0 && scanResult.warnings === 0) {
2022
+ console.log(`[${TOOL_NAME}] ✅ Setup complete.`);
2023
+ console.log(`[${TOOL_NAME}] Copy AI setup prompt with: ${SHORT_TOOL_NAME} copy-prompt`);
2024
+ }
2025
+
2026
+ setExitCodeFromScan(scanResult);
2027
+ }
2028
+
2029
+ function ensureMainBranch(repoRoot) {
2030
+ const branchResult = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
2031
+ if (branchResult.status !== 0) {
2032
+ throw new Error(`Unable to detect current branch in ${repoRoot}`);
2033
+ }
2034
+
2035
+ const branch = branchResult.stdout.trim();
2036
+ if (branch !== 'main') {
2037
+ throw new Error(`Release blocked: current branch is '${branch}' (required: 'main')`);
2038
+ }
2039
+ }
2040
+
2041
+ function ensureCleanWorkingTree(repoRoot) {
2042
+ const statusResult = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
2043
+ if (statusResult.status !== 0) {
2044
+ throw new Error(`Unable to read git status in ${repoRoot}`);
2045
+ }
2046
+
2047
+ const dirty = statusResult.stdout.trim();
2048
+ if (dirty.length > 0) {
2049
+ throw new Error('Release blocked: working tree is not clean');
2050
+ }
2051
+ }
2052
+
2053
+ function release(rawArgs) {
2054
+ if (rawArgs.length > 0) {
2055
+ throw new Error(`Unknown option: ${rawArgs[0]}`);
2056
+ }
2057
+
2058
+ const repoRoot = resolveRepoRoot(process.cwd());
2059
+ if (path.resolve(repoRoot) !== MAINTAINER_RELEASE_REPO) {
2060
+ throw new Error(
2061
+ `Release blocked: command only allowed in ${MAINTAINER_RELEASE_REPO} (current: ${repoRoot})`,
2062
+ );
2063
+ }
2064
+
2065
+ ensureMainBranch(repoRoot);
2066
+ ensureCleanWorkingTree(repoRoot);
2067
+
2068
+ console.log(`[${TOOL_NAME}] Releasing ${packageJson.name}@${packageJson.version} from ${repoRoot}`);
2069
+ const publishResult = run(NPM_BIN, ['publish'], { cwd: repoRoot, stdio: 'inherit' });
2070
+ if (publishResult.status !== 0) {
2071
+ throw new Error('npm publish failed');
2072
+ }
2073
+
2074
+ console.log(`[${TOOL_NAME}] ✅ Publish complete.`);
2075
+ process.exitCode = 0;
2076
+ }
2077
+
2078
+ function printAgentsSnippet() {
2079
+ const snippetPath = path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md');
2080
+ process.stdout.write(fs.readFileSync(snippetPath, 'utf8'));
2081
+ }
2082
+
2083
+ function copyPrompt() {
2084
+ process.stdout.write(AI_SETUP_PROMPT);
2085
+ process.exitCode = 0;
2086
+ }
2087
+
2088
+ function copyCommands() {
2089
+ process.stdout.write(AI_SETUP_COMMANDS);
2090
+ process.exitCode = 0;
2091
+ }
2092
+
2093
+ function sync(rawArgs) {
2094
+ const options = parseSyncArgs(rawArgs);
2095
+ const repoRoot = resolveRepoRoot(options.target);
2096
+ const baseBranch = resolveBaseBranch(repoRoot, options.base);
2097
+ const strategy = resolveSyncStrategy(repoRoot, options.strategy);
2098
+ const baseRef = `origin/${baseBranch}`;
2099
+
2100
+ ensureOriginBaseRef(repoRoot, baseBranch);
2101
+
2102
+ if (options.allAgentBranches) {
2103
+ const refs = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/*'], { allowFailure: true });
2104
+ if (refs.status !== 0) {
2105
+ throw new Error('Unable to list local agent branches');
2106
+ }
2107
+ const branches = (refs.stdout || '').split('\n').map((item) => item.trim()).filter(Boolean);
2108
+ const rows = branches.map((branch) => {
2109
+ const counts = aheadBehind(repoRoot, branch, baseRef);
2110
+ return {
2111
+ branch,
2112
+ base: baseRef,
2113
+ ahead: counts.ahead,
2114
+ behind: counts.behind,
2115
+ syncRequired: counts.behind > 0,
2116
+ };
2117
+ });
2118
+
2119
+ if (options.json) {
2120
+ process.stdout.write(`${JSON.stringify({
2121
+ repoRoot,
2122
+ base: baseRef,
2123
+ branchCount: rows.length,
2124
+ rows,
2125
+ }, null, 2)}\n`);
2126
+ } else {
2127
+ console.log(`[${TOOL_NAME}] Sync report target: ${repoRoot}`);
2128
+ console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
2129
+ if (rows.length === 0) {
2130
+ console.log(`[${TOOL_NAME}] No local agent branches found.`);
2131
+ } else {
2132
+ for (const row of rows) {
2133
+ console.log(` - ${row.branch} | ahead ${row.ahead} | behind ${row.behind} | syncRequired=${row.syncRequired}`);
2134
+ }
2135
+ }
2136
+ }
2137
+
2138
+ const hasBehind = rows.some((row) => row.behind > 0);
2139
+ process.exitCode = options.check && hasBehind ? 1 : 0;
2140
+ return;
2141
+ }
2142
+
2143
+ const branch = currentBranchName(repoRoot);
2144
+ if (!options.allowNonAgent && !branch.startsWith('agent/')) {
2145
+ throw new Error(`sync is limited to agent/* branches by default (current: ${branch}). Use --allow-non-agent to override.`);
2146
+ }
2147
+
2148
+ const dirty = workingTreeIsDirty(repoRoot);
2149
+ if (!options.check && !options.allowDirty && dirty) {
2150
+ throw new Error('Sync blocked: working tree is not clean. Commit or stash changes first, or pass --allow-dirty.');
2151
+ }
2152
+
2153
+ const before = aheadBehind(repoRoot, branch, baseRef);
2154
+
2155
+ const payload = {
2156
+ repoRoot,
2157
+ branch,
2158
+ base: baseRef,
2159
+ strategy,
2160
+ dirty,
2161
+ aheadBefore: before.ahead,
2162
+ behindBefore: before.behind,
2163
+ syncRequired: before.behind > 0,
2164
+ status: 'checked',
2165
+ };
2166
+
2167
+ if (options.check) {
2168
+ if (options.json) {
2169
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
2170
+ } else {
2171
+ console.log(`[${TOOL_NAME}] Sync check target: ${repoRoot}`);
2172
+ console.log(`[${TOOL_NAME}] Branch: ${branch}`);
2173
+ console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
2174
+ console.log(`[${TOOL_NAME}] Ahead: ${before.ahead}`);
2175
+ console.log(`[${TOOL_NAME}] Behind: ${before.behind}`);
2176
+ console.log(`[${TOOL_NAME}] Sync required: ${before.behind > 0 ? 'yes' : 'no'}`);
2177
+ }
2178
+ process.exitCode = before.behind > 0 ? 1 : 0;
2179
+ return;
2180
+ }
2181
+
2182
+ if (before.behind === 0) {
2183
+ const result = { ...payload, status: 'no-op', aheadAfter: before.ahead, behindAfter: before.behind };
2184
+ if (options.json) {
2185
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2186
+ } else {
2187
+ console.log(`[${TOOL_NAME}] Branch '${branch}' is already up to date with ${baseRef}.`);
2188
+ }
2189
+ process.exitCode = 0;
2190
+ return;
2191
+ }
2192
+
2193
+ if (options.dryRun) {
2194
+ const result = { ...payload, status: 'dry-run' };
2195
+ if (options.json) {
2196
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2197
+ } else {
2198
+ console.log(`[${TOOL_NAME}] Dry run: would sync '${branch}' onto ${baseRef} via ${strategy}.`);
2199
+ }
2200
+ process.exitCode = 0;
2201
+ return;
2202
+ }
2203
+
2204
+ const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
2205
+ const lockState = lockRegistryStatus(repoRoot);
2206
+ let lockBackup = null;
2207
+ if (lockState.dirty && fs.existsSync(lockPath)) {
2208
+ lockBackup = fs.readFileSync(lockPath, 'utf8');
2209
+ }
2210
+
2211
+ if (lockState.dirty) {
2212
+ if (lockState.untracked) {
2213
+ fs.rmSync(lockPath, { force: true });
2214
+ } else {
2215
+ const resetLock = gitRun(repoRoot, ['checkout', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
2216
+ if (resetLock.status !== 0) {
2217
+ throw new Error(`Unable to temporarily reset ${LOCK_FILE_RELATIVE} before sync`);
2218
+ }
2219
+ }
2220
+ }
2221
+
2222
+ try {
2223
+ syncOperation(repoRoot, strategy, baseRef, options.ffOnly);
2224
+ } finally {
2225
+ if (lockBackup !== null) {
2226
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2227
+ fs.writeFileSync(lockPath, lockBackup, 'utf8');
2228
+ }
2229
+ }
2230
+ const after = aheadBehind(repoRoot, branch, baseRef);
2231
+ const result = {
2232
+ ...payload,
2233
+ status: 'success',
2234
+ aheadAfter: after.ahead,
2235
+ behindAfter: after.behind,
2236
+ };
2237
+
2238
+ if (options.json) {
2239
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2240
+ } else {
2241
+ console.log(`[${TOOL_NAME}] Sync target: ${repoRoot}`);
2242
+ console.log(`[${TOOL_NAME}] Branch: ${branch}`);
2243
+ console.log(`[${TOOL_NAME}] Base: ${baseRef}`);
2244
+ console.log(`[${TOOL_NAME}] Strategy: ${strategy}`);
2245
+ console.log(`[${TOOL_NAME}] Behind before sync: ${before.behind}`);
2246
+ console.log(`[${TOOL_NAME}] Result: success (behind now: ${after.behind})`);
2247
+ }
2248
+
2249
+ process.exitCode = 0;
2250
+ }
2251
+
2252
+ function protect(rawArgs) {
2253
+ const parsed = parseTargetFlag(rawArgs, process.cwd());
2254
+ const [subcommand, ...rest] = parsed.args;
2255
+ const repoRoot = resolveRepoRoot(parsed.target);
2256
+
2257
+ if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
2258
+ console.log(
2259
+ `${TOOL_NAME} protect commands:\n` +
2260
+ ` ${TOOL_NAME} protect list [--target <path>]\n` +
2261
+ ` ${TOOL_NAME} protect add <branch...> [--target <path>]\n` +
2262
+ ` ${TOOL_NAME} protect remove <branch...> [--target <path>]\n` +
2263
+ ` ${TOOL_NAME} protect set <branch...> [--target <path>]\n` +
2264
+ ` ${TOOL_NAME} protect reset [--target <path>]`,
2265
+ );
2266
+ process.exitCode = 0;
2267
+ return;
2268
+ }
2269
+
2270
+ const requestedBranches = uniquePreserveOrder(parseBranchList(rest.join(' ')));
2271
+
2272
+ if (subcommand === 'list') {
2273
+ const branches = readProtectedBranches(repoRoot);
2274
+ console.log(`[${TOOL_NAME}] Protected branches (${branches.length}): ${branches.join(', ')}`);
2275
+ process.exitCode = 0;
2276
+ return;
2277
+ }
2278
+
2279
+ if (subcommand === 'add') {
2280
+ if (requestedBranches.length === 0) {
2281
+ throw new Error('protect add requires one or more branch names');
2282
+ }
2283
+ const current = readProtectedBranches(repoRoot);
2284
+ const next = uniquePreserveOrder([...current, ...requestedBranches]);
2285
+ writeProtectedBranches(repoRoot, next);
2286
+ console.log(`[${TOOL_NAME}] Protected branches updated: ${next.join(', ')}`);
2287
+ process.exitCode = 0;
2288
+ return;
2289
+ }
2290
+
2291
+ if (subcommand === 'remove') {
2292
+ if (requestedBranches.length === 0) {
2293
+ throw new Error('protect remove requires one or more branch names');
2294
+ }
2295
+ const current = readProtectedBranches(repoRoot);
2296
+ const removals = new Set(requestedBranches);
2297
+ const next = current.filter((branch) => !removals.has(branch));
2298
+ writeProtectedBranches(repoRoot, next);
2299
+ console.log(
2300
+ `[${TOOL_NAME}] Protected branches updated: ` +
2301
+ `${(next.length > 0 ? next : DEFAULT_PROTECTED_BRANCHES).join(', ')}`,
2302
+ );
2303
+ if (next.length === 0) {
2304
+ console.log(`[${TOOL_NAME}] Reset to defaults (${DEFAULT_PROTECTED_BRANCHES.join(', ')}) because list was empty.`);
2305
+ }
2306
+ process.exitCode = 0;
2307
+ return;
2308
+ }
2309
+
2310
+ if (subcommand === 'set') {
2311
+ if (requestedBranches.length === 0) {
2312
+ throw new Error('protect set requires one or more branch names');
2313
+ }
2314
+ writeProtectedBranches(repoRoot, requestedBranches);
2315
+ console.log(`[${TOOL_NAME}] Protected branches set: ${requestedBranches.join(', ')}`);
2316
+ process.exitCode = 0;
2317
+ return;
2318
+ }
2319
+
2320
+ if (subcommand === 'reset') {
2321
+ writeProtectedBranches(repoRoot, []);
2322
+ console.log(`[${TOOL_NAME}] Protected branches reset to defaults: ${DEFAULT_PROTECTED_BRANCHES.join(', ')}`);
2323
+ process.exitCode = 0;
2324
+ return;
2325
+ }
2326
+
2327
+ throw new Error(`Unknown protect subcommand: ${subcommand}`);
2328
+ }
2329
+
2330
+ function levenshteinDistance(a, b) {
2331
+ const rows = a.length + 1;
2332
+ const cols = b.length + 1;
2333
+ const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
2334
+
2335
+ for (let i = 0; i < rows; i += 1) matrix[i][0] = i;
2336
+ for (let j = 0; j < cols; j += 1) matrix[0][j] = j;
2337
+
2338
+ for (let i = 1; i < rows; i += 1) {
2339
+ for (let j = 1; j < cols; j += 1) {
2340
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2341
+ matrix[i][j] = Math.min(
2342
+ matrix[i - 1][j] + 1, // deletion
2343
+ matrix[i][j - 1] + 1, // insertion
2344
+ matrix[i - 1][j - 1] + cost, // substitution
2345
+ );
2346
+ }
2347
+ }
2348
+ return matrix[a.length][b.length];
2349
+ }
2350
+
2351
+ function maybeSuggestCommand(command) {
2352
+ let best = null;
2353
+ let bestDistance = Number.POSITIVE_INFINITY;
2354
+
2355
+ for (const candidate of SUGGESTIBLE_COMMANDS) {
2356
+ const dist = levenshteinDistance(command, candidate);
2357
+ if (dist < bestDistance) {
2358
+ bestDistance = dist;
2359
+ best = candidate;
2360
+ }
2361
+ }
2362
+
2363
+ if (best && bestDistance <= 2) {
2364
+ return best;
2365
+ }
2366
+
2367
+ return null;
2368
+ }
2369
+
2370
+ function normalizeCommandOrThrow(command) {
2371
+ if (COMMAND_TYPO_ALIASES.has(command)) {
2372
+ const mapped = COMMAND_TYPO_ALIASES.get(command);
2373
+ console.log(`[${TOOL_NAME}] Interpreting '${command}' as '${mapped}'.`);
2374
+ return mapped;
2375
+ }
2376
+ return command;
2377
+ }
2378
+
2379
+ function main() {
2380
+ const args = process.argv.slice(2);
2381
+
2382
+ if (args.length === 0) {
2383
+ maybeSelfUpdateBeforeStatus();
2384
+ status([]);
2385
+ return;
2386
+ }
2387
+
2388
+ const [rawCommand, ...rest] = args;
2389
+ const command = normalizeCommandOrThrow(rawCommand);
2390
+
2391
+ if (command === '--help' || command === '-h' || command === 'help') {
2392
+ usage();
2393
+ return;
2394
+ }
2395
+
2396
+ if (command === '--version' || command === '-v' || command === 'version') {
2397
+ console.log(packageJson.version);
2398
+ return;
2399
+ }
2400
+
2401
+ if (command === 'status') {
2402
+ status(rest);
2403
+ return;
2404
+ }
2405
+
2406
+ if (command === 'setup') {
2407
+ setup(rest);
2408
+ return;
2409
+ }
2410
+
2411
+ if (command === 'doctor') {
2412
+ doctor(rest);
2413
+ return;
2414
+ }
2415
+
2416
+ if (command === 'report') {
2417
+ report(rest);
2418
+ return;
2419
+ }
2420
+
2421
+ if (command === 'copy-prompt') {
2422
+ copyPrompt();
2423
+ return;
2424
+ }
2425
+
2426
+ if (command === 'copy-commands') {
2427
+ copyCommands();
2428
+ return;
2429
+ }
2430
+
2431
+ if (command === 'protect') {
2432
+ protect(rest);
2433
+ return;
2434
+ }
2435
+
2436
+ if (command === 'sync') {
2437
+ sync(rest);
2438
+ return;
2439
+ }
2440
+
2441
+ if (command === 'release') {
2442
+ release(rest);
2443
+ return;
2444
+ }
2445
+
2446
+ if (command === 'install') {
2447
+ install(rest);
2448
+ return;
2449
+ }
2450
+
2451
+ if (command === 'fix') {
2452
+ fix(rest);
2453
+ return;
2454
+ }
2455
+
2456
+ if (command === 'scan') {
2457
+ scan(rest);
2458
+ return;
2459
+ }
2460
+
2461
+ if (command === 'print-agents-snippet') {
2462
+ printAgentsSnippet();
2463
+ return;
2464
+ }
2465
+
2466
+ const suggestion = maybeSuggestCommand(command);
2467
+ if (suggestion) {
2468
+ throw new Error(`Unknown command: ${command}. Did you mean '${suggestion}'?`);
2469
+ }
2470
+ throw new Error(`Unknown command: ${command}`);
2471
+ }
2472
+
2473
+ try {
2474
+ main();
2475
+ } catch (error) {
2476
+ console.error(`[${TOOL_NAME}] ${error.message}`);
2477
+ process.exitCode = 1;
2478
+ }