codehere 0.6.2 → 0.7.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 (70) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +28 -4
  4. package/dist/application/use-cases/generate-narration.js +11 -0
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +211 -16
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
  23. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  24. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  25. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  26. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  27. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  28. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  29. package/dist/infrastructure/orchestration/codex-adapter.js +146 -13
  30. package/dist/infrastructure/orchestration/context-handoff.js +3 -2
  31. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  32. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  33. package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
  34. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  35. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  36. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  37. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  38. package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
  39. package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
  40. package/dist/infrastructure/orchestration/session-manager.js +94 -6
  41. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  42. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  43. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  44. package/dist/infrastructure/protocols/cloud-sync.js +1 -6
  45. package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
  46. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  47. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  48. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  49. package/dist/infrastructure/protocols/verify-page.js +941 -0
  50. package/dist/infrastructure/security/trust-scorer.js +76 -1
  51. package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
  52. package/dist/infrastructure/storage/project-thread-migration.js +2 -2
  53. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  54. package/dist/monitoring.js +1 -1
  55. package/dist/presentation/cli/commands/audit-command.js +126 -0
  56. package/dist/presentation/cli/commands/config-command.js +2 -2
  57. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  58. package/dist/presentation/cli/commands/help-command.js +51 -1
  59. package/dist/presentation/cli/commands/report-command.js +6 -4
  60. package/dist/presentation/cli/commands/setup-command.js +71 -18
  61. package/dist/presentation/cli/commands/status-command.js +0 -4
  62. package/dist/presentation/cli/commands/thread-command.js +288 -0
  63. package/dist/presentation/cli/commands/verify-command.js +233 -0
  64. package/dist/presentation/cli/error-display.js +1 -1
  65. package/dist/presentation/cli/repl.js +32 -1
  66. package/dist/presentation/cli/task-deliverable.js +127 -0
  67. package/dist/testing/cassette.js +125 -0
  68. package/dist/testing/replay-adapter.js +166 -0
  69. package/dist/ui.js +18 -1
  70. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -39,6 +39,10 @@ import { executeNarratorCommand } from './presentation/cli/commands/narrator-com
39
39
  import { executeTrustCommand } from './presentation/cli/commands/trust-command.js';
40
40
  import { executeReportCommand } from './presentation/cli/commands/report-command.js';
41
41
  import { executeScanToolCommand } from './presentation/cli/commands/scan-tool-command.js';
42
+ import { executeVerifyCommand } from './presentation/cli/commands/verify-command.js';
43
+ import { executeAuditCommand } from './presentation/cli/commands/audit-command.js';
44
+ import { executeContextSourceCommand } from './presentation/cli/commands/context-source-command.js';
45
+ import { executeThreadCommand } from './presentation/cli/commands/thread-command.js';
42
46
  import { displayError } from './presentation/cli/error-display.js';
43
47
  import { performHealthCheck, formatHealthStatus, getMetrics } from './monitoring.js';
44
48
  import { colors, createBanner, createTable, createWelcomeMessage, success, error, info, sectionHeader, formatNumber, newline, isMinimalStyle, printMinimalHeader, } from './ui.js';
@@ -58,7 +62,7 @@ import { checkTypo, looksLikeUnknownCommand } from './utils/typo-guard.js';
58
62
  const program = new Command();
59
63
  program
60
64
  .name('codehere')
61
- .description('A PM for your AI coding agents. Delegate a task, watch it run, get a plain-English report with a trust score on every file.')
65
+ .description('A PM for your AI coding agents. Delegate a task, watch it run, and get a plain-English report with a trust score on every file it changed.')
62
66
  .version(getVersion())
63
67
  .configureHelp({ helpWidth: 80, sortSubcommands: true })
64
68
  // rc.18: many users hit `codehere --help` first and get Commander's flat
@@ -118,6 +122,10 @@ program
118
122
  }
119
123
  if (!process.stdin.isTTY) {
120
124
  console.log(createWelcomeMessage());
125
+ // Non-interactive (piped / scripted): the REPL can't run, so leave the
126
+ // user with ONE clear next step instead of a dead-end welcome screen.
127
+ console.log(colors.dim(' Next: ') + colors.cyan('codehere start') + colors.dim(' open the dashboard'));
128
+ console.log(colors.dim(' New here? ') + colors.cyan('codehere setup') + colors.dim(' one-time setup\n'));
121
129
  return;
122
130
  }
123
131
  const { runInteractiveRepl } = await import('./presentation/cli/repl.js');
