@stitchdb/cli 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +373 -12
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,32 +2,63 @@
2
2
  /**
3
3
  * `stitch` — CLI for the Stitch memory + agent control plane.
4
4
  *
5
- * stitch login Save your API key locally.
6
- * stitch logout Remove the saved key.
7
- * stitch whoami Show the configured key (masked).
5
+ * stitch login Save your API key locally.
6
+ * stitch logout Remove the saved key.
7
+ * stitch whoami Show the configured key (masked).
8
+ *
9
+ * stitch install One-shot: install Stitch as an MCP
10
+ * server in Claude Code, wire auto-log
11
+ * hooks, write a CLAUDE.md instruction
12
+ * in the current project so Claude
13
+ * auto-recalls past context at session start.
8
14
  *
9
15
  * stitch remember <text> [--kind X] [--tag a,b]
10
16
  * stitch recall <query> [-k 5]
11
17
  *
12
- * stitch agent register <name> Create a new agent identity.
13
- * stitch agent list List your agents (online status).
14
- * stitch agent run --id <agent_id> Hold open a control channel + execute dispatched commands.
15
- * stitch agent dispatch <id> <prompt> Send a command to a connected agent.
16
- * stitch agent revoke <id> Revoke an agent.
18
+ * stitch thread append --role <r> [--thread <t>] [--content <c>]
19
+ * Append a turn (or read content from stdin).
20
+ * Used by Claude Code hooks for auto-logging.
21
+ * stitch thread recall [--thread <t>] [--last 10] [--semantic <q>]
22
+ * stitch thread current Print the thread name for the current repo / cwd.
23
+ *
24
+ * stitch agent register <name> Create a new agent identity.
25
+ * stitch agent list List your agents (online status).
26
+ * stitch agent run --id <agent_id> Hold open a control channel.
27
+ * stitch agent dispatch <id> <prompt> Send a command to a connected agent.
28
+ * stitch agent revoke <id> Revoke an agent.
17
29
  *
18
30
  * stitch workspace create <name>
19
31
  * stitch workspace list
20
- * stitch workspace use <id> Set the default workspace.
32
+ * stitch workspace use <id>
21
33
  *
22
- * stitch mcp-install [--id <agent_id>] Print the `claude mcp add stitch …` command for your workspace.
34
+ * stitch mcp-install Print the `claude mcp add stitch …` command.
23
35
  */
24
36
  import * as fs from 'node:fs';
25
37
  import * as path from 'node:path';
26
38
  import * as os from 'node:os';
27
- import { spawn } from 'node:child_process';
39
+ import { spawn, spawnSync } from 'node:child_process';
40
+ import { fileURLToPath } from 'node:url';
28
41
  import { Stitch } from '@stitchdb/agent';
29
42
  const CONFIG_DIR = path.join(os.homedir(), '.stitch');
