codehere 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +87 -168
- package/dist/application/use-cases/generate-narration.js +11 -0
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +211 -16
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +49 -18
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +38 -5
- package/dist/infrastructure/orchestration/codex-adapter.js +150 -22
- package/dist/infrastructure/orchestration/conductor-adapter.js +9 -3
- package/dist/infrastructure/orchestration/context-handoff.js +3 -2
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
- package/dist/infrastructure/orchestration/graphify-context.js +18 -13
- package/dist/infrastructure/orchestration/memory-injector.js +46 -1
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +230 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +112 -6
- package/dist/infrastructure/orchestration/session-persistence.js +140 -31
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1283 -13
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3913 -88
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
- package/dist/infrastructure/storage/project-thread-migration.js +2 -2
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +32 -1
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +18 -1
- package/package.json +5 -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
|
|
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('--
|
|
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
|
-
|
|
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
|
-
|
|
683
|
-
console.log(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
860
|
+
const event = JSON.parse(frame.slice(5).trim());
|
|
701
861
|
const type = String(event.type || 'message');
|
|
702
|
-
if (type === '
|
|
703
|
-
|
|
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=(
|
|
16
|
+
COMPREPLY=($(compgen -W "--help --version" -- \${cur}))
|
|
17
17
|
else
|
|
18
|
-
COMPREPLY=(
|
|
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
|