@@ -354,6 +362,140 @@ Examples:
354
362
  process.exit(1);
355
363
  }
356
364
  });
365
+ // ── verify ───────────────────────────────────────────────────────────────────
366
+ // v0.7 verifier-agent (F-03). Catches the easy lies a Final Report makes
367
+ // against the structured artifacts recorded during the session.
368
+ program
369
+ .command('verify')
370
+ .description("Verify a session's Final Report against the recorded reality (catches optimistic claims)")
371
+ .argument('[session-id]', 'Session id (or short prefix) to verify. Omit to list recent sessions.')
372
+ .option('--json', 'Output machine-readable JSON instead of formatted findings')
373
+ .addHelpText('after', `
374
+ Examples:
375
+ $ codehere verify # list recent sessions to pick from
376
+ $ codehere verify a1b2c3d4 # verify by short id prefix
377
+ $ codehere verify a1b2c3d4 --json # machine-readable verdict for CI
378
+
379
+ Exit codes:
380
+ 0 = verified, partial (warnings only), or no Final Report to verify
381
+ 1 = broken (a claim contradicts recorded reality)
382
+ 2 = session not found
383
+ `)
384
+ .action(async (sessionId, options) => {
385
+ try {
386
+ // Per codex Round 2: set exitCode instead of calling process.exit so a
387
+ // piped --json consumer sees the full payload before Node tears down.
388
+ // The picker mode can write ~15 sessions worth of JSON · enough to
389
+ // exceed pipe buffer + race the process exit. exitCode preserves the
390
+ // intent (CI uses the exit code) without forcing an immediate teardown.
391
+ process.exitCode = await executeVerifyCommand(sessionId, { json: options.json });
392
+ }
393
+ catch (err) {
394
+ console.error(error('Error:'), err instanceof Error ? err.message : String(err));
395
+ process.exitCode = 1;
396
+ }
397
+ });
398
+ // ── audit ────────────────────────────────────────────────────────────────────
399
+ // F-05 of the demo v12 backlog · expose the hash-chain verifier as a CLI.
400
+ program
401
+ .command('audit')
402
+ .description('Inspect the hash-chained audit log · verify the chain or show one entry')
403
+ .argument('[subcommand]', 'verify | show', 'verify')
404
+ .argument('[arg]', 'For `show`: the sequence number to print.')
405
+ .option('--json', 'Output machine-readable JSON instead of formatted text')
406
+ .addHelpText('after', `
407
+ Examples:
408
+ $ codehere audit # equivalent to: codehere audit verify
409
+ $ codehere audit verify # walk the chain, report intact/tampered
410
+ $ codehere audit verify --json # machine-readable verdict for CI
411
+ $ codehere audit show 42 # print entry with seq=42
412
+ $ codehere audit show 42 --json
413
+
414
+ Exit codes:
415
+ 0 = chain intact OR entry found
416
+ 1 = chain BROKEN (one or more break points detected)
417
+ 2 = invalid input / no entry found
418
+ `)
419
+ .action((sub, arg, options) => {
420
+ try {
421
+ process.exitCode = executeAuditCommand(sub, arg, { json: options.json });
422
+ }
423
+ catch (err) {
424
+ console.error(error('Error:'), err instanceof Error ? err.message : String(err));
425
+ process.exitCode = 1;
426
+ }
427
+ });
428
+ // ── context-source ───────────────────────────────────────────────────────────
429
+ // v0.8 S2.5 · OAuth click-to-connect for Linear, Figma, etc.
430
+ program
431
+ .command('context-source')
432
+ .alias('cs')
433
+ .description('Manage context sources (Linear, Figma) · connect via OAuth, list status, disconnect')
434
+ .argument('[subcommand]', 'list | connect | disconnect', 'list')
435
+ .argument('[provider]', 'For connect/disconnect: linear | figma | ...')
436
+ .option('--json', 'Machine-readable output')
437
+ .option('--no-open', 'For connect: skip auto-opening the browser (just print the URL)')
438
+ .addHelpText('after', `
439
+ Examples:
440
+ $ codehere context-source # equivalent to: list
441
+ $ codehere context-source list
442
+ $ codehere context-source connect linear
443
+ $ codehere context-source connect figma --no-open
444
+ $ codehere context-source disconnect linear
445
+
446
+ Tokens persist at ~/.codehere/context-sources.json (0600 mode).
447
+ Requires the daemon running: codehere start
448
+ Provider client IDs come from env: CODEHERE_LINEAR_CLIENT_ID, CODEHERE_FIGMA_CLIENT_ID
449
+ `)
450
+ .action(async (sub, provider, options) => {
451
+ try {
452
+ process.exitCode = await executeContextSourceCommand(sub, provider, {
453
+ json: options.json,
454
+ open: options.open,
455
+ });
456
+ }
457
+ catch (err) {
458
+ console.error(error('Error:'), err instanceof Error ? err.message : String(err));
459
+ process.exitCode = 1;
460
+ }
461
+ });
462
+ // ── thread ───────────────────────────────────────────────────────────────────
463
+ // v0.8 S4 · signed-payload "GitHub for agent sessions" · share/import threads
464
+ program
465
+ .command('thread')
466
+ .description('Share or import a session · Ed25519-signed bundle · local-first')
467
+ .argument('<subcommand>', 'export | import | keys | trust | untrust')
468
+ .argument('[arg]', 'For export: session-id · For import/trust/untrust: file path or pubkey')
469
+ .argument('[label]', 'For trust: an optional human label for the key')
470
+ .option('--out <file>', 'For export: write the signed bundle to a file (default: stdout)')
471
+ .option('--trust', 'For import: trust the signer key automatically if unknown')
472
+ .option('--json', 'Machine-readable output')
473
+ .addHelpText('after', `
474
+ Examples:
475
+ $ codehere thread export a1b2c3d4 --out demo-thread.json
476
+ $ codehere thread import ./shared.json --trust
477
+ $ codehere thread keys
478
+ $ codehere thread trust <pubkey-base64> "Daniel"
479
+ $ codehere thread untrust <pubkey-base64>
480
+
481
+ Bundles include: spawned · steps · artifacts · finalReport · signature.
482
+ No source code · only the synthesized record.
483
+ Recipient must trust the signer key (or pass --trust) before replay.
484
+ `)
485
+ .action((sub, arg, label, options) => {
486
+ try {
487
+ process.exitCode = executeThreadCommand(sub, arg, label, {
488
+ out: options.out,
489
+ trust: options.trust,
490
+ label,
491
+ json: options.json,
492
+ });
493
+ }
494
+ catch (err) {
495
+ console.error(error('Error:'), err instanceof Error ? err.message : String(err));
496
+ process.exitCode = 1;
497
+ }
498
+ });
357
499
  // ── hook ─────────────────────────────────────────────────────────────────────