30
43
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
44
+ const UPDATE_CACHE = path.join(CONFIG_DIR, 'update-check.json');
45
+ // Embedded at build time from package.json, but keeps a fallback if the file's
46
+ // not next to the bundle (e.g. published tarball layout).
47
+ const CLI_VERSION = readSelfVersion() ?? '0.0.0';
48
+ function readSelfVersion() {
49
+ try {
50
+ const here = path.dirname(fileURLToPath(import.meta.url));
51
+ for (const candidate of [path.join(here, '..', 'package.json'), path.join(here, 'package.json')]) {
52
+ if (fs.existsSync(candidate)) {
53
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
54
+ if (pkg.name === '@stitchdb/cli' && typeof pkg.version === 'string')
55
+ return pkg.version;
56
+ }
57
+ }
58
+ }
59
+ catch { /* ignore */ }
60
+ return null;
61
+ }
31
62
  function loadConfig() {
32
63
  try {
33
64
  return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
@@ -306,6 +337,311 @@ async function cmdAgent(args) {
306
337
  console.error('Usage: stitch agent [register|list|run|dispatch|revoke] …');
307
338
  process.exit(2);
308
339
  }
340
+ // ── Threads — append / recall / current ───────────────────────────────────
341
+ function inferThread() {
342
+ // Reasonable default: <git-repo-name>/<branch> if we're in a git repo,
343
+ // else the cwd's basename. Hooks call this to get a stable thread per project.
344
+ try {
345
+ const dir = process.cwd();
346
+ let cur = dir;
347
+ for (let i = 0; i < 8; i++) {
348
+ if (fs.existsSync(path.join(cur, '.git'))) {
349
+ const parent = path.dirname(cur);
350
+ const repoName = path.basename(cur);
351
+ let branch = 'main';
352
+ try {
353
+ const head = fs.readFileSync(path.join(cur, '.git', 'HEAD'), 'utf8').trim();
354
+ const m = head.match(/^ref:\s+refs\/heads\/(.+)$/);
355
+ if (m)
356
+ branch = m[1];
357
+ }
358
+ catch { /* detached */ }
359
+ return `${repoName}/${branch}`;
360
+ void parent;
361
+ }
362
+ const next = path.dirname(cur);
363
+ if (next === cur)
364
+ break;
365
+ cur = next;
366
+ }
367
+ return path.basename(dir);
368
+ }
369
+ catch {
370
+ return 'default';
371
+ }
372
+ }
373
+ async function cmdThread(args) {
374
+ const cfg = loadConfig();
375
+ const stitch = client(cfg);
376
+ const sub = args[0];
377
+ const rest = args.slice(1);
378
+ if (sub === 'append') {
379
+ const role = parseFlag(rest, ['--role']);
380
+ let thread = parseFlag(rest, ['--thread', '-t']) || inferThread();
381
+ let content = parseFlag(rest, ['--content', '-c']);
382
+ if (!content) {
383
+ // If no --content given, read from stdin (so a hook can pipe in the message).
384
+ content = await readStdinAll();
385
+ }
386
+ if (!role) {
387
+ console.error('Usage: stitch thread append --role user|assistant --content <text> [--thread name]');
388
+ process.exit(2);
389
+ }
390
+ if (!content) {
391
+ console.error('No content provided (use --content or pipe stdin).');
392
+ process.exit(2);
393
+ }
394
+ const out = await stitch.thread(thread).append({ role, content });
395
+ console.log(`Appended ${out.turn_id} to ${thread} (${out.thread_id})`);
396
+ return;
397
+ }
398
+ if (sub === 'recall') {
399
+ const positionals = positional(rest);
400
+ const thread = parseFlag(rest, ['--thread', '-t']) || positionals[0] || inferThread();
401
+ const last = Number(parseFlag(rest, ['--last']) || '10');
402
+ const semantic = parseFlag(rest, ['--semantic']);
403
+ const out = await stitch.thread(thread).recall({ last, semantic, semantic_k: 5 });
404
+ if (!out.thread_id) {
405
+ console.log('Thread not found.');
406
+ return;
407
+ }
408
+ if (out.recent.length === 0 && out.semantic.length === 0) {
409
+ console.log('Empty thread.');
410
+ return;
411
+ }
412
+ if (out.recent.length) {
413
+ console.log(`# Recent (${out.recent.length})`);
414
+ for (const t of out.recent)
415
+ console.log(`- ${t.role}: ${t.content}`);
416
+ }
417
+ if (out.semantic.length) {
418
+ console.log(`\n# Semantic matches`);
419
+ for (const t of out.semantic)
420
+ console.log(`- ${t.role} (score ${(t.score ?? 0).toFixed(3)}): ${t.content}`);
421
+ }
422
+ return;
423
+ }
424
+ if (sub === 'current') {
425
+ console.log(inferThread());
426
+ return;
427
+ }
428
+ console.error('Usage: stitch thread [append|recall|current] …');
429
+ process.exit(2);
430
+ }
431
+ function readStdinAll() {
432
+ return new Promise((resolve) => {
433
+ let data = '';
434
+ if (process.stdin.isTTY) {
435
+ resolve('');
436
+ return;
437
+ }
438
+ process.stdin.setEncoding('utf8');
439
+ process.stdin.on('data', (c) => (data += c));
440
+ process.stdin.on('end', () => resolve(data.trim()));
441
+ });
442
+ }
443
+ function loadUpdateCache() {
444
+ try {
445
+ return JSON.parse(fs.readFileSync(UPDATE_CACHE, 'utf8'));
446
+ }
447
+ catch {
448
+ return null;
449
+ }
450
+ }
451
+ function saveUpdateCache(c) {
452
+ try {
453
+ if (!fs.existsSync(CONFIG_DIR))
454
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
455
+ fs.writeFileSync(UPDATE_CACHE, JSON.stringify(c));
456
+ }
457
+ catch { /* ignore */ }
458
+ }
459
+ function semverGt(a, b) {
460
+ const pa = a.split('.').map((x) => parseInt(x, 10) || 0);
461
+ const pb = b.split('.').map((x) => parseInt(x, 10) || 0);
462
+ for (let i = 0; i < 3; i++) {
463
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
464
+ return true;
465
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
466
+ return false;
467
+ }
468
+ return false;
469
+ }
470
+ /**
471
+ * Best-effort fetch of the latest published version. Cached for 6h so we don't
472
+ * hammer npm. Runs after the user's command is done and prints a one-line
473
+ * notice if an update is available. Never blocks, never errors loudly.
474
+ */
475
+ async function maybeNotifyUpdate() {
476
+ if (process.env.STITCH_NO_UPDATE_CHECK === '1')
477
+ return;
478
+ try {
479
+ const cache = loadUpdateCache();
480
+ const fresh = cache && Date.now() - cache.checkedAt < 6 * 3600_000;
481
+ let latest = fresh ? cache.latest : '';
482
+ if (!fresh) {
483
+ const ctrl = new AbortController();
484
+ const t = setTimeout(() => ctrl.abort(), 1500);
485
+ try {
486
+ const res = await fetch('https://registry.npmjs.org/@stitchdb/cli/latest', { signal: ctrl.signal });
487
+ if (res.ok) {
488
+ const j = await res.json();
489
+ if (j.version)
490
+ latest = j.version;
491
+ saveUpdateCache({ checkedAt: Date.now(), latest });
492
+ }
493
+ }
494
+ finally {
495
+ clearTimeout(t);
496
+ }
497
+ }
498
+ if (latest && semverGt(latest, CLI_VERSION)) {
499
+ const msg = `\n Stitch CLI ${latest} is available (you have ${CLI_VERSION}). Run: stitch update\n`;
500
+ process.stderr.write(msg);
501
+ }
502
+ }
503
+ catch { /* never let the check break a real command */ }
504
+ }
505
+ async function cmdUpdate(args) {
506
+ const channel = parseFlag(args, ['--tag']) || 'latest';
507
+ console.log(`Updating @stitchdb/cli (current: ${CLI_VERSION}) → ${channel}…`);
508
+ // Use the user's npm. Try `npm i -g @stitchdb/cli@latest` first; if that
509
+ // fails with EACCES, retry under sudo.
510
+ const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
511
+ const result = spawnSync(cmd, ['install', '-g', `@stitchdb/cli@${channel}`], { stdio: 'inherit' });
512
+ if (result.status !== 0) {
513
+ console.error('\nUpdate failed. Try one of:');
514
+ console.error(` npm install -g @stitchdb/cli@${channel}`);
515
+ console.error(` sudo npm install -g @stitchdb/cli@${channel}`);
516
+ process.exit(result.status ?? 1);
517
+ }
518
+ // Reset the cache so we don't nag with the version we just installed.
519
+ saveUpdateCache({ checkedAt: Date.now(), latest: '0.0.0' });
520
+ console.log('\nUpdated. Open a fresh shell or run `which stitch` to verify.');
521
+ }
522
+ // ── One-shot install: MCP server + auto-log hooks + project CLAUDE.md ─────
523
+ async function cmdInstall(args) {
524
+ const cfg = loadConfig();
525
+ if (!cfg.apiKey) {
526
+ console.error('Run `stitch login` first.');
527
+ process.exit(2);
528
+ }
529
+ const stitch = client(cfg);
530
+ const ws = await stitch.resolveWorkspace();
531
+ const baseUrl = cfg.baseUrl || 'https://db.stitchdb.com';
532
+ const mcpUrl = `${baseUrl}/mcp/v1/${ws}`;
533
+ const noMcp = hasFlag(args, ['--no-mcp']);
534
+ const noHooks = hasFlag(args, ['--no-hooks']);
535
+ const noClaudeMd = hasFlag(args, ['--no-claude-md']);
536
+ // 1. claude mcp add
537
+ if (!noMcp) {
538
+ const claudePath = process.env.STITCH_CLAUDE_BIN || 'claude';
539
+ process.stdout.write('• Adding Stitch as an MCP server in Claude Code… ');
540
+ const { exit_code, stderr } = await runSilent(claudePath, [
541
+ 'mcp', 'add', '--transport', 'http', '--scope', 'user', 'stitch', mcpUrl,
542
+ '-H', `Authorization: Bearer ${cfg.apiKey}`,
543
+ ]);
544
+ if (exit_code === 0)
545
+ console.log('ok');
546
+ else if (stderr.includes('already exists'))
547
+ console.log('already configured');
548
+ else
549
+ console.log(`failed (${stderr.trim().slice(0, 120)})`);
550
+ }
551
+ // 2. Hooks for auto-logging conversations
552
+ if (!noHooks) {
553
+ process.stdout.write('• Wiring auto-log hooks (UserPromptSubmit + Stop)… ');
554
+ try {
555
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
556
+ const existing = fs.existsSync(settingsPath)
557
+ ? JSON.parse(fs.readFileSync(settingsPath, 'utf8'))
558
+ : {};
559
+ existing.hooks = existing.hooks || {};
560
+ existing.hooks.UserPromptSubmit = mergeHook(existing.hooks.UserPromptSubmit, STITCH_USER_HOOK);
561
+ existing.hooks.Stop = mergeHook(existing.hooks.Stop, STITCH_STOP_HOOK);
562
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
563
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
564
+ console.log('ok');
565
+ }
566
+ catch (e) {
567
+ console.log(`skipped (${e.message})`);
568
+ }
569
+ }
570
+ // 3. CLAUDE.md instruction in cwd so Claude auto-recalls at session start
571
+ if (!noClaudeMd) {
572
+ const claudeMd = path.join(process.cwd(), 'CLAUDE.md');
573
+ const block = STITCH_CLAUDE_MD_BLOCK;
574
+ let body = '';
575
+ let action = 'created';
576
+ if (fs.existsSync(claudeMd)) {
577
+ body = fs.readFileSync(claudeMd, 'utf8');
578
+ if (body.includes('<!-- stitch:auto -->')) {
579
+ action = 'already present';
580
+ }
581
+ else {
582
+ body = body.trimEnd() + '\n\n' + block + '\n';
583
+ action = 'appended';
584
+ }
585
+ }
586
+ else {
587
+ body = block + '\n';
588
+ }
589
+ if (action !== 'already present')
590
+ fs.writeFileSync(claudeMd, body);
591
+ console.log(`• ${action === 'created' ? 'Wrote' : action === 'appended' ? 'Appended to' : 'Found'} ./CLAUDE.md (${action})`);
592
+ }
593
+ console.log();
594
+ console.log('Done. Open a fresh `claude` session — Stitch is wired.');
595
+ console.log(' • remember/recall tools are available');
596
+ console.log(' • every turn auto-logs to the thread for this repo');
597
+ console.log(' • Claude auto-pulls past context at session start (per CLAUDE.md)');
598
+ }
599
+ const STITCH_USER_HOOK = {
600
+ matcher: '*',
601
+ hooks: [
602
+ { type: 'command', command: `bash -c 'jq -r .prompt | stitch thread append --role user --thread "$(stitch thread current)" 2>/dev/null || true'` },
603
+ ],
604
+ };
605
+ const STITCH_STOP_HOOK = {
606
+ matcher: '*',
607
+ hooks: [
608
+ { type: 'command', command: `bash -c 'jq -r .response.text 2>/dev/null | stitch thread append --role assistant --thread "$(stitch thread current)" 2>/dev/null || true'` },
609
+ ],
610
+ };
611
+ function mergeHook(existing, entry) {
612
+ const arr = Array.isArray(existing) ? existing.slice() : [];
613
+ // Replace any earlier Stitch entry; identify by marker substring.
614
+ const isStitch = (h) => JSON.stringify(h).includes('stitch thread append');
615
+ const filtered = arr.filter((h) => !isStitch(h));
616
+ filtered.push(entry);
617
+ return filtered;
618
+ }
619
+ const STITCH_CLAUDE_MD_BLOCK = `<!-- stitch:auto -->
620
+ ## Stitch memory
621
+
622
+ This project is wired to Stitch (memory layer for AI agents).
623
+
624
+ When a session starts here, **call \`recall\` from the \`stitch\` MCP server first**
625
+ to pull any prior facts, decisions, or discussion threads about this repo before
626
+ answering. The thread name for this repo is reported by \`stitch thread current\`.
627
+
628
+ When you learn something durable about this codebase or its conventions, call
629
+ \`remember\` to save it — \`fact\`, \`decision\`, \`snippet\`, or \`preference\`.
630
+
631
+ Every user message and assistant reply is also auto-logged to a per-repo thread
632
+ via Claude Code hooks, so future sessions can \`thread_recall\` to resume context.
633
+ <!-- /stitch:auto -->`;
634
+ async function runSilent(cmd, args) {
635
+ return new Promise((resolve) => {
636
+ const child = spawn(cmd, args);
637
+ let stdout = '';
638
+ let stderr = '';
639
+ child.stdout.on('data', (d) => (stdout += d.toString()));
640
+ child.stderr.on('data', (d) => (stderr += d.toString()));
641
+ child.on('error', (e) => resolve({ stdout, stderr: stderr + '\n' + e.message, exit_code: 127 }));
642
+ child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
643
+ });
644
+ }
309
645
  async function cmdMcpInstall(args) {
310
646
  const cfg = loadConfig();
311
647
  if (!cfg.apiKey) {
@@ -445,9 +781,24 @@ function help() {
445
781
  stitch whoami Show the configured key.
446
782
  stitch logout
447
783
 
784
+ stitch update Update to the latest @stitchdb/cli.
785
+ stitch version Print the installed version.
786
+
787
+ stitch install [--no-mcp] [--no-hooks] [--no-claude-md]
788
+ One-shot: register Stitch as an MCP
789
+ server in Claude Code, wire up auto-log
790
+ hooks, drop a CLAUDE.md instruction in
791
+ the current project so Claude auto-recalls
792
+ at session start.
793
+
448
794
  stitch remember <text> [--kind X] [--tag a,b]
449
795
  stitch recall <query> [-k 5]
450
796
 
797
+ stitch thread append --role user|assistant [--thread <name>] [--content <text>]
798
+ stitch thread recall [--thread <name>] [--last 10] [--semantic <q>]
799
+ stitch thread current Print the auto-derived thread name
800
+ for the current repo / cwd.
801
+
451
802
  stitch workspace [list | create <name> | use <id>]
452
803
 
453
804
  stitch agent register <name> Create an agent identity (id only).
@@ -469,6 +820,16 @@ async function main(argv) {
469
820
  case 'whoami': return cmdWhoami();
470
821
  case 'remember': return cmdRemember(rest);
471
822
  case 'recall': return cmdRecall(rest);
823
+ case 'thread': return cmdThread(rest);
824
+ case 'install': return cmdInstall(rest);
825
+ case 'update':
826
+ case 'upgrade': return cmdUpdate(rest);
827
+ case 'version':
828
+ case '--version':
829
+ case '-v': {
830
+ console.log(CLI_VERSION);
831
+ return;
832
+ }
472
833
  case 'workspace':
473
834
  case 'ws': return cmdWorkspace(rest);
474
835
  case 'agent': return cmdAgent(rest);
@@ -489,4 +850,4 @@ async function main(argv) {
489
850
  process.exit(1);
490
851
  }
491
852
  }
492
- main(process.argv.slice(2));
853
+ main(process.argv.slice(2)).then(() => maybeNotifyUpdate());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {