guardlink 1.0.0 → 1.2.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 (91) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +14 -0
  3. package/dist/agents/config.d.ts +8 -0
  4. package/dist/agents/config.d.ts.map +1 -1
  5. package/dist/agents/config.js +28 -5
  6. package/dist/agents/config.js.map +1 -1
  7. package/dist/agents/index.d.ts +2 -1
  8. package/dist/agents/index.d.ts.map +1 -1
  9. package/dist/agents/index.js +1 -1
  10. package/dist/agents/index.js.map +1 -1
  11. package/dist/agents/launcher.d.ts +14 -0
  12. package/dist/agents/launcher.d.ts.map +1 -1
  13. package/dist/agents/launcher.js +126 -1
  14. package/dist/agents/launcher.js.map +1 -1
  15. package/dist/agents/prompts.d.ts +2 -2
  16. package/dist/agents/prompts.d.ts.map +1 -1
  17. package/dist/agents/prompts.js +251 -31
  18. package/dist/agents/prompts.js.map +1 -1
  19. package/dist/analyze/index.d.ts +34 -1
  20. package/dist/analyze/index.d.ts.map +1 -1
  21. package/dist/analyze/index.js +281 -8
  22. package/dist/analyze/index.js.map +1 -1
  23. package/dist/analyze/llm.d.ts +54 -3
  24. package/dist/analyze/llm.d.ts.map +1 -1
  25. package/dist/analyze/llm.js +418 -97
  26. package/dist/analyze/llm.js.map +1 -1
  27. package/dist/analyze/prompts.d.ts +3 -2
  28. package/dist/analyze/prompts.d.ts.map +1 -1
  29. package/dist/analyze/prompts.js +227 -111
  30. package/dist/analyze/prompts.js.map +1 -1
  31. package/dist/analyze/tools.d.ts +22 -0
  32. package/dist/analyze/tools.d.ts.map +1 -0
  33. package/dist/analyze/tools.js +230 -0
  34. package/dist/analyze/tools.js.map +1 -0
  35. package/dist/analyzer/sarif.js +1 -1
  36. package/dist/cli/index.d.ts +15 -7
  37. package/dist/cli/index.d.ts.map +1 -1
  38. package/dist/cli/index.js +290 -150
  39. package/dist/cli/index.js.map +1 -1
  40. package/dist/dashboard/data.d.ts +5 -0
  41. package/dist/dashboard/data.d.ts.map +1 -1
  42. package/dist/dashboard/data.js +24 -12
  43. package/dist/dashboard/data.js.map +1 -1
  44. package/dist/dashboard/diagrams.d.ts.map +1 -1
  45. package/dist/dashboard/diagrams.js +310 -37
  46. package/dist/dashboard/diagrams.js.map +1 -1
  47. package/dist/dashboard/generate.d.ts.map +1 -1
  48. package/dist/dashboard/generate.js +197 -64
  49. package/dist/dashboard/generate.js.map +1 -1
  50. package/dist/init/picker.d.ts.map +1 -1
  51. package/dist/init/picker.js +2 -2
  52. package/dist/init/picker.js.map +1 -1
  53. package/dist/init/templates.d.ts.map +1 -1
  54. package/dist/init/templates.js +52 -32
  55. package/dist/init/templates.js.map +1 -1
  56. package/dist/mcp/server.d.ts.map +1 -1
  57. package/dist/mcp/server.js +14 -28
  58. package/dist/mcp/server.js.map +1 -1
  59. package/dist/parser/index.d.ts +1 -0
  60. package/dist/parser/index.d.ts.map +1 -1
  61. package/dist/parser/index.js +1 -0
  62. package/dist/parser/index.js.map +1 -1
  63. package/dist/parser/parse-line.js +3 -3
  64. package/dist/parser/parse-line.js.map +1 -1
  65. package/dist/parser/parse-project.js +1 -1
  66. package/dist/parser/validate.d.ts +31 -0
  67. package/dist/parser/validate.d.ts.map +1 -0
  68. package/dist/parser/validate.js +149 -0
  69. package/dist/parser/validate.js.map +1 -0
  70. package/dist/report/report.d.ts.map +1 -1
  71. package/dist/report/report.js +64 -0
  72. package/dist/report/report.js.map +1 -1
  73. package/dist/tui/commands.d.ts +3 -3
  74. package/dist/tui/commands.d.ts.map +1 -1
  75. package/dist/tui/commands.js +390 -206
  76. package/dist/tui/commands.js.map +1 -1
  77. package/dist/tui/config.d.ts +2 -0
  78. package/dist/tui/config.d.ts.map +1 -1
  79. package/dist/tui/config.js.map +1 -1
  80. package/dist/tui/format.d.ts +7 -0
  81. package/dist/tui/format.d.ts.map +1 -1
  82. package/dist/tui/format.js +59 -0
  83. package/dist/tui/format.js.map +1 -1
  84. package/dist/tui/index.d.ts.map +1 -1
  85. package/dist/tui/index.js +32 -19
  86. package/dist/tui/index.js.map +1 -1
  87. package/dist/tui/input.d.ts +2 -2
  88. package/dist/tui/input.js +2 -2
  89. package/dist/types/index.d.ts +1 -1
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/package.json +1 -1
@@ -5,18 +5,18 @@
5
5
  * Returns void. Throws on fatal errors.
6
6
  */
7
7
  import { resolve, basename } from 'node:path';
8
- import { writeFileSync } from 'node:fs';
9
- import { parseProject } from '../parser/index.js';
8
+ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures } from '../parser/index.js';
10
10
  import { initProject, detectProject, promptAgentSelection } from '../init/index.js';
11
11
  import { generateReport } from '../report/index.js';
12
12
  import { generateDashboardHTML } from '../dashboard/index.js';
13
13
  import { computeStats, computeSeverity, computeExposures } from '../dashboard/data.js';
14
- import { generateThreatReport, serializeModel, listThreatReports, loadThreatReportsForDashboard, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage } from '../analyze/index.js';
14
+ import { generateThreatReport, serializeModel, listThreatReports, loadThreatReportsForDashboard, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage, buildProjectContext, extractCodeSnippets } from '../analyze/index.js';
15
15
  import { diffModels, formatDiff, parseAtRef } from '../diff/index.js';
