ai-hist 0.15.3 → 0.15.6

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 CHANGED
@@ -190,14 +190,16 @@ See [the migration guide](https://github.com/AgentWorkforce/relayhistory/blob/ma
190
190
 
191
191
  ## Cloud opt-in
192
192
 
193
- Run `ai-hist enable-cloud` to log in, drain local history and keep pushing. Use `--once` to exit after draining. The async SDK exports `enableCloud`, `pushCloud`, `installGitHooks` and `createShareableTrace`; cloud transport and stage-scoped auth stay in Rust. Interactive login requires Agent Relay. See [cloud setup](https://github.com/AgentWorkforce/relayhistory/blob/main/docs/enable-cloud.md).
193
+ Run `ai-hist enable-cloud` to log in, drain local history and keep pushing. Use `--once` to exit after draining. The npm CLI bundles Agent Relay Cloud login, so no separate `agent-relay` CLI install is required; a run without a TTY fails promptly with interactive-login and token guidance. The async SDK exports `enableCloud`, `pushCloud`, `installGitHooks` and `createShareableTrace`; RelayHistory exchange, transport, and stage-scoped auth stay in Rust. See [cloud setup](https://github.com/AgentWorkforce/relayhistory/blob/main/docs/enable-cloud.md).
194
194
 
195
195
  ## Cloud token and replay
196
196
 
197
197
  The npm CLI uses the same Rust engine as the public async SDK:
198
198
 
199
199
  ```bash
200
- export RTH_TOKEN=$(ai-hist token)
200
+ # Shell-safe token export (fails if ai-hist token fails)
201
+ RTH_TOKEN="$(ai-hist token)" || { echo "Failed to get token" >&2; exit 1; }
202
+ export RTH_TOKEN
201
203
  ai-hist replay SESSION_ID
202
204
  ai-hist replay SESSION_ID --json --out transcript.json
203
205
  ```
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
- import { bootstrapLocal, discoverSessions, formatSessionRow, getSession, getSessionEventsPage, getSessionFileEditsPage, getSessionRelationships, getSessionToolCallsPage, getSessionTree, hydrateSession, listSessionCatalogPage, recent, resumeCommand, search, stats, sync, enableCloud, accessToken, replay, } from './index.js';
4
- const BOOLEAN_FLAGS = new Set(['all', 'fts', 'json', 'local', 'no-bootstrap', 'no-related', 'no-warning', 'once', 'pretty', 'remote', 'version']);
3
+ import { discoverSessions, ensureLocalStore, formatSessionRow, getSession, getSessionEventsPage, getSessionFileEditsPage, getSessionRelationships, getSessionToolCallsPage, getSessionTree, hydrateSession, listSessionCatalogPage, loadStoredRelayhistoryAuth, login, recent, resumeCommand, search, stats, sync, enableCloud, accessToken, replay, validateCloudExchangeBaseUrl, } from './index.js';
4
+ import { prepareCloudSessionForEnableCloud } from './cloud-preflight.js';
5
+ const BOOLEAN_FLAGS = new Set(['all', 'fts', 'help', 'json', 'local', 'no-bootstrap', 'no-related', 'no-warning', 'once', 'pretty', 'remote', 'version']);
5
6
  const VALUE_FLAGS = new Set([
6
- 'base-url', 'interval', 'max-content', 'out', 'after', 'after-ms', 'after-session-id', 'after-source', 'before-ms', 'db', 'limit',
7
- 'max-depth', 'max-nodes', 'project', 'source', 'tag', 'tokens',
7
+ 'base-url', 'interval', 'label', 'max-content', 'out', 'after', 'after-ms', 'after-session-id', 'after-source', 'before-ms', 'db', 'limit',
8
+ 'max-depth', 'max-nodes', 'project', 'source', 'tag', 'token', 'tokens',
8
9
  ]);
9
10
  const KNOWN_FLAGS = new Set([...BOOLEAN_FLAGS, ...VALUE_FLAGS]);
10
11
  function versionTriple(value) {
@@ -100,6 +101,18 @@ function textFlag(args, name) {
100
101
  const value = args.flags.get(name)?.at(-1);
101
102
  return typeof value === 'string' ? value : undefined;
102
103
  }
104
+ async function relayAccessTokenForCloudCommand(command, rawArgs, args, reuseStoredAuth) {
105
+ const explicitToken = textFlag(args, 'token');
106
+ if (explicitToken !== undefined)
107
+ return explicitToken;
108
+ const baseUrl = textFlag(args, 'base-url');
109
+ if (reuseStoredAuth && await loadStoredRelayhistoryAuth(baseUrl))
110
+ return undefined;
111
+ const preparedToken = await prepareCloudSessionForEnableCloud(command, rawArgs, process.env);
112
+ if (preparedToken !== null)
113
+ await validateCloudExchangeBaseUrl(baseUrl);
114
+ return preparedToken ?? undefined;
115
+ }
103
116
  function textFlags(args, name) {
104
117
  return (args.flags.get(name) ?? []).filter((value) => typeof value === 'string');
105
118
  }
@@ -188,12 +201,40 @@ function output(value, json) {
188
201
  process.stdout.write(`${String(value)}\n`);
189
202
  }
190
203
  }
204
+ function showHelp() {
205
+ process.stdout.write(`Usage:
206
+ ai-hist [--no-bootstrap] [--db PATH] [--json] [--help]
207
+ ai-hist enable-cloud [--base-url URL] [--token TOKEN] [--db PATH] [--interval SECONDS] [--once] [--json]
208
+ ai-hist sessions list [--pretty] [--local | --remote | --all] [--source SOURCE]... [--limit N] [--before-ms MS] [--after JSON | --after-source SOURCE --after-session-id ID [--after-ms MS]] [--json]
209
+ ai-hist sessions discover [--local | --remote | --all] [--source SOURCE] [--limit N] [--json]
210
+ ai-hist sessions hydrate SOURCE SESSION_ID [--local | --remote | --all] [--no-related] [--db PATH] [--json]
211
+ ai-hist sessions relationships SOURCE SESSION_ID [--db PATH] [--json]
212
+ ai-hist sessions tree SOURCE SESSION_ID [--max-depth N] [--max-nodes N] [--db PATH] [--json]
213
+ ai-hist sessions tools SOURCE SESSION_ID [--limit N] [--after JSON] [--db PATH] [--json]
214
+ ai-hist sessions edits SOURCE SESSION_ID [--limit N] [--after JSON] [--db PATH] [--json]
215
+ ai-hist search QUERY... [--local | --remote | --all] [--source SOURCE] [--project PATH] [--limit N] [--json]
216
+ ai-hist recent [N] [--local | --remote | --all] [--source SOURCE] [--project PATH] [--json]
217
+ ai-hist session SESSION_ID [--source SOURCE] [--json]
218
+ ai-hist events SESSION_ID [--source SOURCE] [--limit N] [--after JSON] [--json]
219
+ ai-hist resume QUERY... [--local | --remote | --all] [--db PATH] [--fts] [--json]
220
+ ai-hist pack QUERY... [--local | --remote | --all] [--source SOURCE] [--project PATH] [--tag TAG] [--limit N] [--tokens N] [--db PATH] [--fts] [--json]
221
+ ai-hist login [--base-url URL] [--token TOKEN] [--label LABEL] [--json]
222
+ ai-hist token [--base-url URL]
223
+ ai-hist replay SESSION_ID [--base-url URL] [--limit N] [--max-content N] [--json] [--out PATH] [--help]
224
+ ai-hist stats [--local | --remote | --all] [--json]
225
+ ai-hist sync [--local | --remote | --all] [--db PATH] [--json]
226
+
227
+ Every command that reads local history indexes it on first use; pass
228
+ --no-bootstrap to answer from the store exactly as it stands.
229
+ `);
230
+ process.exit(0);
231
+ }
191
232
  function usage(message) {
192
233
  if (message)
193
234
  process.stderr.write(`ai-hist: ${message}\n\n`);
194
235
  process.stderr.write(`Usage:
195
- ai-hist [--no-bootstrap] [--db PATH] [--json]
196
- ai-hist enable-cloud [--base-url URL] [--db PATH] [--interval SECONDS] [--once] [--json]
236
+ ai-hist [--no-bootstrap] [--db PATH] [--json] [--help]
237
+ ai-hist enable-cloud [--base-url URL] [--token TOKEN] [--db PATH] [--interval SECONDS] [--once] [--json]
197
238
  ai-hist sessions list [--pretty] [--local | --remote | --all] [--source SOURCE]... [--limit N] [--before-ms MS] [--after JSON | --after-source SOURCE --after-session-id ID [--after-ms MS]] [--json]
198
239
  ai-hist sessions discover [--local | --remote | --all] [--source SOURCE] [--limit N] [--json]
199
240
  ai-hist sessions hydrate SOURCE SESSION_ID [--local | --remote | --all] [--no-related] [--db PATH] [--json]
@@ -207,10 +248,14 @@ function usage(message) {
207
248
  ai-hist events SESSION_ID [--source SOURCE] [--limit N] [--after JSON] [--json]
208
249
  ai-hist resume QUERY... [--local | --remote | --all] [--db PATH] [--fts] [--json]
209
250
  ai-hist pack QUERY... [--local | --remote | --all] [--source SOURCE] [--project PATH] [--tag TAG] [--limit N] [--tokens N] [--db PATH] [--fts] [--json]
251
+ ai-hist login [--base-url URL] [--token TOKEN] [--label LABEL] [--json]
210
252
  ai-hist token [--base-url URL]
211
- ai-hist replay SESSION_ID [--base-url URL] [--limit N] [--max-content N] [--json] [--out PATH]
253
+ ai-hist replay SESSION_ID [--base-url URL] [--limit N] [--max-content N] [--json] [--out PATH] [--help]
212
254
  ai-hist stats [--local | --remote | --all] [--json]
213
255
  ai-hist sync [--local | --remote | --all] [--db PATH] [--json]
256
+
257
+ Every command that reads local history indexes it on first use; pass
258
+ --no-bootstrap to answer from the store exactly as it stands.
214
259
  `);
215
260
  process.exit(2);
216
261
  }
@@ -364,7 +409,6 @@ function queryPositionals(subcommand, rest, command) {
364
409
  return query;
365
410
  }
366
411
  async function runResume(args, subcommand, rest, json) {
367
- validateFlags(args, 'resume', ['all', 'db', 'fts', 'json', 'local', 'remote']);
368
412
  const query = queryPositionals(subcommand, rest, 'resume');
369
413
  // Matches the Rust CLI: search is capped to the single best match, then that
370
414
  // one row is checked for a usable session id rather than scanning further.
@@ -418,9 +462,6 @@ function takeCodePoints(text, limit) {
418
462
  return { truncated: true, text: points.slice(0, limit).join('') };
419
463
  }
420
464
  async function runPack(args, subcommand, rest, json) {
421
- validateFlags(args, 'pack', [
422
- 'all', 'db', 'fts', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag', 'tokens',
423
- ]);
424
465
  const query = queryPositionals(subcommand, rest, 'pack');
425
466
  const queryStr = query.join(' ');
426
467
  const tokens = nonNegativeIntFlag(args, 'tokens') ?? 0;
@@ -495,6 +536,120 @@ function outputTree(value, json) {
495
536
  if (value.truncated)
496
537
  process.stdout.write('truncated: node/depth budget reached\n');
497
538
  }
539
+ function validateInterval(args) {
540
+ // --interval is seconds; validate before converting so the error names the
541
+ // unit the caller actually typed rather than a millisecond bound.
542
+ const seconds = numberFlag(args, 'interval') ?? 60;
543
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 2_147_483) {
544
+ usage('--interval must be a whole number of seconds between 1 and 2147483');
545
+ }
546
+ }
547
+ // The whole dispatch surface in one table. `readsLocalStore` is the decision
548
+ // that used to live only in the bare-invocation branch: keeping it here is what
549
+ // stops `search` and `ai-hist` disagreeing about whether a store exists.
550
+ const COMMANDS = new Map([
551
+ ['', { name: 'ai-hist', positionals: [0, 0], allowed: ['db', 'json', 'help'], readsLocalStore: true }],
552
+ ['login', { name: 'login', positionals: [0, 0],
553
+ allowed: ['base-url', 'json', 'label', 'token'],
554
+ validate: (args) => {
555
+ if (textFlag(args, 'token') && !textFlag(args, 'base-url'))
556
+ usage('login requires --base-url with --token');
557
+ } }],
558
+ ['token', { name: 'token', positionals: [0, 0], allowed: ['base-url'] }],
559
+ ['replay', { name: 'replay', positionals: [1, 1], requires: 'replay requires SESSION_ID',
560
+ allowed: ['base-url', 'limit', 'max-content', 'json', 'out', 'help'] }],
561
+ ['enable-cloud', { name: 'enable-cloud', positionals: [0, 0], validate: validateInterval,
562
+ allowed: ['base-url', 'db', 'interval', 'once', 'json', 'token'] }],
563
+ ['sessions list', { name: 'sessions list', positionals: [0, 0], readsLocalStore: true,
564
+ validate: (args) => {
565
+ if (args.flags.has('json') && args.flags.has('pretty'))
566
+ usage('--pretty and --json are mutually exclusive');
567
+ },
568
+ allowed: [
569
+ 'after', 'after-ms', 'after-session-id', 'after-source', 'all', 'before-ms', 'db',
570
+ 'json', 'limit', 'local', 'pretty', 'remote', 'source',
571
+ ] }],
572
+ ['sessions discover', { name: 'sessions discover', positionals: [0, 0],
573
+ allowed: ['all', 'db', 'json', 'limit', 'local', 'remote', 'source'] }],
574
+ ['sessions hydrate', { name: 'sessions hydrate', positionals: [2, 2], readsLocalStore: true,
575
+ requires: 'sessions hydrate requires SOURCE and SESSION_ID',
576
+ allowed: ['all', 'db', 'json', 'local', 'no-related', 'remote'] }],
577
+ ['sessions relationships', { name: 'sessions relationships', positionals: [2, 2], readsLocalStore: true,
578
+ rejectsScope: true, requires: 'sessions relationships requires SOURCE and SESSION_ID',
579
+ allowed: ['all', 'db', 'json', 'local', 'remote'] }],
580
+ ['sessions tree', { name: 'sessions tree', positionals: [2, 2], readsLocalStore: true, rejectsScope: true,
581
+ requires: 'sessions tree requires SOURCE and SESSION_ID',
582
+ allowed: ['all', 'db', 'json', 'local', 'max-depth', 'max-nodes', 'remote'] }],
583
+ ['sessions tools', { name: 'sessions tools', positionals: [2, 2], readsLocalStore: true,
584
+ requires: 'sessions tools requires SOURCE and SESSION_ID', allowed: ['after', 'db', 'json', 'limit'] }],
585
+ ['sessions edits', { name: 'sessions edits', positionals: [2, 2], readsLocalStore: true,
586
+ requires: 'sessions edits requires SOURCE and SESSION_ID', allowed: ['after', 'db', 'json', 'limit'] }],
587
+ ['search', { name: 'search', positionals: [1, null], requires: 'search requires a query', readsLocalStore: true,
588
+ allowed: ['all', 'before-ms', 'db', 'fts', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag'] }],
589
+ ['recent', { name: 'recent', positionals: [0, 1], readsLocalStore: true,
590
+ validate: (args) => {
591
+ const count = args.positional[1];
592
+ if (count !== undefined && !Number.isFinite(Number(count))) {
593
+ usage(`recent count must be a number (got '${count}')`);
594
+ }
595
+ },
596
+ allowed: ['all', 'before-ms', 'db', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag'] }],
597
+ ['session', { name: 'session', positionals: [1, 1], requires: 'session requires SESSION_ID',
598
+ readsLocalStore: true, rejectsScope: true,
599
+ allowed: ['all', 'db', 'json', 'local', 'remote', 'source', 'tag'] }],
600
+ ['events', { name: 'events', positionals: [1, 1], requires: 'events requires SESSION_ID',
601
+ readsLocalStore: true, rejectsScope: true,
602
+ allowed: ['after', 'all', 'db', 'json', 'limit', 'local', 'remote', 'source'] }],
603
+ ['resume', { name: 'resume', positionals: [1, null], requires: 'resume requires a query', readsLocalStore: true,
604
+ allowed: ['all', 'db', 'fts', 'json', 'local', 'remote'] }],
605
+ ['pack', { name: 'pack', positionals: [1, null], requires: 'pack requires a query', readsLocalStore: true,
606
+ validate: (args) => { nonNegativeIntFlag(args, 'tokens'); },
607
+ allowed: ['all', 'db', 'fts', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag', 'tokens'] }],
608
+ ['stats', { name: 'stats', positionals: [0, 0], readsLocalStore: true,
609
+ allowed: ['all', 'db', 'json', 'local', 'remote', 'tag'] }],
610
+ // sync and `sessions discover` build the store rather than read it, so they
611
+ // do not bootstrap first; running them is itself the remedy for an empty one.
612
+ ['sync', { name: 'sync', positionals: [0, 0], allowed: ['all', 'db', 'json', 'local', 'remote'] }],
613
+ ]);
614
+ /** Command words consumed before the positional arguments start. */
615
+ function commandWords(command) {
616
+ if (command === undefined)
617
+ return 0;
618
+ return command === 'sessions' ? 2 : 1;
619
+ }
620
+ function commandSpec(command, subcommand) {
621
+ if (command === undefined)
622
+ return COMMANDS.get('');
623
+ if (command === 'sessions')
624
+ return subcommand ? COMMANDS.get(`sessions ${subcommand}`) : undefined;
625
+ return COMMANDS.get(command);
626
+ }
627
+ function unknownCommandMessage(command, subcommand) {
628
+ if (command === undefined)
629
+ return 'invalid usage';
630
+ if (command === 'sessions') {
631
+ if (subcommand === undefined)
632
+ return 'sessions requires a subcommand';
633
+ return `unknown sessions subcommand '${subcommand}'`;
634
+ }
635
+ return `unknown command '${command}'`;
636
+ }
637
+ // A store that was never built and a store holding no match are different
638
+ // answers, and `No results.` is only true of the second. Wording and exit code
639
+ // separate them; the query is not run against a store that cannot hold one.
640
+ function reportUnusableStore(readiness, json) {
641
+ if (readiness.status === 'ready' || readiness.status === 'skipped')
642
+ return false;
643
+ const message = readiness.status === 'unbuilt'
644
+ ? 'No local index yet: run ai-hist (or ai-hist sync) to build one.'
645
+ : 'No searchable local sessions found. Start a coding-agent session, then run ai-hist again.';
646
+ if (json)
647
+ output({ status: readiness.status, indexedPrompts: readiness.indexedPrompts, message }, true);
648
+ else
649
+ process.stdout.write(`${message}\n`);
650
+ process.exitCode = 1;
651
+ return true;
652
+ }
498
653
  async function main() {
499
654
  const rawArgs = process.argv.slice(2);
500
655
  const versionArgs = rawArgs.filter((arg) => arg !== '--no-warning');
@@ -504,30 +659,85 @@ async function main() {
504
659
  await maybePrintUpdateNotice(version, rawArgs);
505
660
  return;
506
661
  }
507
- const args = parse(rawArgs);
662
+ const args = parse(rawArgs.map((arg) => arg === '-h' ? '--help' : arg));
508
663
  const [command, subcommand, ...rest] = args.positional;
509
664
  const json = args.flags.has('json');
665
+ // Handle help before command resolution to prevent unknown command errors
666
+ if (args.flags.has('help') && command === undefined) {
667
+ showHelp();
668
+ }
669
+ if (command === 'help' && subcommand === undefined && rest.length === 0) {
670
+ showHelp();
671
+ }
672
+ if (command === 'sessions' && subcommand === undefined && args.flags.has('help')) {
673
+ showHelp();
674
+ }
675
+ const spec = commandSpec(command, subcommand);
676
+ if (!spec)
677
+ usage(unknownCommandMessage(command, subcommand));
678
+ if (args.flags.has('help') && spec.allowed.includes('help')) {
679
+ showHelp();
680
+ }
681
+ // The whole command line is checked before any work: a line that is going to
682
+ // be rejected must not first spend a first-run bootstrap only to exit 2.
683
+ validateFlags(args, spec.name, spec.readsLocalStore ? [...spec.allowed, 'no-bootstrap'] : spec.allowed);
684
+ if (spec.rejectsScope)
685
+ rejectScopeFlag(args, spec.name);
686
+ const tail = args.positional.slice(commandWords(command));
687
+ const [least, most] = spec.positionals;
688
+ if (tail.length < least)
689
+ usage(spec.requires);
690
+ if (most !== null && tail.length > most)
691
+ rejectSurplusPositionals(tail.slice(most), spec.name);
692
+ spec.validate?.(args);
693
+ const intervalSeconds = numberFlag(args, 'interval') ?? 60;
694
+ const sessionSource = command === 'sessions' ? tail[0] : undefined;
695
+ const sessionId = command === 'sessions' ? tail[1] : undefined;
696
+ const recentFallback = command === 'recent' && tail.length > 0 ? Number(tail[0]) : undefined;
697
+ let readiness = null;
698
+ if (spec.readsLocalStore) {
699
+ readiness = await ensureLocalStore({
700
+ dbPath: textFlag(args, 'db'), scope: scopeFlag(args), bootstrap: !args.flags.has('no-bootstrap'),
701
+ });
702
+ if (readiness.bootstrap?.status === 'partial') {
703
+ process.stderr.write('Some local sessions could not be fully indexed; run ai-hist --json for diagnostics.\n');
704
+ }
705
+ // The bare invocation reports the store's condition as its result and
706
+ // succeeds either way. Every command that asks the store a question refuses
707
+ // to answer out of one that cannot hold an answer.
708
+ if (command !== undefined && reportUnusableStore(readiness, json))
709
+ return;
710
+ }
510
711
  if (command === undefined) {
511
- validateFlags(args, 'ai-hist', ['db', 'json', 'no-bootstrap']);
512
- if (args.flags.has('no-bootstrap')) {
712
+ // --no-bootstrap answers from the catalog as it stands; the bootstrap path
713
+ // reports what it just indexed.
714
+ if (!readiness?.bootstrap) {
513
715
  output(await listSessionCatalogPage({ dbPath: textFlag(args, 'db') }), json);
514
716
  return;
515
717
  }
516
- const result = await bootstrapLocal({ dbPath: textFlag(args, 'db') });
517
718
  if (json)
518
- output(result, true);
719
+ output(readiness.bootstrap, true);
519
720
  else {
520
- process.stdout.write(result.indexedPrompts > 0
521
- ? `Ready: ${result.indexedPrompts} indexed prompt(s). Search with: ai-hist search "your query"\n`
721
+ process.stdout.write(readiness.indexedPrompts > 0
722
+ ? `Ready: ${readiness.indexedPrompts} indexed prompt(s). Search with: ai-hist search "your query"\n`
522
723
  : 'No searchable local sessions found. Start a coding-agent session, then run ai-hist again.\n');
523
- if (result.status === 'partial')
524
- process.stderr.write('Some local sessions could not be fully indexed; run ai-hist --json for diagnostics.\n');
525
724
  }
526
725
  return;
527
726
  }
727
+ if (command === 'login') {
728
+ const relayAccessToken = await relayAccessTokenForCloudCommand(command, rawArgs, args, false);
729
+ const auth = await login({
730
+ baseUrl: textFlag(args, 'base-url'),
731
+ relayAccessToken,
732
+ label: textFlag(args, 'label'),
733
+ });
734
+ if (json)
735
+ output({ ok: true, baseUrl: auth.baseUrl }, true);
736
+ else
737
+ process.stdout.write(`Logged in to ${auth.baseUrl} (session stored).\n`);
738
+ return;
739
+ }
528
740
  if (command === 'token') {
529
- validateFlags(args, 'token', ['base-url']);
530
- rejectSurplusPositionals(args.positional.slice(1), 'token');
531
741
  const token = await accessToken({ baseUrl: textFlag(args, 'base-url') });
532
742
  if (process.stdout.isTTY)
533
743
  process.stderr.write('Warning: this access token is a secret and will remain in terminal scrollback.\n');
@@ -535,10 +745,6 @@ async function main() {
535
745
  return;
536
746
  }
537
747
  if (command === 'replay') {
538
- validateFlags(args, 'replay', ['base-url', 'limit', 'max-content', 'json', 'out']);
539
- if (!subcommand)
540
- usage('replay requires SESSION_ID');
541
- rejectSurplusPositionals(rest, 'replay');
542
748
  const result = await replay(subcommand, {
543
749
  baseUrl: textFlag(args, 'base-url'), limit: nonNegativeIntFlag(args, 'limit'),
544
750
  maxContent: nonNegativeIntFlag(args, 'max-content'), json, out: textFlag(args, 'out'),
@@ -548,16 +754,10 @@ async function main() {
548
754
  return;
549
755
  }
550
756
  if (command === 'enable-cloud') {
551
- validateFlags(args, 'enable-cloud', ['base-url', 'db', 'interval', 'once', 'json']);
552
- rejectSurplusPositionals(args.positional.slice(1), 'enable-cloud');
553
- // --interval is seconds; validate before converting so the error names the
554
- // unit the caller actually typed rather than a millisecond bound.
555
- const intervalSeconds = numberFlag(args, 'interval') ?? 60;
556
- if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds < 1 || intervalSeconds > 2_147_483) {
557
- usage('--interval must be a whole number of seconds between 1 and 2147483');
558
- }
757
+ const relayAccessToken = await relayAccessTokenForCloudCommand(command, rawArgs, args, true);
559
758
  const handle = await enableCloud({
560
759
  baseUrl: textFlag(args, 'base-url'), dbPath: textFlag(args, 'db'),
760
+ relayAccessToken,
561
761
  intervalMs: intervalSeconds * 1000,
562
762
  watch: !args.flags.has('once'),
563
763
  onPush: (result) => output(result, json),
@@ -572,14 +772,7 @@ async function main() {
572
772
  return;
573
773
  }
574
774
  if (command === 'sessions' && subcommand === 'list') {
575
- validateFlags(args, 'sessions list', [
576
- 'after', 'after-ms', 'after-session-id', 'after-source', 'all', 'before-ms', 'db',
577
- 'json', 'limit', 'local', 'pretty', 'remote', 'source',
578
- ]);
579
- rejectSurplusPositionals(rest, 'sessions list');
580
775
  const sources = textFlags(args, 'source');
581
- if (json && args.flags.has('pretty'))
582
- usage('--pretty and --json are mutually exclusive');
583
776
  const page = await listSessionCatalogPage({
584
777
  dbPath: textFlag(args, 'db'), scope: scopeFlag(args), sources: sources.length ? sources : undefined,
585
778
  limit: numberFlag(args, 'limit'), beforeMs: numberFlag(args, 'before-ms'),
@@ -598,8 +791,6 @@ async function main() {
598
791
  return;
599
792
  }
600
793
  if (command === 'sessions' && subcommand === 'discover') {
601
- validateFlags(args, 'sessions discover', ['all', 'db', 'json', 'limit', 'local', 'remote', 'source']);
602
- rejectSurplusPositionals(rest, 'sessions discover');
603
794
  const sources = textFlags(args, 'source');
604
795
  outputDiscovery(await discoverSessions({
605
796
  dbPath: textFlag(args, 'db'), scope: scopeFlag(args), sources: sources.length ? sources : undefined,
@@ -608,14 +799,9 @@ async function main() {
608
799
  return;
609
800
  }
610
801
  if (command === 'sessions' && subcommand === 'hydrate') {
611
- validateFlags(args, 'sessions hydrate', ['all', 'db', 'json', 'local', 'no-related', 'remote']);
612
- const [source, sessionId, ...surplus] = rest;
613
- if (!source || !sessionId)
614
- usage('sessions hydrate requires SOURCE and SESSION_ID');
615
- rejectSurplusPositionals(surplus, 'sessions hydrate');
616
802
  outputHydration(await hydrateSession({
617
- source: source,
618
- sessionId,
803
+ source: sessionSource,
804
+ sessionId: sessionId,
619
805
  scope: scopeFlag(args),
620
806
  dbPath: textFlag(args, 'db'),
621
807
  includeRelated: !args.flags.has('no-related'),
@@ -623,85 +809,46 @@ async function main() {
623
809
  return;
624
810
  }
625
811
  if (command === 'sessions' && subcommand === 'relationships') {
626
- validateFlags(args, 'sessions relationships', ['all', 'db', 'json', 'local', 'remote']);
627
- const [source, sessionId, ...surplus] = rest;
628
- if (!source || !sessionId)
629
- usage('sessions relationships requires SOURCE and SESSION_ID');
630
- rejectSurplusPositionals(surplus, 'sessions relationships');
631
- rejectScopeFlag(args, 'sessions relationships');
632
812
  outputRelationships(await getSessionRelationships({
633
- source: source, sessionId, dbPath: textFlag(args, 'db'),
813
+ source: sessionSource, sessionId: sessionId, dbPath: textFlag(args, 'db'),
634
814
  }), json);
635
815
  return;
636
816
  }
637
817
  if (command === 'sessions' && subcommand === 'tree') {
638
- validateFlags(args, 'sessions tree', ['all', 'db', 'json', 'local', 'max-depth', 'max-nodes', 'remote']);
639
- const [source, sessionId, ...surplus] = rest;
640
- if (!source || !sessionId)
641
- usage('sessions tree requires SOURCE and SESSION_ID');
642
- rejectSurplusPositionals(surplus, 'sessions tree');
643
- rejectScopeFlag(args, 'sessions tree');
644
818
  outputTree(await getSessionTree({
645
- source: source, sessionId, dbPath: textFlag(args, 'db'),
819
+ source: sessionSource, sessionId: sessionId, dbPath: textFlag(args, 'db'),
646
820
  maxDepth: numberFlag(args, 'max-depth'), maxNodes: numberFlag(args, 'max-nodes'),
647
821
  }), json);
648
822
  return;
649
823
  }
650
824
  if (command === 'sessions' && (subcommand === 'tools' || subcommand === 'edits')) {
651
825
  const name = `sessions ${subcommand}`;
652
- validateFlags(args, name, ['after', 'db', 'json', 'limit']);
653
- const [source, sessionId, ...surplus] = rest;
654
- if (!source || !sessionId)
655
- usage(`${name} requires SOURCE and SESSION_ID`);
656
- rejectSurplusPositionals(surplus, name);
657
826
  const options = {
658
827
  dbPath: textFlag(args, 'db'),
659
828
  limit: numberFlag(args, 'limit'),
660
829
  after: evidenceCursorFlag(args),
661
830
  };
662
831
  if (subcommand === 'tools') {
663
- outputToolCalls(await getSessionToolCallsPage(source, sessionId, options), json);
832
+ outputToolCalls(await getSessionToolCallsPage(sessionSource, sessionId, options), json);
664
833
  }
665
834
  else {
666
- outputFileEdits(await getSessionFileEditsPage(source, sessionId, options), json);
835
+ outputFileEdits(await getSessionFileEditsPage(sessionSource, sessionId, options), json);
667
836
  }
668
837
  return;
669
838
  }
670
839
  if (command === 'search') {
671
- validateFlags(args, 'search', [
672
- 'all', 'before-ms', 'db', 'fts', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag',
673
- ]);
674
- if (!subcommand)
675
- usage();
676
840
  output(await search([subcommand, ...rest].join(' '), { ...common(args), rawFts: args.flags.has('fts') }), json);
677
841
  return;
678
842
  }
679
843
  if (command === 'recent') {
680
- validateFlags(args, 'recent', [
681
- 'all', 'before-ms', 'db', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag',
682
- ]);
683
- rejectSurplusPositionals(rest, 'recent');
684
- const fallback = subcommand ? Number(subcommand) : undefined;
685
- if (fallback !== undefined && !Number.isFinite(fallback))
686
- usage(`recent count must be a number (got '${subcommand}')`);
687
- output(await recent({ ...common(args), limit: numberFlag(args, 'limit') ?? fallback }), json);
844
+ output(await recent({ ...common(args), limit: numberFlag(args, 'limit') ?? recentFallback }), json);
688
845
  return;
689
846
  }
690
847
  if (command === 'session') {
691
- validateFlags(args, 'session', ['all', 'db', 'json', 'local', 'remote', 'source', 'tag']);
692
- if (!subcommand)
693
- usage();
694
- rejectSurplusPositionals(rest, 'session');
695
- rejectScopeFlag(args, 'session');
696
848
  output(await getSession(subcommand, { dbPath: textFlag(args, 'db'), source: textFlag(args, 'source'), tag: textFlag(args, 'tag') }), json);
697
849
  return;
698
850
  }
699
851
  if (command === 'events') {
700
- validateFlags(args, 'events', ['after', 'all', 'db', 'json', 'limit', 'local', 'remote', 'source']);
701
- if (!subcommand)
702
- usage();
703
- rejectSurplusPositionals(rest, 'events');
704
- rejectScopeFlag(args, 'events');
705
852
  output(await getSessionEventsPage(subcommand, {
706
853
  dbPath: textFlag(args, 'db'), source: textFlag(args, 'source'),
707
854
  limit: numberFlag(args, 'limit'), after: cursorFlag(args),
@@ -717,14 +864,10 @@ async function main() {
717
864
  return;
718
865
  }
719
866
  if (command === 'stats') {
720
- validateFlags(args, 'stats', ['all', 'db', 'json', 'local', 'remote', 'tag']);
721
- rejectSurplusPositionals([subcommand, ...rest].filter((value) => value !== undefined), 'stats');
722
867
  output(await stats({ dbPath: textFlag(args, 'db'), scope: scopeFlag(args), tag: textFlag(args, 'tag') }), json);
723
868
  return;
724
869
  }
725
870
  if (command === 'sync') {
726
- validateFlags(args, 'sync', ['all', 'db', 'json', 'local', 'remote']);
727
- rejectSurplusPositionals([subcommand, ...rest].filter((value) => value !== undefined), 'sync');
728
871
  output(await sync({ dbPath: textFlag(args, 'db'), scope: scopeFlag(args) }), json);
729
872
  return;
730
873
  }