358
500
  program
359
501
  .command('hook')
@@ -638,10 +780,18 @@ program
638
780
  // ── task (spawn an agent session via CLI) ────────────────────────────────────
639
781
  program
640
782
  .command('task <prompt...>')
641
- .description('Assign a task — spawns the default agent under codehere. Requires `codehere start` running.')
783
+ .description('Assign a task — spawns an agent under codehere. Requires `codehere start` running.')
642
784
  .option('-p, --port <number>', 'Daemon port', '3773')
643
785
  .option('--host <host>', 'Daemon host', '127.0.0.1')
644
- .option('--watch', 'Stream events to the terminal (default: just returns the session id)')
786
+ .option('--agent <id>', 'Which agent to run (claude-code, codex, aider, gemini, opencode). Default: auto.')
787
+ .option('--watch', 'Watch it work and print the report when it finishes')
788
+ .addHelpText('after', `
789
+ Examples:
790
+ $ codehere task "fix the totalValue bug in inventory.js" # assign, get a session id
791
+ $ codehere task "add a health check route" --watch # watch it work + see the report
792
+ $ codehere task "summarize recent changes" --agent codex --watch # pick the agent
793
+
794
+ Needs a running daemon: start one with \`codehere start\` (or \`codehere serve\`).`)
645
795
  .action(async (promptParts, options) => {
646
796
  const prompt = promptParts.join(' ').trim();
647
797
  if (!prompt) {
@@ -652,8 +802,9 @@ program
652
802
  const host = options?.host ?? '127.0.0.1';
653
803
  const base = `http://${host}:${port}`;
654
804
  const http = await import('http');
805
+ const { waitForDeliverable, formatDeliverable } = await import('./presentation/cli/task-deliverable.js');
655
806
  const spawnResult = await new Promise((resolve) => {
656
- const body = JSON.stringify({ prompt });
807
+ const body = JSON.stringify({ prompt, ...(options?.agent ? { agent: options.agent } : {}) });
657
808
  const r = http.request(`${base}/api/task`, {
658
809
  method: 'POST',
659
810
  headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
@@ -676,16 +827,26 @@ program
676
827
  r.end();
677
828
  });
678
829
  if (!spawnResult || !spawnResult.sessionId) {
679
- console.error(error(`Could not reach daemon at ${base}. Start it with: codehere start`));
830
+ if (spawnResult?.error)
831
+ console.error(error(spawnResult.error));
832
+ else
833
+ console.error(error(`Could not reach the daemon at ${base}. Start it with: codehere start`));
680
834
  process.exit(1);
681
835
  }
682
- console.log(success(`Task assigned · session ${spawnResult.sessionId}`));
683
- console.log(colors.dim(` Watch: ${base}/ · Kill: codehere kill ${spawnResult.sessionId}`));
684
- if (!options?.watch)
836
+ const sid = spawnResult.sessionId;
837
+ console.log(success(`Task assigned · session ${sid.slice(0, 8)}`));
838
+ if (!options?.watch) {
839
+ console.log(colors.dim(` Watch in the browser: ${base}/`));
840
+ console.log(colors.dim(` Or in the terminal: codehere task "${prompt}" --watch`));
841
+ console.log(colors.dim(` Stop it: codehere kill ${sid.slice(0, 8)}`));
685
842
  return;
686
- const streamUrl = `${base}/api/task/${spawnResult.sessionId}/events`;
843
+ }
844
+ // --watch · render the agent's actual steps as they complete, then print
845
+ // the deliverable (report + files + trust + verifier) when it's done.
846
+ console.log(colors.dim(` Watching… (Ctrl-C to stop watching; the agent keeps running)`));
847
+ const printed = new Set();
687
848
  await new Promise((resolve) => {
688
- const r = http.get(streamUrl, (res) => {
849
+ const r = http.get(`${base}/api/task/${sid}/events`, (res) => {
689
850
  let buf = '';
690
851
  res.on('data', (c) => {
691
852
  buf += c.toString();
@@ -695,20 +856,37 @@ program
695
856
  buf = buf.slice(i + 2);
696
857
  if (!frame.startsWith('data:'))
697
858
  continue;
698
- const payload = frame.slice(5).trim();
699
859
  try {
700
- const event = JSON.parse(payload);
860
+ const event = JSON.parse(frame.slice(5).trim());
701
861
  const type = String(event.type || 'message');
702
- if (type === 'exit') {
703
- console.log(colors.dim(` [exit] ${String(event.data?.status ?? '?')}`));
862
+ if (type === 'codehere:steps') {
863
+ const steps = (event.data?.steps ?? []);
864
+ for (const s of steps) {
865
+ if (s.state !== 'done' && s.state !== 'failed')
866
+ continue;
867
+ if (printed.has(s.label))
868
+ continue; // one line per step, on first settle
869
+ printed.add(s.label);
870
+ const mark = s.state === 'failed' ? colors.error('✗') : colors.success('✓');
871
+ const text = s.kind === 'say' ? colors.dim(s.label) : s.label;
872
+ console.log(` ${mark} ${text}`);
873
+ }
874
+ }
875
+ else if (type === 'codehere:error') {
876
+ const d = event.data;
877
+ if (d?.message)
878
+ console.log(` ${colors.error('!')} ${d.message}`);
879
+ if (d?.recovery)
880
+ console.log(colors.dim(` ${d.recovery}`));
881
+ }
882
+ else if (type === 'exit') {
704
883
  res.destroy();
705
884
  resolve();
706
885
  return;
707
886
  }
708
- console.log(colors.dim(` [${type}]`));
709
887
  }
710
888
  catch {
711
- /* skip */
889
+ /* skip malformed frame */
712
890
  }
713
891
  }
714
892
  });
@@ -716,6 +894,23 @@ program
716
894
  });
717
895
  r.on('error', () => resolve());
718
896
  });
897
+ // The Final Report is generated after exit — poll for it, then print.
898
+ // Real agents take ~30-60s to write it, so tell the user we're waiting.
899
+ process.stdout.write(colors.dim(' Writing the report…'));
900
+ const result = await waitForDeliverable(base, sid);
901
+ process.stdout.write('\r' + ' '.repeat(24) + '\r'); // clear the waiting line
902
+ if (result && (result.finalReport || result.failedReport || result.status !== 'completed')) {
903
+ console.log(formatDeliverable(result));
904
+ }
905
+ else if (result) {
906
+ // Completed, but the report is still generating past our wait window.
907
+ console.log(formatDeliverable(result));
908
+ console.log(colors.dim(' (the written report is still being generated — run the command below in a moment)'));
909
+ }
910
+ else {
911
+ console.log(colors.dim('\n Task finished, but the report could not be fetched from the daemon.'));
912
+ }
913
+ console.log(colors.dim(`Next: codehere verify ${sid.slice(0, 8)} (see this report + the fact-check anytime)`));
719
914
  });
720
915
  // ── kill (stop a running task) ───────────────────────────────────────────────
721
916
  program
@@ -13,9 +13,9 @@ _complete_codehere() {
13
13
  opts="ask plan orchestrate fix suggest run review status help version"
14
14
 
15
15
  if [[ \${cur} == -* ]]; then
16
- COMPREPLY=(\$(compgen -W "--help --version" -- \${cur}))
16
+ COMPREPLY=($(compgen -W "--help --version" -- \${cur}))
17
17
  else
18
- COMPREPLY=(\$(compgen -W "\${opts}" -- \${cur}))
18
+ COMPREPLY=($(compgen -W "\${opts}" -- \${cur}))
19
19
  fi
20
20
  }
21
21
 
@@ -103,7 +103,7 @@ export class ConfigManager {
103
103
  writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8');
104
104
  }
105
105
  catch (error) {
106
- throw new Error(`Failed to save config to ${this.configPath}: ${error instanceof Error ? error.message : String(error)}`);
106
+ throw new Error(`Failed to save config to ${this.configPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
107
107
  }
108
108
  }
109
109
  /**
@@ -0,0 +1,157 @@
1
+ /**
2
+ * draw.io context-source adapter (v0.8 S5 · third reference adapter).
3
+ *
4
+ * URL shapes accepted (we route both via the same scheme to keep the
5
+ * agent prompt forgiving):
6
+ * - `drawio://<gist-or-raw-url-base64>` · base64url-encoded source URL
7
+ * - `drawio://gh/<owner>/<repo>/<path>` · raw GitHub-hosted .drawio XML
8
+ * - `drawio://url/<urlencoded-https-url>` · arbitrary HTTPS-hosted .drawio
9
+ *
10
+ * No OAuth · draw.io is a client-side editor, the file itself is the
11
+ * payload. We fetch the raw XML, summarize node + edge counts, and emit
12
+ * the first ~3KB of the XML so the agent can read structure without us
13
+ * doing graphviz-style parsing here. Bigger diagrams get a `...truncated`
14
+ * suffix; agents can ask the human for a screenshot if they need pixels.
15
+ *
16
+ * Local-first: no codehere endpoint is contacted. Network call goes
17
+ * directly to GitHub-raw / the supplied HTTPS host. Audit chain records
18
+ * fetch + byte count, never content.
19
+ */
20
+ import { ContextSourceError, estimateTokens, } from '../context-source.js';
21
+ const MAX_XML_CHARS = 3000;
22
+ const MAX_RESPONSE_BYTES = 1_500_000; // 1.5MB · refuse anything bigger to protect prompt budget
23
+ /** Turn the codehere drawio:// URL into a concrete HTTPS fetch target. */
24
+ function resolveTarget(url) {
25
+ const raw = url.toString();
26
+ const after = raw.slice('drawio://'.length).replace(/^\/+/, '');
27
+ if (!after) {
28
+ throw new ContextSourceError('drawio', 'invalid_url', 'drawio URL missing path');
29
+ }
30
+ // gh/<owner>/<repo>/<path...>
31
+ if (after.startsWith('gh/')) {
32
+ const parts = after.slice('gh/'.length).split('/');
33
+ if (parts.length < 3) {
34
+ throw new ContextSourceError('drawio', 'invalid_url', 'gh/ path needs <owner>/<repo>/<path>');
35
+ }
36
+ const [owner, repo, ...rest] = parts;
37
+ const path = rest.join('/');
38
+ if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) {
39
+ throw new ContextSourceError('drawio', 'invalid_url', 'gh/ owner/repo must be alphanumeric');
40
+ }
41
+ return {
42
+ fetchUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/${path}`,
43
+ label: `github.com/${owner}/${repo}/${path}`,
44
+ };
45
+ }
46
+ // url/<urlencoded-https-url>
47
+ if (after.startsWith('url/')) {
48
+ const encoded = after.slice('url/'.length);
49
+ const decoded = safeDecode(encoded);
50
+ if (!/^https:\/\//.test(decoded)) {
51
+ throw new ContextSourceError('drawio', 'invalid_url', 'url/ target must be https://');
52
+ }
53
+ return { fetchUrl: decoded, label: decoded };
54
+ }
55
+ // base64url-encoded full URL
56
+ const decoded = safeBase64UrlDecode(after);
57
+ if (decoded && /^https:\/\//.test(decoded)) {
58
+ return { fetchUrl: decoded, label: decoded };
59
+ }
60
+ throw new ContextSourceError('drawio', 'invalid_url', 'use drawio://gh/<owner>/<repo>/<path> · drawio://url/<urlencoded-https> · or drawio://<base64url-https>');
61
+ }
62
+ function safeDecode(s) {
63
+ try {
64
+ return decodeURIComponent(s);
65
+ }
66
+ catch {
67
+ return '';
68
+ }
69
+ }
70
+ function safeBase64UrlDecode(s) {
71
+ try {
72
+ const pad = '='.repeat((4 - (s.length % 4)) % 4);
73
+ return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64').toString('utf-8');
74
+ }
75
+ catch {
76
+ return '';
77
+ }
78
+ }
79
+ export const drawioSource = {
80
+ id: 'drawio',
81
+ label: 'draw.io',
82
+ scheme: 'drawio',
83
+ description: 'Fetch a draw.io / diagrams.net diagram XML (architecture, flowchart, etc.)',
84
+ isConfigured() {
85
+ // Public-by-URL · no auth needed. Always available.
86
+ return true;
87
+ },
88
+ async fetch(url, ctx) {
89
+ const target = resolveTarget(url);
90
+ const res = await ctx.httpGet(target.fetchUrl, { 'User-Agent': 'codehere/0.8' });
91
+ if (res.status === 404) {
92
+ throw new ContextSourceError('drawio', 'not_found', `diagram not found at ${target.label}`);
93
+ }
94
+ if (res.status === 429) {
95
+ throw new ContextSourceError('drawio', 'rate_limit', `host returned 429 for ${target.label}`);
96
+ }
97
+ if (res.status !== 200) {
98
+ throw new ContextSourceError('drawio', 'network', `host returned ${res.status} for ${target.label}: ${res.body.slice(0, 200)}`);
99
+ }
100
+ if (res.body.length > MAX_RESPONSE_BYTES) {
101
+ throw new ContextSourceError('drawio', 'parse', `diagram too big (${res.body.length} bytes · limit ${MAX_RESPONSE_BYTES})`);
102
+ }
103
+ if (!looksLikeDrawio(res.body)) {
104
+ throw new ContextSourceError('drawio', 'parse', 'response does not look like a draw.io XML or SVG file');
105
+ }
106
+ const stats = quickStats(res.body);
107
+ const text = renderDiagramAsPlainText(target, res.body, stats);
108
+ return {
109
+ url: url.toString(),
110
+ source: 'drawio',
111
+ fetchedAt: new Date(ctx.now ? ctx.now() : Date.now()).toISOString(),
112
+ text,
113
+ estimatedTokens: estimateTokens(text),
114
+ metadata: {
115
+ label: target.label,
116
+ bytes: res.body.length,
117
+ cellCount: stats.cellCount,
118
+ edgeCount: stats.edgeCount,
119
+ pageCount: stats.pageCount,
120
+ },
121
+ };
122
+ },
123
+ };
124
+ /** Does this payload look like a draw.io diagram (XML) or SVG export?
125
+ * Accept any prolog (whitespace, BOM, `<?xml ...?>`) before the first
126
+ * meaningful tag · `<mxfile`, `<mxGraphModel`, or `<svg`. */
127
+ function looksLikeDrawio(body) {
128
+ const trimmed = body.replace(/^\uFEFF/, '').trimStart();
129
+ const stripped = trimmed.startsWith('<?') ? trimmed.slice(trimmed.indexOf('?>') + 2).trimStart() : trimmed;
130
+ return /^<(mxfile|mxGraphModel|svg)\b/i.test(stripped);
131
+ }
132
+ /** Cheap regex stats · we don't ship a real XML parser. */
133
+ function quickStats(xml) {
134
+ const cellMatches = xml.match(/<mxCell\b/g);
135
+ const edgeMatches = xml.match(/<mxCell\b[^>]*\bedge="1"/g);
136
+ const pageMatches = xml.match(/<diagram\b/g);
137
+ return {
138
+ cellCount: cellMatches ? cellMatches.length : 0,
139
+ edgeCount: edgeMatches ? edgeMatches.length : 0,
140
+ pageCount: pageMatches ? pageMatches.length : 1,
141
+ };
142
+ }
143
+ function renderDiagramAsPlainText(target, xml, stats) {
144
+ const lines = [];
145
+ lines.push(`draw.io diagram · ${target.label}`);
146
+ lines.push('');
147
+ lines.push(`structure: ${stats.cellCount} cells · ${stats.edgeCount} edges · ${stats.pageCount} page${stats.pageCount === 1 ? '' : 's'}`);
148
+ lines.push(`bytes: ${xml.length}`);
149
+ lines.push('');
150
+ lines.push('--- raw xml (truncated) ---');
151
+ lines.push(xml.slice(0, MAX_XML_CHARS));
152
+ if (xml.length > MAX_XML_CHARS) {
153
+ lines.push(`...[truncated · diagram is ${xml.length} chars · ask the human for a screenshot if structure isn't clear]`);
154
+ }
155
+ return lines.join('\n');
156
+ }
157
+ //# sourceMappingURL=drawio-source.js.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Figma context-source adapter (v0.8 S1 · second reference adapter).
3
+ *
4
+ * URL shapes:
5
+ * figma://file/<file-key> · whole-file metadata + top-level frames
6
+ * figma://file/<file-key>/node/<node-id> · one node + its children, flattened
7
+ *
8
+ * Returns the design tree as indented plain text: frame names, types,
9
+ * dimensions, text content of TEXT nodes, fill colors when they're solid.
10
+ * Generated previews + raster bytes are skipped · agents read text, not
11
+ * pixels (overlay-renderer in v0.9 will surface raw frames separately).
12
+ *
13
+ * Auth: ctx.config['figma'] = { token: '<personal-access-token>' }
14
+ * Token comes from figma.com/developers/api · personal access token.
15
+ */
16
+ import { ContextSourceError, estimateTokens, } from '../context-source.js';
17
+ const FIGMA_API = 'https://api.figma.com/v1';
18
+ const MAX_TREE_NODES = 200;
19
+ const MAX_TEXT_PER_NODE = 240;
20
+ function parsePath(url) {
21
+ // figma://file/<key> · hostname = 'file', pathname = '/<key>'
22
+ // figma://file/<key>/node/<id>
23
+ const host = url.host || url.hostname; // 'file'
24
+ if (host !== 'file') {
25
+ throw new ContextSourceError('figma', 'invalid_url', `URL must start with figma://file/<key>; got host='${host}'`);
26
+ }
27
+ const parts = url.pathname.split('/').filter(Boolean);
28
+ if (!parts.length) {
29
+ throw new ContextSourceError('figma', 'invalid_url', 'missing file key in figma:// URL');
30
+ }
31
+ const fileKey = parts[0];
32
+ let nodeId = null;
33
+ if (parts.length >= 3 && parts[1] === 'node') {
34
+ nodeId = decodeURIComponent(parts.slice(2).join('/'));
35
+ }
36
+ return { fileKey, nodeId };
37
+ }
38
+ export const figmaSource = {
39
+ id: 'figma',
40
+ label: 'Figma',
41
+ scheme: 'figma',
42
+ description: 'Fetch a Figma file or single node (tree, dimensions, text content)',
43
+ isConfigured(ctx) {
44
+ const cfg = ctx.config['figma'];
45
+ if (!cfg)
46
+ return false;
47
+ if (cfg.oauth?.accessToken)
48
+ return true;
49
+ return typeof cfg.token === 'string' && cfg.token.length > 8;
50
+ },
51
+ async fetch(url, ctx) {
52
+ const cfg = ctx.config['figma'] ?? {};
53
+ // Figma supports BOTH a personal-access token (X-Figma-Token header) AND
54
+ // an OAuth bearer (Authorization: Bearer ...) · pick whichever is set.
55
+ let headers;
56
+ if (cfg.oauth?.accessToken) {
57
+ headers = { Authorization: `Bearer ${cfg.oauth.accessToken}` };
58
+ }
59
+ else if (cfg.token) {
60
+ headers = { 'X-Figma-Token': cfg.token };
61
+ }
62
+ else {
63
+ throw new ContextSourceError('figma', 'auth', 'no token or OAuth in ctx.config["figma"]');
64
+ }
65
+ const { fileKey, nodeId } = parsePath(url);
66
+ const endpoint = cfg.endpoint ?? FIGMA_API;
67
+ let res;
68
+ if (nodeId) {
69
+ const target = `${endpoint}/files/${encodeURIComponent(fileKey)}/nodes?ids=${encodeURIComponent(nodeId)}`;
70
+ res = await ctx.httpGet(target, headers);
71
+ }
72
+ else {
73
+ const target = `${endpoint}/files/${encodeURIComponent(fileKey)}?depth=2`;
74
+ res = await ctx.httpGet(target, headers);
75
+ }
76
+ if (res.status === 401 || res.status === 403) {
77
+ throw new ContextSourceError('figma', 'auth', `Figma returned ${res.status}`);
78
+ }
79
+ if (res.status === 404) {
80
+ throw new ContextSourceError('figma', 'not_found', `file ${fileKey} not found`);
81
+ }
82
+ if (res.status === 429) {
83
+ throw new ContextSourceError('figma', 'rate_limit', 'Figma rate limit hit');
84
+ }
85
+ if (res.status !== 200) {
86
+ throw new ContextSourceError('figma', 'network', `Figma returned ${res.status}: ${res.body.slice(0, 200)}`);
87
+ }
88
+ let text;
89
+ let metadata;
90
+ if (nodeId) {
91
+ const parsed = JSON.parse(res.body);
92
+ if (parsed.err)
93
+ throw new ContextSourceError('figma', 'parse', `Figma error: ${parsed.err}`);
94
+ const node = parsed.nodes?.[nodeId]?.document;
95
+ if (!node)
96
+ throw new ContextSourceError('figma', 'not_found', `node ${nodeId} not in response`);
97
+ text = renderNodeAsTree(node, fileKey, nodeId);
98
+ metadata = { fileKey, nodeId, type: node.type, name: node.name };
99
+ }
100
+ else {
101
+ const parsed = JSON.parse(res.body);
102
+ if (parsed.err)
103
+ throw new ContextSourceError('figma', 'parse', `Figma error: ${parsed.err}`);
104
+ if (!parsed.document)
105
+ throw new ContextSourceError('figma', 'parse', 'Figma response missing document');
106
+ text = renderFile(parsed, fileKey);
107
+ metadata = { fileKey, name: parsed.name ?? null, lastModified: parsed.lastModified ?? null };
108
+ }
109
+ return {
110
+ url: url.toString(),
111
+ source: 'figma',
112
+ fetchedAt: new Date(ctx.now ? ctx.now() : Date.now()).toISOString(),
113
+ text,
114
+ estimatedTokens: estimateTokens(text),
115
+ metadata,
116
+ };
117
+ },
118
+ };
119
+ function renderFile(file, fileKey) {
120
+ const lines = [];
121
+ lines.push(`Figma file · ${file.name ?? '(unnamed)'}`);
122
+ lines.push(`https://www.figma.com/file/${fileKey}`);
123
+ if (file.lastModified)
124
+ lines.push(`updated: ${file.lastModified}`);
125
+ lines.push('');
126
+ if (file.document) {
127
+ lines.push('--- top-level tree (depth 2) ---');
128
+ renderInto(lines, file.document, 0, { remaining: MAX_TREE_NODES });
129
+ }
130
+ return lines.join('\n');
131
+ }
132
+ function renderNodeAsTree(node, fileKey, nodeId) {
133
+ const lines = [];
134
+ lines.push(`Figma node · ${node.name} (${node.type})`);
135
+ lines.push(`https://www.figma.com/file/${fileKey}?node-id=${encodeURIComponent(nodeId)}`);
136
+ lines.push('');
137
+ renderInto(lines, node, 0, { remaining: MAX_TREE_NODES });
138
+ return lines.join('\n');
139
+ }
140
+ function renderInto(out, node, depth, budget) {
141
+ if (budget.remaining <= 0) {
142
+ if (budget.remaining === 0) {
143
+ out.push('...[tree truncated · node budget reached]');
144
+ budget.remaining = -1;
145
+ }
146
+ return;
147
+ }
148
+ budget.remaining -= 1;
149
+ const indent = ' '.repeat(depth);
150
+ const bb = node.absoluteBoundingBox;
151
+ const dim = bb ? ` ${Math.round(bb.width)}×${Math.round(bb.height)}` : '';
152
+ const visible = node.visible === false ? ' [hidden]' : '';
153
+ let line = `${indent}${node.type} · ${node.name}${dim}${visible}`;
154
+ if (node.characters) {
155
+ const t = node.characters.replace(/\s+/g, ' ').slice(0, MAX_TEXT_PER_NODE);
156
+ line += ` · "${t}${node.characters.length > MAX_TEXT_PER_NODE ? '…' : ''}"`;
157
+ }
158
+ out.push(line);
159
+ for (const child of node.children ?? []) {
160
+ renderInto(out, child, depth + 1, budget);
161
+ }
162
+ }
163
+ //# sourceMappingURL=figma-source.js.map