16
16
  import { generateSarif } from '../analyzer/index.js';
17
- import { C, severityBadge, severityText, severityTextPad, severityOrder, computeGrade, gradeColored, readCodeContext, trunc, bar, fileLink, fileLinkTrunc } from './format.js';
18
- import { resolveLLMConfig, saveTuiConfig } from './config.js';
19
- import { AGENTS, parseAgentFlag, launchAgent, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
17
+ import { C, severityBadge, severityText, severityTextPad, severityOrder, computeGrade, gradeColored, readCodeContext, trunc, bar, fileLink, fileLinkTrunc, cleanCliArtifacts } from './format.js';
18
+ import { resolveLLMConfig, saveTuiConfig, loadTuiConfig } from './config.js';
19
+ import { AGENTS, parseAgentFlag, launchAgent, launchAgentInline, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
20
20
  import { describeConfigSource } from '../agents/config.js';
21
21
  // ─── Shared context ──────────────────────────────────────────────────
22
22
  /** Prompt user to pick an agent interactively (TUI only) */
@@ -61,19 +61,19 @@ export function cmdHelp() {
61
61
  ['/init [name]', 'Initialize GuardLink in this project'],
62
62
  ['/parse', 'Parse annotations, build threat model'],
63
63
  ['/status', 'Risk grade + summary stats'],
64
- ['/scan', 'Find unannotated security-relevant functions'],
65
64
  ['/validate [--strict]', 'Check for syntax errors + dangling refs'],
66
65
  ['', ''],
67
- ['/exposures [flags]', 'List exposures (--asset, --severity, --file, --threat)'],
68
- ['/show <n>', 'Detail view of exposure #n with code context'],
66
+ ['/exposures [--all]', 'List open exposures by severity (filter: --asset --severity --threat --file)'],
67
+ ['/show <n>', 'Detail view + code context for an exposure (from /exposures list)'],
68
+ ['/scan', 'Annotation coverage scanner — find unannotated symbols'],
69
69
  ['/assets', 'Asset tree with threat/control counts'],
70
70
  ['/files', 'Annotated file tree with exposure counts'],
71
71
  ['/view <file>', 'Show all annotations in a file with code context'],
72
72
  ['', ''],
73
- ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general)'],
73
+ ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general|custom)'],
74
74
  ['/threat-reports', 'List saved AI threat reports'],
75
75
  ['/annotate <prompt>', 'Launch coding agent to annotate codebase'],
76
- ['/model', 'Set AI provider + API key'],
76
+ ['/model', 'Set AI provider (API or CLI agent: Claude Code, Codex, Gemini)'],
77
77
  ['(freeform text)', 'Chat about your threat model with AI'],
78
78
  ['', ''],
79
79
  ['/report', 'Generate markdown + JSON report'],
@@ -81,6 +81,7 @@ export function cmdHelp() {
81
81
  ['/diff [ref]', 'Compare model against a git ref (default: HEAD~1)'],
82
82
  ['/sarif [-o file]', 'Export SARIF 2.1.0 for GitHub / VS Code'],
83
83
  ['', ''],
84
+ ['/gal', 'GAL annotation language guide'],
84
85
  ['/help', 'This help'],
85
86
  ['/quit', 'Exit'],
86
87
  ];
@@ -326,9 +327,7 @@ export function cmdExposures(args, ctx) {
326
327
  return;
327
328
  }
328
329
  console.log('');
329
- // Determine terminal width for adaptive layout
330
330
  const termWidth = process.stdout.columns || 100;
331
- // Manual table (we need colored cells which formatTable can't do directly)
332
331
  const header = ` ${C.dim('#'.padEnd(4))}${C.dim('SEVERITY'.padEnd(12))}${C.dim('ASSET'.padEnd(18))}${C.dim('THREAT'.padEnd(20))}${C.dim('FILE'.padEnd(30))}${C.dim('LINE')}`;
333
332
  console.log(header);
334
333
  console.log(C.dim(' ' + '─'.repeat(Math.min(termWidth - 4, 96))));
@@ -367,7 +366,6 @@ export function cmdShow(args, ctx) {
367
366
  }
368
367
  console.log(` ${C.cyan('│')} ${C.dim(fileLink(exp.location.file, exp.location.line, ctx.root))}`);
369
368
  console.log(` ${C.cyan('│')}`);
370
- // Code context
371
369
  const { lines } = readCodeContext(exp.location.file, exp.location.line, ctx.root);
372
370
  for (const l of lines) {
373
371
  console.log(` ${C.cyan('│')} ${l}`);
@@ -375,6 +373,33 @@ export function cmdShow(args, ctx) {
375
373
  console.log(` ${C.cyan('└')}`);
376
374
  console.log('');
377
375
  }
376
+ // ─── /scan ───────────────────────────────────────────────────────────
377
+ export function cmdScan(ctx) {
378
+ if (!ctx.model) {
379
+ console.log(C.warn(' No threat model. Run /parse first.'));
380
+ return;
381
+ }
382
+ const cov = ctx.model.coverage;
383
+ const pct = cov.coverage_percent;
384
+ console.log('');
385
+ console.log(` ${C.bold('Coverage:')} ${cov.annotated_symbols}/${cov.total_symbols} symbols (${pct}%)`);
386
+ const unannotated = cov.unannotated_critical || [];
387
+ if (unannotated.length === 0) {
388
+ console.log(C.green(' All security-relevant symbols are annotated!'));
389
+ }
390
+ else {
391
+ console.log(C.warn(` ${unannotated.length} unannotated symbol(s):`));
392
+ console.log('');
393
+ const show = unannotated.slice(0, 25);
394
+ for (const u of show) {
395
+ console.log(` ${C.dim(fileLink(u.file, u.line, ctx.root))} ${u.kind} ${C.bold(u.name)}`);
396
+ }
397
+ if (unannotated.length > 25) {
398
+ console.log(C.dim(` ... and ${unannotated.length - 25} more`));
399
+ }
400
+ }
401
+ console.log('');
402
+ }
378
403
  // ─── /assets ─────────────────────────────────────────────────────────
379
404
  export function cmdAssets(ctx) {
380
405
  if (!ctx.model) {
@@ -671,33 +696,6 @@ export async function cmdParse(ctx) {
671
696
  console.log(C.error(` ✗ Parse failed: ${err.message}`));
672
697
  }
673
698
  }
674
- // ─── /scan ───────────────────────────────────────────────────────────
675
- export function cmdScan(ctx) {
676
- if (!ctx.model) {
677
- console.log(C.warn(' No threat model. Run /parse first.'));
678
- return;
679
- }
680
- const cov = ctx.model.coverage;
681
- const pct = cov.coverage_percent;
682
- console.log('');
683
- console.log(` ${C.bold('Coverage:')} ${cov.annotated_symbols}/${cov.total_symbols} symbols (${pct}%)`);
684
- const unannotated = cov.unannotated_critical || [];
685
- if (unannotated.length === 0) {
686
- console.log(C.green(' All security-relevant symbols are annotated!'));
687
- }
688
- else {
689
- console.log(C.warn(` ${unannotated.length} unannotated symbol(s):`));
690
- console.log('');
691
- const show = unannotated.slice(0, 25);
692
- for (const u of show) {
693
- console.log(` ${C.dim(fileLink(u.file, u.line, ctx.root))} ${u.kind} ${C.bold(u.name)}`);
694
- }
695
- if (unannotated.length > 25) {
696
- console.log(C.dim(` ... and ${unannotated.length - 25} more`));
697
- }
698
- }
699
- console.log('');
700
- }
701
699
  // ─── /validate ───────────────────────────────────────────────────────
702
700
  export async function cmdValidate(ctx) {
703
701
  console.log(C.dim(' Checking annotations...'));
@@ -706,9 +704,13 @@ export async function cmdValidate(ctx) {
706
704
  ctx.model = model;
707
705
  // Dangling refs
708
706
  const danglingDiags = findDanglingRefs(model);
709
- const allDiags = [...diagnostics, ...danglingDiags];
707
+ // Check for @accepts without @audit (governance concern)
708
+ const acceptAuditDiags = findAcceptedWithoutAudit(model);
709
+ const allDiags = [...diagnostics, ...danglingDiags, ...acceptAuditDiags];
710
710
  // Unmitigated exposures
711
711
  const unmitigated = findUnmitigatedExposures(model);
712
+ // Accepted-but-unmitigated exposures
713
+ const acceptedOnly = findAcceptedExposures(model);
712
714
  // Print diagnostics
713
715
  const errors = allDiags.filter(d => d.level === 'error');
714
716
  const warnings = allDiags.filter(d => d.level === 'warning');
@@ -728,8 +730,16 @@ export async function cmdValidate(ctx) {
728
730
  console.log(` ${sev} ${u.asset} → ${u.threat} ${C.dim(fileLink(u.location.file, u.location.line, ctx.root))}`);
729
731
  }
730
732
  }
733
+ if (acceptedOnly.length > 0) {
734
+ console.log('');
735
+ console.log(C.warn(` ⚡ ${acceptedOnly.length} accepted-but-unmitigated exposure(s) (no control in code):`));
736
+ for (const a of acceptedOnly) {
737
+ const sev = a.severity ? severityBadge(a.severity) : C.dim('unset');
738
+ console.log(` ${sev} ${a.asset} → ${a.threat} ${C.dim(fileLink(a.location.file, a.location.line, ctx.root))}`);
739
+ }
740
+ }
731
741
  console.log('');
732
- if (errors.length === 0 && unmitigated.length === 0) {
742
+ if (errors.length === 0 && unmitigated.length === 0 && acceptedOnly.length === 0) {
733
743
  console.log(C.success(' ✓ All annotations valid, no unmitigated exposures.'));
734
744
  }
735
745
  else {
@@ -740,6 +750,8 @@ export async function cmdValidate(ctx) {
740
750
  parts.push(`${warnings.length} warning(s)`);
741
751
  if (unmitigated.length > 0)
742
752
  parts.push(`${unmitigated.length} unmitigated`);
753
+ if (acceptedOnly.length > 0)
754
+ parts.push(`${acceptedOnly.length} accepted without mitigation`);
743
755
  console.log(` ${parts.join(', ')}`);
744
756
  }
745
757
  }
@@ -817,69 +829,110 @@ export async function cmdSarif(args, ctx) {
817
829
  }
818
830
  console.log('');
819
831
  }
820
- // ─── Helpers: validate ───────────────────────────────────────────────
821
- function findDanglingRefs(model) {
822
- const diagnostics = [];
823
- const definedIds = new Set();
824
- for (const a of model.assets)
825
- if (a.id)
826
- definedIds.add(a.id);
827
- for (const t of model.threats)
828
- if (t.id)
829
- definedIds.add(t.id);
830
- for (const c of model.controls)
831
- if (c.id)
832
- definedIds.add(c.id);
833
- for (const b of model.boundaries)
834
- if (b.id)
835
- definedIds.add(b.id);
836
- const checkRef = (ref, loc) => {
837
- if (ref.startsWith('#')) {
838
- const id = ref.slice(1);
839
- if (!definedIds.has(id)) {
840
- diagnostics.push({
841
- level: 'warning',
842
- message: `Dangling reference: #${id} is never defined`,
843
- file: loc.file,
844
- line: loc.line,
845
- });
846
- }
847
- }
848
- };
849
- for (const m of model.mitigations) {
850
- checkRef(m.threat, m.location);
851
- if (m.control)
852
- checkRef(m.control, m.location);
853
- }
854
- for (const e of model.exposures)
855
- checkRef(e.threat, e.location);
856
- for (const a of model.acceptances)
857
- checkRef(a.threat, a.location);
858
- for (const t of model.transfers)
859
- checkRef(t.threat, t.location);
860
- if (model.validations) {
861
- for (const v of model.validations)
862
- checkRef(v.control, v.location);
863
- }
864
- return diagnostics;
865
- }
866
- function findUnmitigatedExposures(model) {
867
- const mitigated = new Set();
868
- for (const m of model.mitigations)
869
- mitigated.add(`${m.asset}::${m.threat}`);
870
- for (const a of model.acceptances)
871
- mitigated.add(`${a.asset}::${a.threat}`);
872
- return model.exposures.filter(e => !mitigated.has(`${e.asset}::${e.threat}`));
832
+ const CLI_AGENT_OPTIONS = [
833
+ { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic\'s coding agent (claude cli)' },
834
+ { id: 'codex', name: 'Codex CLI', desc: 'OpenAI\'s coding agent (codex cli)' },
835
+ { id: 'gemini', name: 'Gemini CLI', desc: 'Google\'s coding agent (gemini cli)' },
836
+ ];
837
+ const CLI_AGENT_NAMES = {
838
+ 'claude-code': 'Claude Code',
839
+ 'codex': 'Codex CLI',
840
+ 'gemini': 'Gemini CLI',
841
+ };
842
+ /** Provider model catalogs — popular models per provider, ordered by capability */
843
+ const PROVIDER_MODELS = {
844
+ anthropic: [
845
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', desc: 'Latest, frontier coding & agents' },
846
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', desc: 'Most intelligent, complex reasoning' },
847
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', desc: 'Previous gen, strong all-rounder' },
848
+ { id: 'claude-opus-4-5', name: 'Claude Opus 4.5', desc: 'Previous gen, deep analysis' },
849
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', desc: 'Fastest, lowest cost' },
850
+ ],
851
+ openai: [
852
+ { id: 'gpt-5.2', name: 'GPT-5.2', desc: 'Latest flagship, smartest & most precise' },
853
+ { id: 'gpt-5.2-pro', name: 'GPT-5.2 Pro', desc: 'Enhanced GPT-5.2 for complex tasks' },
854
+ { id: 'gpt-5', name: 'GPT-5', desc: 'Frontier model with reasoning' },
855
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', desc: 'Fast and affordable' },
856
+ { id: 'gpt-5-nano', name: 'GPT-5 Nano', desc: 'Fastest, lowest cost' },
857
+ { id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', desc: 'Optimized for agentic coding' },
858
+ { id: 'o3', name: 'o3', desc: 'Reasoning model, complex analysis' },
859
+ { id: 'o4-mini', name: 'o4-mini', desc: 'Fast reasoning model' },
860
+ { id: 'gpt-4.1', name: 'GPT-4.1', desc: 'Previous gen flagship' },
861
+ { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', desc: 'Previous gen, fast' },
862
+ ],
863
+ google: [
864
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', desc: 'Best price-performance, reasoning' },
865
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', desc: 'Most advanced, deep reasoning & coding' },
866
+ { id: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash-Lite', desc: 'Fastest, most budget-friendly' },
867
+ { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', desc: 'Preview: frontier-class at low cost' },
868
+ { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', desc: 'Preview: state-of-the-art reasoning' },
869
+ { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', desc: 'Preview: advanced agentic & coding' },
870
+ ],
871
+ deepseek: [
872
+ { id: 'deepseek-chat', name: 'DeepSeek V3.2', desc: 'General purpose, fast (128K context)' },
873
+ { id: 'deepseek-reasoner', name: 'DeepSeek R1', desc: 'Thinking mode, best for analysis' },
874
+ ],
875
+ openrouter: [
876
+ { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', desc: 'Anthropic via OpenRouter' },
877
+ { id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', desc: 'Anthropic via OpenRouter' },
878
+ { id: 'openai/gpt-5.2', name: 'GPT-5.2', desc: 'OpenAI via OpenRouter' },
879
+ { id: 'openai/o3', name: 'o3', desc: 'OpenAI reasoning via OpenRouter' },
880
+ { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', desc: 'Google via OpenRouter' },
881
+ { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', desc: 'Google via OpenRouter' },
882
+ { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', desc: 'DeepSeek via OpenRouter' },
883
+ ],
884
+ ollama: [
885
+ { id: 'llama3.2', name: 'Llama 3.2', desc: 'Meta, good general purpose' },
886
+ { id: 'qwen2.5-coder:32b', name: 'Qwen 2.5 Coder 32B', desc: 'Best local coding model' },
887
+ { id: 'deepseek-r1:32b', name: 'DeepSeek R1 32B', desc: 'Local reasoning model' },
888
+ { id: 'gemma3:27b', name: 'Gemma 3 27B', desc: 'Google, strong local model' },
889
+ { id: 'mistral', name: 'Mistral 7B', desc: 'Lightweight, fast' },
890
+ ],
891
+ };
892
+ /** Helper to display a numbered model selection menu and return the chosen model ID */
893
+ async function pickModel(ctx, provider) {
894
+ const models = PROVIDER_MODELS[provider];
895
+ if (!models || models.length === 0) {
896
+ // Fallback to free-text for unknown providers
897
+ const model = await ask(ctx, ' Model name: ');
898
+ return model || null;
899
+ }
900
+ console.log('');
901
+ console.log(' Select model:');
902
+ for (let i = 0; i < models.length; i++) {
903
+ const m = models[i];
904
+ console.log(` ${C.bold(String(i + 1))} ${m.name.padEnd(24)} ${C.dim(m.desc)}`);
905
+ }
906
+ console.log(` ${C.bold(String(models.length + 1))} ${C.dim('Custom (enter model ID manually)')}`);
907
+ console.log('');
908
+ const choice = await ask(ctx, ` Model [1-${models.length + 1}]: `);
909
+ const idx = parseInt(choice, 10) - 1;
910
+ if (idx < 0 || idx > models.length) {
911
+ console.log(C.warn(' Cancelled.'));
912
+ return null;
913
+ }
914
+ if (idx === models.length) {
915
+ // Custom model
916
+ const custom = await ask(ctx, ' Model ID: ');
917
+ return custom || null;
918
+ }
919
+ return models[idx].id;
873
920
  }
874
- // ─── /model ──────────────────────────────────────────────────────────
875
921
  export async function cmdModel(ctx) {
876
922
  const current = resolveLLMConfig(ctx.root);
923
+ const tuiCfg = loadTuiConfig(ctx.root);
877
924
  const source = describeConfigSource(ctx.root);
878
- if (current) {
925
+ // Show current configuration
926
+ if (tuiCfg?.aiMode === 'cli-agent' && tuiCfg?.cliAgent) {
927
+ const agentName = CLI_AGENT_NAMES[tuiCfg.cliAgent] || tuiCfg.cliAgent;
928
+ console.log(` ${C.dim('Current:')} ${agentName} ${C.dim('(CLI Agent)')}`);
929
+ console.log(` ${C.dim('Source:')} ${source}`);
930
+ console.log('');
931
+ }
932
+ else if (current) {
879
933
  console.log(` ${C.dim('Current:')} ${current.provider} / ${current.model}`);
880
934
  console.log(` ${C.dim('Source:')} ${source}`);
881
935
  console.log('');
882
- // If config comes from env vars, offer to keep it
883
936
  if (source.includes('env var')) {
884
937
  const override = await ask(ctx, ' Override with project config? (y/N): ');
885
938
  if (override.toLowerCase() !== 'y') {
@@ -892,88 +945,120 @@ export async function cmdModel(ctx) {
892
945
  console.log(C.dim(' No AI provider configured.'));
893
946
  console.log('');
894
947
  }
895
- // Provider selection
896
- const providers = ['anthropic', 'openai', 'openrouter', 'deepseek', 'ollama'];
897
- console.log(' Select provider:');
898
- providers.forEach((p, i) => console.log(` ${C.bold(String(i + 1))} ${p}`));
948
+ // Step 1: Choose mode — CLI Agents or API
949
+ console.log(' How would you like to use AI?');
950
+ console.log(` ${C.bold('1')} CLI Agents ${C.dim('(terminal-based coding agents)')}`);
951
+ console.log(` ${C.bold('2')} API ${C.dim('(direct LLM API calls)')}`);
899
952
  console.log('');
900
- const choice = await ask(ctx, ` Provider [1-${providers.length}]: `);
901
- const idx = parseInt(choice, 10) - 1;
902
- if (idx < 0 || idx >= providers.length) {
953
+ const modeChoice = await ask(ctx, ' Choice [1-2]: ');
954
+ const modeIdx = parseInt(modeChoice, 10);
955
+ if (modeIdx < 1 || modeIdx > 2) {
903
956
  console.log(C.warn(' Cancelled.'));
904
957
  return;
905
958
  }
906
- const provider = providers[idx];
907
- // API key
908
- let apiKey = '';
909
- if (provider !== 'ollama') {
910
- apiKey = await ask(ctx, ' API Key: ');
911
- if (!apiKey) {
912
- console.log(C.warn(' Cancelled no API key provided.'));
959
+ if (modeIdx === 1) {
960
+ // ── CLI Agent selection ──
961
+ console.log('');
962
+ console.log(' Select CLI Agent:');
963
+ for (let i = 0; i < CLI_AGENT_OPTIONS.length; i++) {
964
+ const a = CLI_AGENT_OPTIONS[i];
965
+ console.log(` ${C.bold(String(i + 1))} ${a.name.padEnd(16)} ${C.dim(a.desc)}`);
966
+ }
967
+ console.log('');
968
+ const agentChoice = await ask(ctx, ` Agent [1-${CLI_AGENT_OPTIONS.length}]: `);
969
+ const agentIdx = parseInt(agentChoice, 10) - 1;
970
+ if (agentIdx < 0 || agentIdx >= CLI_AGENT_OPTIONS.length) {
971
+ console.log(C.warn(' Cancelled.'));
913
972
  return;
914
973
  }
974
+ const selectedAgent = CLI_AGENT_OPTIONS[agentIdx];
975
+ saveTuiConfig(ctx.root, {
976
+ aiMode: 'cli-agent',
977
+ cliAgent: selectedAgent.id,
978
+ });
979
+ console.log('');
980
+ console.log(` ${C.success('✓')} Configured: ${C.bold(selectedAgent.name)} ${C.dim('(CLI Agent)')}`);
981
+ console.log(C.dim(' Saved to .guardlink/config.json'));
982
+ console.log(C.dim(` Use /threat-report or /annotate — they will launch ${selectedAgent.name} automatically.`));
983
+ console.log('');
915
984
  }
916
985
  else {
917
- apiKey = 'ollama-local';
918
- }
919
- // Model selection
920
- const defaults = {
921
- anthropic: 'claude-sonnet-4-5-20250929',
922
- openai: 'gpt-4o',
923
- openrouter: 'anthropic/claude-sonnet-4-5-20250929',
924
- deepseek: 'deepseek-chat',
925
- ollama: 'llama3.2',
926
- };
927
- const model = await ask(ctx, ` Model [${defaults[provider]}]: `);
928
- saveTuiConfig(ctx.root, {
929
- provider,
930
- model: model || defaults[provider],
931
- apiKey,
932
- });
933
- const displayKey = apiKey.length > 8 ? apiKey.slice(0, 6) + '•'.repeat(8) : '•'.repeat(8);
934
- console.log('');
935
- console.log(` ${C.success('✓')} Configured: ${C.bold(model || defaults[provider])} (${provider})`);
936
- console.log(` Key: ${displayKey}`);
937
- console.log(C.dim(' Saved to .guardlink/config.json'));
938
- console.log('');
939
- }
940
- // ─── /threat-report ──────────────────────────────────────────────────
941
- export async function cmdThreatReport(args, ctx) {
942
- if (!ctx.model) {
943
- console.log(C.warn(' No threat model. Run /parse first.'));
944
- return;
945
- }
946
- const { agent, cleanArgs } = parseAgentFlag(args);
947
- const framework = cleanArgs.trim().toLowerCase() || '';
948
- const validFrameworks = ['stride', 'dread', 'pasta', 'attacker', 'rapid', 'general'];
949
- if (!framework) {
986
+ // ── API provider selection ──
987
+ const providers = [
988
+ { id: 'anthropic', name: 'Anthropic', desc: 'Claude Sonnet 4.6, Opus 4.6, Haiku 4.5' },
989
+ { id: 'openai', name: 'OpenAI', desc: 'GPT-5.2, o3, o4-mini, GPT-5.1 Codex' },
990
+ { id: 'google', name: 'Google', desc: 'Gemini 2.5 Flash/Pro, Gemini 3 Pro' },
991
+ { id: 'deepseek', name: 'DeepSeek', desc: 'DeepSeek V3.2, R1 reasoning' },
992
+ { id: 'openrouter', name: 'OpenRouter', desc: 'Multi-provider gateway' },
993
+ { id: 'ollama', name: 'Ollama', desc: 'Local models (Llama, Qwen, Gemma)' },
994
+ ];
950
995
  console.log('');
951
- console.log(` ${C.bold('Threat report frameworks:')}`);
952
- for (const fw of validFrameworks) {
953
- console.log(` ${C.bold('/threat-report ' + fw.padEnd(12))} ${C.dim(FRAMEWORK_LABELS[fw])}`);
996
+ console.log(' Select provider:');
997
+ for (let i = 0; i < providers.length; i++) {
998
+ const p = providers[i];
999
+ console.log(` ${C.bold(String(i + 1))} ${p.name.padEnd(14)} ${C.dim(p.desc)}`);
1000
+ }
1001
+ console.log('');
1002
+ const choice = await ask(ctx, ` Provider [1-${providers.length}]: `);
1003
+ const idx = parseInt(choice, 10) - 1;
1004
+ if (idx < 0 || idx >= providers.length) {
1005
+ console.log(C.warn(' Cancelled.'));
1006
+ return;
1007
+ }
1008
+ const provider = providers[idx].id;
1009
+ // Model selection — numbered menu
1010
+ const modelId = await pickModel(ctx, provider);
1011
+ if (!modelId)
1012
+ return;
1013
+ // API key
1014
+ let apiKey = '';
1015
+ if (provider !== 'ollama') {
1016
+ console.log('');
1017
+ apiKey = await ask(ctx, ' API Key: ');
1018
+ if (!apiKey) {
1019
+ console.log(C.warn(' Cancelled — no API key provided.'));
1020
+ return;
1021
+ }
954
1022
  }
1023
+ else {
1024
+ apiKey = 'ollama-local';
1025
+ }
1026
+ saveTuiConfig(ctx.root, {
1027
+ aiMode: 'api',
1028
+ provider,
1029
+ model: modelId,
1030
+ apiKey,
1031
+ });
1032
+ const displayKey = apiKey.length > 8 ? apiKey.slice(0, 6) + '•'.repeat(8) : '•'.repeat(8);
1033
+ // Find display name for the model
1034
+ const modelEntry = PROVIDER_MODELS[provider]?.find(m => m.id === modelId);
1035
+ const modelDisplay = modelEntry ? `${modelEntry.name} (${modelId})` : modelId;
955
1036
  console.log('');
956
- console.log(C.dim(' Flags: --claude-code --codex --gemini --cursor --windsurf --clipboard'));
957
- console.log(C.dim(' Without flag: uses configured API provider (see /model)'));
958
- console.log(C.dim(' Example: /threat-report stride --claude-code'));
1037
+ console.log(` ${C.success('✓')} Configured: ${C.bold(modelDisplay)}`);
1038
+ console.log(` Provider: ${providers[idx].name} Key: ${displayKey}`);
1039
+ console.log(C.dim(' Saved to .guardlink/config.json'));
959
1040
  console.log('');
960
- return;
961
1041
  }
962
- const isStandard = validFrameworks.includes(framework);
963
- const fw = (isStandard ? framework : 'general');
964
- const customPrompt = isStandard ? undefined : cleanArgs.trim();
965
- // ── Agent path: spawn CLI agent or copy to clipboard ──
966
- if (agent) {
967
- const modelJson = serializeModel(ctx.model);
968
- const systemPrompt = FRAMEWORK_PROMPTS[fw];
969
- const userMessage = buildUserMessage(modelJson, fw, customPrompt);
970
- const analysisPrompt = `You are analyzing a codebase with GuardLink security annotations.
1042
+ }
1043
+ // ─── /threat-report ──────────────────────────────────────────────────
1044
+ /**
1045
+ * Build the full analysis prompt for CLI agents.
1046
+ * Includes system prompt, serialized model, project context, code snippets,
1047
+ * and instructions to read source code.
1048
+ */
1049
+ function buildAgentAnalysisPrompt(root, model, fw, customPrompt, reportLabel) {
1050
+ const modelJson = serializeModel(model);
1051
+ const projectContext = buildProjectContext(root);
1052
+ const codeSnippets = extractCodeSnippets(root, model);
1053
+ const systemPrompt = FRAMEWORK_PROMPTS[fw];
1054
+ const userMessage = buildUserMessage(modelJson, fw, customPrompt, projectContext || undefined, codeSnippets || undefined);
1055
+ return `You are analyzing a codebase with GuardLink security annotations.
971
1056
  You have access to the full source code in the current directory.
972
1057
 
973
1058
  ${systemPrompt}
974
1059
 
975
1060
  ## Task
976
- Read the source code and GuardLink annotations, then produce a thorough ${FRAMEWORK_LABELS[fw]}.
1061
+ Read the source code and GuardLink annotations, then produce a thorough ${reportLabel}.
977
1062
 
978
1063
  ## Threat Model (serialized from annotations)
979
1064
  ${userMessage}
@@ -982,51 +1067,128 @@ ${userMessage}
982
1067
  1. Read the actual source files to understand the code — don't just rely on the serialized model above
983
1068
  2. Cross-reference the annotations with the real code to validate findings
984
1069
  3. Produce the full report as markdown
985
- 4. Save the output to .guardlink/threat-reports/ with a timestamped filename
986
- 5. Be specificreference actual files, functions, and line numbers from the codebase`;
987
- console.log(` ${C.dim('Sending')} ${FRAMEWORK_LABELS[fw]} ${C.dim('to')} ${agent.name}${C.dim('...')}`);
1070
+ 4. Be specific reference actual files, functions, and line numbers from the codebase
1071
+ 5. Output ONLY the markdown report content do NOT add any metadata comments, save confirmations, or file path messages
1072
+ 6. Do NOT include lines like "Generated by...", "Agent:", "Project:", or "The report file write was blocked..."`;
1073
+ }
1074
+ /**
1075
+ * Save inline agent output as a threat report markdown file.
1076
+ */
1077
+ function saveInlineReport(root, content, fw, agentName, project, annotationCount) {
1078
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1079
+ const reportsDir = resolve(root, '.guardlink', 'threat-reports');
1080
+ if (!existsSync(reportsDir))
1081
+ mkdirSync(reportsDir, { recursive: true });
1082
+ const filename = `${timestamp}-${fw}.md`;
1083
+ const filepath = resolve(reportsDir, filename);
1084
+ const cleanedContent = cleanCliArtifacts(content);
1085
+ const header = `---
1086
+ framework: ${fw}
1087
+ label: ${FRAMEWORK_LABELS[fw]}
1088
+ model: ${agentName}
1089
+ timestamp: ${new Date().toISOString()}
1090
+ project: ${project}
1091
+ annotations: ${annotationCount}
1092
+ ---
1093
+
1094
+ # ${FRAMEWORK_LABELS[fw]}
1095
+
1096
+ > Generated by \`guardlink threat-report ${fw}\` on ${new Date().toISOString().slice(0, 10)}
1097
+ > Agent: ${agentName} | Project: ${project} | Annotations: ${annotationCount}
1098
+
1099
+ `;
1100
+ writeFileSync(filepath, header + cleanedContent + '\n');
1101
+ return `.guardlink/threat-reports/${filename}`;
1102
+ }
1103
+ export async function cmdThreatReport(args, ctx) {
1104
+ if (!ctx.model) {
1105
+ console.log(C.warn(' No threat model. Run /parse first.'));
1106
+ return;
1107
+ }
1108
+ // Parse any explicit --agent flag override
1109
+ const { agent: flagAgent, cleanArgs } = parseAgentFlag(args);
1110
+ const input = cleanArgs.trim();
1111
+ const validFrameworks = ['stride', 'dread', 'pasta', 'attacker', 'rapid', 'general'];
1112
+ // Show help when no arguments given
1113
+ if (!input) {
988
1114
  console.log('');
989
- // Use shared launcher — foreground for terminal agents, IDE open for others
990
- if (agent.cmd) {
991
- const copied = copyToClipboard(analysisPrompt);
992
- if (copied) {
993
- console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
994
- }
995
- console.log(` ${C.dim('Launching')} ${agent.name} ${C.dim('in foreground...')}`);
1115
+ console.log(` ${C.bold('Threat report frameworks:')}`);
1116
+ for (const fw of validFrameworks) {
1117
+ console.log(` ${C.bold('/threat-report ' + fw.padEnd(12))} ${C.dim(FRAMEWORK_LABELS[fw])}`);
1118
+ }
1119
+ console.log('');
1120
+ console.log(` ${C.bold('Custom prompt:')}`);
1121
+ console.log(C.dim(' /threat-report <any text> Uses your text as the analysis prompt'));
1122
+ console.log(C.dim(' Example: /threat-report Create a comprehensive report mixing STRIDE and DREAD'));
1123
+ console.log('');
1124
+ console.log(C.dim(' Uses the AI provider configured via /model (API or CLI agent).'));
1125
+ console.log(C.dim(' Override with: --claude-code --codex --gemini --clipboard'));
1126
+ console.log('');
1127
+ return;
1128
+ }
1129
+ // Determine framework vs custom prompt
1130
+ const inputLower = input.toLowerCase();
1131
+ const isStandard = validFrameworks.includes(inputLower);
1132
+ const fw = (isStandard ? inputLower : 'general');
1133
+ const customPrompt = isStandard ? undefined : input;
1134
+ const reportLabel = customPrompt ? 'Custom Threat Analysis' : FRAMEWORK_LABELS[fw];
1135
+ // ── Resolve execution method ──
1136
+ // Priority: explicit --flag > /model config > env-var API
1137
+ const tuiCfg = loadTuiConfig(ctx.root);
1138
+ // Resolve the agent to use (flag override or configured CLI agent)
1139
+ let agent = flagAgent;
1140
+ if (!agent && tuiCfg?.aiMode === 'cli-agent' && tuiCfg?.cliAgent) {
1141
+ agent = AGENTS.find(a => a.id === tuiCfg.cliAgent) || null;
1142
+ }
1143
+ // ── Path 1: CLI Agent (inline, non-interactive) ──
1144
+ if (agent && agent.cmd) {
1145
+ const analysisPrompt = buildAgentAnalysisPrompt(ctx.root, ctx.model, fw, customPrompt, reportLabel);
1146
+ console.log(` ${C.dim('Generating')} ${reportLabel} ${C.dim('via')} ${agent.name} ${C.dim('(inline)...')}`);
1147
+ console.log(C.dim(` Annotations: ${ctx.model.annotations_parsed} | Exposures: ${ctx.model.exposures.length}`));
1148
+ console.log('');
1149
+ const result = await launchAgentInline(agent, analysisPrompt, ctx.root, (text) => process.stdout.write(text), { autoYes: true });
1150
+ if (result.error) {
1151
+ console.log(C.error(`\n ✗ ${result.error}`));
996
1152
  console.log('');
997
- const result = launchAgent(agent, analysisPrompt, ctx.root);
998
- if (result.error) {
999
- console.log(C.error(` ✗ ${result.error}`));
1000
- }
1001
- else {
1002
- console.log(`\n ${C.success('✓')} ${agent.name} session ended.`);
1003
- console.log(` Run ${C.bold('/threat-reports')} to see saved results.`);
1004
- }
1153
+ return;
1005
1154
  }
1006
- else {
1007
- const result = launchAgent(agent, analysisPrompt, ctx.root);
1008
- if (result.clipboardCopied) {
1009
- console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
1010
- }
1011
- if (result.launched && agent.app) {
1012
- console.log(` ${C.success('✓')} ${agent.name} launched with project: ${ctx.projectName}`);
1013
- console.log(`\n Paste (Cmd+V) the prompt in ${agent.name}.`);
1014
- }
1015
- else if (result.error) {
1016
- console.log(C.error(` ✗ ${result.error}`));
1017
- }
1155
+ process.stdout.write('\n');
1156
+ // Save the agent's output as a report
1157
+ if (result.content.trim()) {
1158
+ const savedTo = saveInlineReport(ctx.root, result.content, fw, agent.name, ctx.model.project, ctx.model.annotations_parsed);
1159
+ console.log('');
1160
+ console.log(` ${C.success('✓')} Report saved to ${savedTo}`);
1018
1161
  }
1019
1162
  console.log('');
1020
1163
  return;
1021
1164
  }
1022
- // ── API path: direct LLM call ──
1165
+ // ── Path 2: Clipboard / IDE agent (copy prompt, open app) ──
1166
+ if (agent && !agent.cmd) {
1167
+ const analysisPrompt = buildAgentAnalysisPrompt(ctx.root, ctx.model, fw, customPrompt, reportLabel);
1168
+ const result = launchAgent(agent, analysisPrompt, ctx.root);
1169
+ if (result.clipboardCopied) {
1170
+ console.log(C.success(` ✓ Prompt copied to clipboard (${analysisPrompt.length.toLocaleString()} chars)`));
1171
+ }
1172
+ if (result.launched && agent.app) {
1173
+ console.log(` ${C.success('✓')} ${agent.name} launched with project: ${ctx.projectName}`);
1174
+ console.log(`\n Paste (Cmd+V) the prompt in ${agent.name}.`);
1175
+ }
1176
+ else if (result.error) {
1177
+ console.log(C.error(` ✗ ${result.error}`));
1178
+ }
1179
+ console.log('');
1180
+ return;
1181
+ }
1182
+ // ── Path 3: Direct API call ──
1023
1183
  const llmConfig = resolveLLMConfig(ctx.root);
1024
1184
  if (!llmConfig) {
1025
- console.log(C.warn(' No AI provider configured. Run /model first, or use --claude-code / --codex.'));
1185
+ console.log(C.warn(' No AI provider configured. Run /model first.'));
1026
1186
  console.log(C.dim(' Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in environment.'));
1187
+ console.log(C.dim(' Or use: /threat-report <prompt> --claude-code'));
1027
1188
  return;
1028
1189
  }
1029
- console.log(` ${C.dim('Generating report with')} ${llmConfig.model}${C.dim('...')}`);
1190
+ console.log(` ${C.dim('Generating')} ${reportLabel} ${C.dim('with')} ${llmConfig.model}${C.dim('...')}`);
1191
+ console.log(C.dim(` Annotations: ${ctx.model.annotations_parsed} | Exposures: ${ctx.model.exposures.length}`));
1030
1192
  console.log('');
1031
1193
  try {
1032
1194
  const result = await generateThreatReport({
@@ -1135,8 +1297,10 @@ export async function cmdAnnotate(args, ctx) {
1135
1297
  }
1136
1298
  // ─── Freeform AI Chat ────────────────────────────────────────────────
1137
1299
  export async function cmdChat(text, ctx) {
1300
+ const tuiCfg = loadTuiConfig(ctx.root);
1138
1301
  const llmConfig = resolveLLMConfig(ctx.root);
1139
- if (!llmConfig) {
1302
+ const useAgent = tuiCfg?.aiMode === 'cli-agent' && !!tuiCfg?.cliAgent;
1303
+ if (!useAgent && !llmConfig) {
1140
1304
  console.log(C.warn(' No AI provider configured. Run /model first, or set an API key in environment.'));
1141
1305
  return;
1142
1306
  }
@@ -1159,17 +1323,37 @@ Keep responses under 500 words unless the user asks for detail.`;
1159
1323
  };
1160
1324
  userMessage = `Threat model context:\n${JSON.stringify(compact, null, 2)}\n\nUser question: ${text}`;
1161
1325
  }
1162
- console.log('');
1163
- console.log(C.dim(` Thinking via ${llmConfig.model}...`));
1164
- console.log('');
1165
- try {
1166
- const { chatCompletion } = await import('../analyze/llm.js');
1167
- const response = await chatCompletion(llmConfig, systemPrompt, userMessage, (chunk) => process.stdout.write(chunk));
1168
- process.stdout.write('\n\n');
1326
+ if (useAgent) {
1327
+ const agent = AGENTS.find(a => a.id === tuiCfg.cliAgent);
1328
+ if (!agent) {
1329
+ console.log(C.error(` ✗ Configured agent ${tuiCfg.cliAgent} not found.`));
1330
+ return;
1331
+ }
1332
+ console.log('');
1333
+ console.log(C.dim(` Thinking via ${agent.name}...`));
1334
+ console.log('');
1335
+ const prompt = `${systemPrompt}\n\n${userMessage}`;
1336
+ const result = await launchAgentInline(agent, prompt, ctx.root, (chunk) => process.stdout.write(chunk), { autoYes: true });
1337
+ if (result.error) {
1338
+ console.log(C.error(`\n ✗ AI request failed: ${result.error}`));
1339
+ }
1340
+ else {
1341
+ console.log('\n');
1342
+ }
1169
1343
  }
1170
- catch (err) {
1171
- console.log(C.error(` ✗ AI request failed: ${err.message}`));
1344
+ else {
1345
+ console.log('');
1346
+ console.log(C.dim(` Thinking via ${llmConfig.model}...`));
1172
1347
  console.log('');
1348
+ try {
1349
+ const { chatCompletion } = await import('../analyze/llm.js');
1350
+ await chatCompletion(llmConfig, systemPrompt, userMessage, (chunk) => process.stdout.write(chunk));
1351
+ process.stdout.write('\n\n');
1352
+ }
1353
+ catch (err) {
1354
+ console.log(C.error(` ✗ AI request failed: ${err.message}`));
1355
+ console.log('');
1356
+ }
1173
1357
  }
1174
1358
  }
1175
1359
  // ─── /report ─────────────────────────────────────────────────────────