flipstream 0.5.0 → 0.6.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.
Files changed (98) hide show
  1. package/README.md +168 -12
  2. package/dist/commands/auth/clear-all.d.ts +1 -0
  3. package/dist/commands/auth/clear-all.js +91 -24
  4. package/dist/commands/auth/login.js +8 -2
  5. package/dist/commands/auth/logout.js +2 -2
  6. package/dist/commands/auth/status.js +35 -8
  7. package/dist/commands/catalog.d.ts +15 -0
  8. package/dist/commands/catalog.js +110 -0
  9. package/dist/commands/connections/list.d.ts +1 -0
  10. package/dist/commands/connections/list.js +28 -2
  11. package/dist/commands/contract.d.ts +11 -0
  12. package/dist/commands/contract.js +35 -0
  13. package/dist/commands/health.d.ts +10 -0
  14. package/dist/commands/health.js +31 -0
  15. package/dist/commands/log/add.js +7 -3
  16. package/dist/commands/log/list.js +1 -1
  17. package/dist/commands/query.d.ts +15 -2
  18. package/dist/commands/query.js +256 -43
  19. package/dist/commands/skills/install.d.ts +16 -0
  20. package/dist/commands/skills/install.js +55 -0
  21. package/dist/commands/workspaces/connections.js +4 -2
  22. package/dist/commands/workspaces/get.js +5 -3
  23. package/dist/commands/workspaces/list.js +4 -1
  24. package/dist/lib/api/errors.d.ts +1 -0
  25. package/dist/lib/api/errors.js +13 -2
  26. package/dist/lib/api/http.d.ts +2 -0
  27. package/dist/lib/api/http.js +40 -4
  28. package/dist/lib/api/ids.d.ts +1 -0
  29. package/dist/lib/api/ids.js +5 -0
  30. package/dist/lib/api/retry.d.ts +1 -1
  31. package/dist/lib/api/retry.js +1 -1
  32. package/dist/lib/api/short-uuid.d.ts +1 -0
  33. package/dist/lib/api/short-uuid.js +30 -0
  34. package/dist/lib/auth/claims.d.ts +1 -1
  35. package/dist/lib/auth/claims.js +2 -2
  36. package/dist/lib/auth/flow.js +10 -2
  37. package/dist/lib/auth/headless.js +14 -10
  38. package/dist/lib/auth/refresh.js +33 -3
  39. package/dist/lib/auth/session.js +1 -1
  40. package/dist/lib/command/base.d.ts +4 -0
  41. package/dist/lib/command/base.js +102 -3
  42. package/dist/lib/command/flags.d.ts +4 -0
  43. package/dist/lib/command/flags.js +11 -0
  44. package/dist/lib/command/planner.d.ts +9 -0
  45. package/dist/lib/command/planner.js +14 -0
  46. package/dist/lib/config/constants.d.ts +3 -1
  47. package/dist/lib/config/constants.js +14 -1
  48. package/dist/lib/config/xdg.d.ts +4 -0
  49. package/dist/lib/config/xdg.js +56 -1
  50. package/dist/lib/errors.d.ts +20 -1
  51. package/dist/lib/errors.js +125 -17
  52. package/dist/lib/output/dialogs.d.ts +27 -0
  53. package/dist/lib/output/dialogs.js +94 -0
  54. package/dist/lib/output/interactivity.d.ts +11 -0
  55. package/dist/lib/output/interactivity.js +54 -0
  56. package/dist/lib/output/machine-mode.d.ts +2 -0
  57. package/dist/lib/output/machine-mode.js +25 -0
  58. package/dist/lib/output/redact.d.ts +1 -0
  59. package/dist/lib/output/redact.js +12 -0
  60. package/dist/lib/output/runlog.d.ts +3 -0
  61. package/dist/lib/output/runlog.js +72 -0
  62. package/dist/lib/output/sanitize.d.ts +2 -0
  63. package/dist/lib/output/sanitize.js +57 -0
  64. package/dist/lib/output/sidecar.d.ts +30 -0
  65. package/dist/lib/output/sidecar.js +58 -0
  66. package/dist/lib/output/table.js +5 -1
  67. package/dist/lib/output/trace.d.ts +11 -0
  68. package/dist/lib/output/trace.js +89 -0
  69. package/dist/lib/planner/catalog.d.ts +26 -0
  70. package/dist/lib/planner/catalog.js +60 -0
  71. package/dist/lib/planner/client.d.ts +14 -0
  72. package/dist/lib/planner/client.js +47 -0
  73. package/dist/lib/planner/connection.d.ts +14 -0
  74. package/dist/lib/planner/connection.js +139 -0
  75. package/dist/lib/planner/diagnose.d.ts +8 -0
  76. package/dist/lib/planner/diagnose.js +50 -0
  77. package/dist/lib/planner/errors.d.ts +14 -0
  78. package/dist/lib/planner/errors.js +129 -0
  79. package/dist/lib/planner/filters.d.ts +8 -0
  80. package/dist/lib/planner/filters.js +74 -0
  81. package/dist/lib/planner/request.d.ts +24 -0
  82. package/dist/lib/planner/request.js +51 -0
  83. package/dist/lib/planner/suggest.d.ts +2 -0
  84. package/dist/lib/planner/suggest.js +45 -0
  85. package/dist/lib/planner/vocabulary.d.ts +9 -0
  86. package/dist/lib/planner/vocabulary.js +95 -0
  87. package/dist/lib/skills/install.d.ts +24 -0
  88. package/dist/lib/skills/install.js +69 -0
  89. package/dist/lib/store/keychain-child.d.ts +11 -0
  90. package/dist/lib/store/keychain-child.js +135 -0
  91. package/dist/lib/store/keyring.d.ts +27 -10
  92. package/dist/lib/store/keyring.js +288 -17
  93. package/dist/lib/store/memory-store.d.ts +6 -5
  94. package/dist/lib/store/memory-store.js +14 -6
  95. package/docs/AGENT-CONTRACT.md +238 -0
  96. package/oclif.manifest.json +392 -8
  97. package/package.json +7 -3
  98. package/skill/SKILL.md +55 -0
package/README.md CHANGED
@@ -107,14 +107,18 @@ If you already removed the binary, clean up manually:
107
107
  * [`flipstream auth login`](#flipstream-auth-login)
108
108
  * [`flipstream auth logout`](#flipstream-auth-logout)
109
109
  * [`flipstream auth status`](#flipstream-auth-status)
110
+ * [`flipstream catalog [SOURCE]`](#flipstream-catalog-source)
110
111
  * [`flipstream commands`](#flipstream-commands)
111
112
  * [`flipstream conn list`](#flipstream-conn-list)
112
113
  * [`flipstream connections list`](#flipstream-connections-list)
114
+ * [`flipstream contract`](#flipstream-contract)
115
+ * [`flipstream health`](#flipstream-health)
113
116
  * [`flipstream log add MESSAGE`](#flipstream-log-add-message)
114
117
  * [`flipstream log list`](#flipstream-log-list)
115
118
  * [`flipstream logs add MESSAGE`](#flipstream-logs-add-message)
116
119
  * [`flipstream logs list`](#flipstream-logs-list)
117
120
  * [`flipstream query`](#flipstream-query)
121
+ * [`flipstream skills install`](#flipstream-skills-install)
118
122
  * [`flipstream version`](#flipstream-version)
119
123
  * [`flipstream workspaces connections ID`](#flipstream-workspaces-connections-id)
120
124
  * [`flipstream workspaces get ID`](#flipstream-workspaces-get-id)
@@ -240,6 +244,45 @@ EXAMPLES
240
244
  $ flipstream auth status --json
241
245
  ```
242
246
 
247
+ ## `flipstream catalog [SOURCE]`
248
+
249
+ Show the dimensions and metrics a source accepts.
250
+
251
+ ```
252
+ USAGE
253
+ $ flipstream catalog [SOURCE] [--json] [-H <value>] [--ndjson] [--timeout <value>] [--verbose] [--auth-host
254
+ <value>] [--url <value>]
255
+
256
+ ARGUMENTS
257
+ [SOURCE] A source name (omit to list every source).
258
+
259
+ FLAGS
260
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
261
+ --auth-host=<value> OAuth issuer host for credentials/refresh (defaults to the prod issuer).
262
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
263
+ --timeout=<value> Network timeout in milliseconds.
264
+ --url=<value> Query-planner base URL (default: the prod planner; env FLIPSTREAM_PLANNER_URL).
265
+ --verbose Print redacted diagnostics to stderr.
266
+
267
+ GLOBAL FLAGS
268
+ --json Format output as json.
269
+
270
+ DESCRIPTION
271
+ Show the dimensions and metrics a source accepts.
272
+
273
+ Print the request vocabulary the query planner accepts. The names shown ARE what `query` takes: `-d` and `-m` and
274
+ filter keys use an item's `name` (the wire name, e.g. search_date), never its `label` (what a human reads, e.g. Date).
275
+ Read from the SERVICE, never from a local data-model checkout — the two differ exactly when it matters most, right
276
+ after a version bump.
277
+
278
+ EXAMPLES
279
+ $ flipstream catalog
280
+
281
+ $ flipstream catalog gsc
282
+
283
+ $ flipstream catalog gsc --json
284
+ ```
285
+
243
286
  ## `flipstream commands`
244
287
 
245
288
  List all flipstream commands.
@@ -351,6 +394,68 @@ EXAMPLES
351
394
  $ flipstream conn list --all
352
395
  ```
353
396
 
397
+ ## `flipstream contract`
398
+
399
+ Print the agent contract (machine-facing promises) to stdout.
400
+
401
+ ```
402
+ USAGE
403
+ $ flipstream contract [--json] [-H <value>] [--ndjson] [--timeout <value>] [--verbose]
404
+
405
+ FLAGS
406
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
407
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
408
+ --timeout=<value> Network timeout in milliseconds.
409
+ --verbose Print redacted diagnostics to stderr.
410
+
411
+ GLOBAL FLAGS
412
+ --json Format output as json.
413
+
414
+ DESCRIPTION
415
+ Print the agent contract (machine-facing promises) to stdout.
416
+
417
+ Print docs/AGENT-CONTRACT.md — the frozen machine contract (output modes, exit codes, error envelope, per-command JSON
418
+ shapes) — to stdout, from the installed package, no network needed. Context self-service for agents: read this before
419
+ driving the CLI programmatically.
420
+
421
+ EXAMPLES
422
+ $ flipstream contract
423
+
424
+ $ flipstream contract --json
425
+ ```
426
+
427
+ ## `flipstream health`
428
+
429
+ Check that the query planner is reachable.
430
+
431
+ ```
432
+ USAGE
433
+ $ flipstream health [--json] [-H <value>] [--ndjson] [--timeout <value>] [--verbose] [--url <value>]
434
+
435
+ FLAGS
436
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
437
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
438
+ --timeout=<value> Network timeout in milliseconds.
439
+ --url=<value> Query-planner base URL (default: the prod planner; env FLIPSTREAM_PLANNER_URL).
440
+ --verbose Print redacted diagnostics to stderr.
441
+
442
+ GLOBAL FLAGS
443
+ --json Format output as json.
444
+
445
+ DESCRIPTION
446
+ Check that the query planner is reachable.
447
+
448
+ Check that the query planner is answering. This is the ONE endpoint that takes no token, so it separates "the service
449
+ is down" from "my credentials are wrong" before any auth debugging starts.
450
+
451
+ EXAMPLES
452
+ $ flipstream health
453
+
454
+ $ flipstream health --json
455
+
456
+ $ flipstream health --url http://localhost:8080
457
+ ```
458
+
354
459
  ## `flipstream log add MESSAGE`
355
460
 
356
461
  Add an entry to a workspace's log.
@@ -517,18 +622,69 @@ EXAMPLES
517
622
 
518
623
  ## `flipstream query`
519
624
 
520
- Run an opaque query against the Flipstream data API.
625
+ Run a logical query against the Flipstream query planner.
521
626
 
522
627
  ```
523
628
  USAGE
524
629
  $ flipstream query [--json] [-H <value>] [--ndjson] [--timeout <value>] [--verbose] [--auth-host <value>]
525
- [--body <value> | --body-file <value>]
630
+ [--url <value>] [--body <value> | --body-file <value>] [--connection-id <value>] [-d <value>...] [--dry-run] [-f
631
+ <value>...] [-m <value>...] [--offset <value>] [--rows <value>] [-s <value>...] [--source <value>] [--table <value>]
632
+
633
+ FLAGS
634
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
635
+ -d, --dimension=<value>... Dimension name (repeatable).
636
+ -f, --filter=<value>... Filter as col=a,b | col=from..to | col=<json> (repeatable). 'col=' is refused.
637
+ -m, --metric=<value>... Metric name (repeatable).
638
+ -s, --sort=<value>... FIELD[:asc|desc] (repeatable).
639
+ --auth-host=<value> OAuth issuer host for credentials/refresh (defaults to the prod issuer).
640
+ --body=<value> Send this JSON body verbatim.
641
+ --body-file=<value> Path to a file holding the JSON body verbatim ("-" reads stdin).
642
+ --connection-id=<value> Connection UUID, or a name to look up.
643
+ --dry-run Print the request body and send nothing.
644
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
645
+ --offset=<value> Row offset.
646
+ --rows=<value> Row limit (default 100).
647
+ --source=<value> The dataset discriminator, e.g. gsc.
648
+ --table=<value> Physical table — honoured only for an unmodelled source.
649
+ --timeout=<value> Network timeout in milliseconds.
650
+ --url=<value> Query-planner base URL (default: the prod planner; env FLIPSTREAM_PLANNER_URL).
651
+ --verbose Print redacted diagnostics to stderr.
652
+
653
+ GLOBAL FLAGS
654
+ --json Format output as json.
655
+
656
+ DESCRIPTION
657
+ Run a logical query against the Flipstream query planner.
658
+
659
+ Send a LOGICAL query (source + dimensions + metrics + filters) to the Flipstream query planner, which resolves which
660
+ physical table answers it and returns the rows. Run `flipstream catalog <source>` first — the names it prints ARE what
661
+ -d, -m and filter keys take. --body/--body-file/stdin still forwards a JSON body verbatim for shapes the flags cannot
662
+ express.
663
+
664
+ EXAMPLES
665
+ $ flipstream catalog gsc
666
+
667
+ $ flipstream query --source gsc --connection-id <id|name> -d search_date -d query -m clicks -f 'search_date=2026-01-01..2026-02-01' --rows 20
668
+
669
+ $ flipstream query --source gsc --connection-id <id> -d query -m clicks --dry-run
670
+
671
+ $ flipstream query --body-file request.json --json
672
+ ```
673
+
674
+ ## `flipstream skills install`
675
+
676
+ Install the flipstream skill for detected AI coding agents.
677
+
678
+ ```
679
+ USAGE
680
+ $ flipstream skills install [--json] [-H <value>] [--ndjson] [--timeout <value>] [--verbose] [--agent
681
+ claude-code|codex|cursor|opencode...] [-y]
526
682
 
527
683
  FLAGS
528
684
  -H, --host=<value> OAuth provider host (defaults to the prod issuer).
529
- --auth-host=<value> OAuth issuer host for credentials/refresh (defaults to the prod issuer).
530
- --body=<value> Opaque JSON request body as a string.
531
- --body-file=<value> Path to a file holding the opaque JSON request body.
685
+ -y, --yes Install without asking.
686
+ --agent=<option>... Install for this agent id only (repeatable). Known: claude-code, codex, cursor, opencode.
687
+ <options: claude-code|codex|cursor|opencode>
532
688
  --ndjson Stream output as NDJSON (one compact JSON object per line).
533
689
  --timeout=<value> Network timeout in milliseconds.
534
690
  --verbose Print redacted diagnostics to stderr.
@@ -537,18 +693,18 @@ GLOBAL FLAGS
537
693
  --json Format output as json.
538
694
 
539
695
  DESCRIPTION
540
- Run an opaque query against the Flipstream data API.
696
+ Install the flipstream skill for detected AI coding agents.
541
697
 
542
- Send an OPAQUE JSON body (--body, --body-file, or piped stdin) to the Flipstream data API and print the structured
543
- JSON response. The CLI never constructs, validates, or transforms the query payload the body is forwarded verbatim
544
- (your agent/Skill builds it).
698
+ Install the flipstream skill (SKILL.md) into the global skills directory of every detected AI coding agent (Claude
699
+ Code, Codex, Cursor, OpenCode). Detection is local env/dir inspection only nothing is reported anywhere. The skill
700
+ teaches an agent to retrieve the command surface and data vocabulary from the CLI instead of guessing.
545
701
 
546
702
  EXAMPLES
547
- $ flipstream query --body '{"opaque":true}'
703
+ $ flipstream skills install
548
704
 
549
- echo '{"opaque":true}' | flipstream query
705
+ $ flipstream skills install --yes
550
706
 
551
- $ flipstream query --body-file query.json --json
707
+ $ flipstream skills install --agent claude-code --agent codex
552
708
  ```
553
709
 
554
710
  ## `flipstream version`
@@ -7,4 +7,5 @@ export default class AuthClearAll extends BaseCommand<typeof AuthClearAll> {
7
7
  };
8
8
  static summary: string;
9
9
  run(): Promise<Record<string, unknown>>;
10
+ private revoke;
10
11
  }
@@ -3,48 +3,115 @@ import { fetchMetadata } from '../../lib/auth/discovery.js';
3
3
  import { revokeToken } from '../../lib/auth/revoke.js';
4
4
  import { BaseCommand } from '../../lib/command/base.js';
5
5
  import { knownHosts, removeConfigDir } from '../../lib/config/xdg.js';
6
+ import { CliError, TimeoutError } from '../../lib/errors.js';
7
+ import { ExitCode } from '../../lib/exit-codes.js';
6
8
  import { createStore } from '../../lib/store/index.js';
7
9
  export default class AuthClearAll extends BaseCommand {
8
10
  static description = 'Purge ALL Flipstream credentials: every keychain entry and the local config directory.';
9
11
  static examples = ['<%= config.bin %> auth clear-all', '<%= config.bin %> auth clear-all --no-revoke'];
10
12
  static flags = {
11
- 'no-revoke': Flags.boolean({ description: 'Clear local credentials only; skip the per-host server revocation calls.' }),
13
+ 'no-revoke': Flags.boolean({
14
+ description: 'Clear local credentials only; skip the per-host server revocation calls.',
15
+ }),
12
16
  };
13
17
  static summary = 'Purge ALL credentials: every keychain entry and the local config dir.';
14
18
  async run() {
15
19
  const store = createStore();
16
20
  const hosts = knownHosts();
17
21
  const timeoutMs = this.flags.timeout ?? 10_000;
18
- // Best-effort revoke each host's refresh token (in parallel no cross-host
19
- // dependency), then clear its keychain entry regardless of the outcome.
20
- await Promise.all(hosts.map(async (host) => {
21
- const creds = store.load(host);
22
- if (!this.flags['no-revoke'] && creds?.refreshToken) {
23
- try {
24
- const meta = await fetchMetadata(host, { timeoutMs });
25
- await revokeToken({
26
- clientId: creds.clientId,
27
- revocationEndpoint: meta.revocationEndpoint,
28
- timeoutMs,
29
- token: creds.refreshToken,
30
- tokenTypeHint: 'refresh_token',
31
- });
32
- }
33
- catch {
34
- // Best-effort proceed to clear the keychain entry anyway.
35
- }
36
- }
37
- store.clear(host);
38
- }));
22
+ // THE LOCAL PURGE COMES FIRST, AND ALONE. An earlier attempt at this gave the
23
+ // command one wall-clock budget covering both keychain and network work, and
24
+ // that budget then got spent by slow token revocations — so a later host's
25
+ // `clear` was skipped entirely and `clear-all` returned having left
26
+ // credentials in the keychain. A purge command must never be talked out of
27
+ // purging by a server that will not answer.
28
+ //
29
+ // So the two phases are separated: every host's entry is read and deleted
30
+ // here, one at a time (each keychain call raises its own OS dialog, and
31
+ // stacking them puts several prompts on one person), and the revocations run
32
+ // afterwards from tokens captured on the way through. Serial keychain work is
33
+ // bounded because each call carries its own deadline; nothing here inherits a
34
+ // network stall.
35
+ const revocations = [];
36
+ const failed = [];
37
+ let keychainTimedOut = false;
38
+ for (const host of hosts) {
39
+ let refreshToken;
40
+ let clientId;
41
+ try {
42
+ // eslint-disable-next-line no-await-in-loop -- serialized on purpose; see above
43
+ const creds = await store.load(host);
44
+ refreshToken = creds?.refreshToken;
45
+ clientId = creds?.clientId;
46
+ }
47
+ catch (error) {
48
+ // Unreadable is not fatal to the purge: the delete below still runs, and
49
+ // a credential we could not read is one we certainly cannot revoke.
50
+ if (error instanceof TimeoutError)
51
+ keychainTimedOut = true;
52
+ }
53
+ if (!this.flags['no-revoke'] && refreshToken && clientId) {
54
+ revocations.push({ clientId, host, token: refreshToken });
55
+ }
56
+ try {
57
+ // eslint-disable-next-line no-await-in-loop -- serialized on purpose; see above
58
+ await store.clear(host);
59
+ }
60
+ catch (error) {
61
+ // Keep purging. One unusable entry must not strand every other host's
62
+ // credentials, and the caller is told exactly which ones survived.
63
+ if (error instanceof TimeoutError)
64
+ keychainTimedOut = true;
65
+ failed.push(host);
66
+ }
67
+ }
68
+ // Best effort, and deliberately AFTER the purge: a revocation that hangs or
69
+ // 500s can no longer cost anybody a deleted credential. These are network
70
+ // calls with their own timeout and are never reported as keychain failures.
71
+ await Promise.all(revocations.map(async (revocation) => this.revoke(revocation, timeoutMs)));
72
+ const cleared = hosts.filter((host) => !failed.includes(host));
73
+ if (failed.length > 0) {
74
+ // The config dir STAYS. knownHosts() is read from it, so removing it here
75
+ // would delete the only record of the hosts whose secrets are still in the
76
+ // keychain — the retry this error recommends would then find nothing to
77
+ // retry, and live credentials would be left with no way to locate them.
78
+ const summary = `Cleared ${cleared.length} host(s); ${failed.length} could not be cleared: ${failed.join(', ')}`;
79
+ const hint = 'Those keychain entries may still exist, so the local host list has been kept for the ' +
80
+ 'retry. Re-run once the keychain can answer.';
81
+ const next = ['flipstream auth clear-all'];
82
+ // keychain_timeout means the KEYCHAIN timed out. It is not a label for a
83
+ // slow issuer, which is why revocation now runs outside this path entirely.
84
+ if (keychainTimedOut) {
85
+ throw new TimeoutError(`${summary} — the OS keychain did not respond in time.`, 'keychain_timeout').withDetails({ hint, next, retryable: true });
86
+ }
87
+ throw new CliError(summary, 'clear_incomplete', ExitCode.GENERIC).withDetails({ hint, next, retryable: true });
88
+ }
39
89
  const configRemoved = removeConfigDir();
40
- return this.respond({ clearedHosts: hosts, configRemoved }, () => {
90
+ return this.respond({ clearedHosts: cleared, configRemoved }, () => {
41
91
  if (hosts.length === 0 && !configRemoved) {
42
92
  this.log('Nothing to clear.');
43
93
  }
44
94
  else {
45
- this.log(`Cleared ${hosts.length} host(s): ${hosts.join(', ') || '(none)'}`);
95
+ this.log(`Cleared ${cleared.length} host(s): ${cleared.join(', ') || '(none)'}`);
46
96
  this.log(configRemoved ? 'Removed the local config directory.' : 'No local config directory to remove.');
47
97
  }
48
98
  });
49
99
  }
100
+ // One host's revocation, swallowing everything: the local credential is
101
+ // already gone, so nothing here can improve or worsen the purge.
102
+ async revoke(revocation, timeoutMs) {
103
+ try {
104
+ const meta = await fetchMetadata(revocation.host, { timeoutMs });
105
+ await revokeToken({
106
+ clientId: revocation.clientId,
107
+ revocationEndpoint: meta.revocationEndpoint,
108
+ timeoutMs,
109
+ token: revocation.token,
110
+ tokenTypeHint: 'refresh_token',
111
+ });
112
+ }
113
+ catch {
114
+ // Best-effort by contract.
115
+ }
116
+ }
50
117
  }
@@ -23,7 +23,7 @@ export default class AuthLogin extends BaseCommand {
23
23
  });
24
24
  const expiresAt = Math.floor(Date.now() / 1000) + result.expiresIn;
25
25
  try {
26
- createStore().save(result.host, {
26
+ await createStore().save(result.host, {
27
27
  accessToken: result.accessToken,
28
28
  clientId: result.clientId,
29
29
  expiresAt,
@@ -44,6 +44,12 @@ export default class AuthLogin extends BaseCommand {
44
44
  }
45
45
  // Emit only the (redacted) decoded claims — never the raw token.
46
46
  const claims = redact(result.claims);
47
- return this.respond(claims, (data) => this.log(JSON.stringify(data)));
47
+ return this.respond(claims, (data) => {
48
+ this.log(JSON.stringify(data));
49
+ // Close the loop (E11-3, #102): a fresh login has exactly two sensible
50
+ // next moves — prove the pipe works, then learn the vocabulary.
51
+ this.footer(`\nNext: ${this.config.bin} health (checks the query planner is reachable)\n` +
52
+ ` ${this.config.bin} catalog (lists the sources you can query)`);
53
+ });
48
54
  }
49
55
  }
@@ -14,7 +14,7 @@ export default class AuthLogout extends BaseCommand {
14
14
  async run() {
15
15
  const host = this.resolvedHost();
16
16
  const store = createStore();
17
- const creds = store.load(host);
17
+ const creds = await store.load(host);
18
18
  if (!creds) {
19
19
  return this.respond({ host, loggedOut: true, revoked: false }, () => this.log(`Not logged in to ${host}; nothing to do.`));
20
20
  }
@@ -40,7 +40,7 @@ export default class AuthLogout extends BaseCommand {
40
40
  }
41
41
  }
42
42
  // Always clear local credentials, even if revocation could not be confirmed.
43
- store.clear(host);
43
+ await store.clear(host);
44
44
  forgetHost(host);
45
45
  return this.respond({ host, loggedOut: true, revoked }, () => {
46
46
  this.log(`Logged out of ${host}.`);
@@ -1,8 +1,15 @@
1
1
  import { tokenClaims } from '../../lib/auth/claims.js';
2
2
  import { BaseCommand } from '../../lib/command/base.js';
3
3
  import { ExitCode } from '../../lib/exit-codes.js';
4
- import { redact } from '../../lib/output/redact.js';
4
+ import { isCI } from '../../lib/output/interactivity.js';
5
+ import { maskAccount, redact } from '../../lib/output/redact.js';
5
6
  import { createStore } from '../../lib/store/index.js';
7
+ // 'expires in 25m' below an hour, 'expires in 2h' above — never a rounded-to-0h.
8
+ function formatRemaining(seconds) {
9
+ if (seconds < 3600)
10
+ return `${Math.max(1, Math.round(seconds / 60))}m`;
11
+ return `${Math.round(seconds / 3600)}h`;
12
+ }
6
13
  export default class AuthStatus extends BaseCommand {
7
14
  static description = 'Show the current Flipstream authentication status for a host.';
8
15
  static examples = ['<%= config.bin %> auth status', '<%= config.bin %> auth status --json'];
@@ -11,16 +18,17 @@ export default class AuthStatus extends BaseCommand {
11
18
  async run() {
12
19
  const host = this.resolvedHost();
13
20
  const store = createStore();
14
- if (!store.available()) {
15
- process.stderr.write('The OS keychain is unavailable (e.g. a headless/SSH session). Browser login needs it; ' +
16
- 'a headless client_credentials / service-account path is PLANNED.\n');
21
+ if (!(await store.available())) {
22
+ process.stderr.write('The OS keychain is unavailable (e.g. a headless/SSH session). Browser login needs it. ' +
23
+ 'Over SSH or any other remote access, use a Flipstream service account from your ' +
24
+ 'Flipstream account instead — it needs no browser and no keychain.\n');
17
25
  return this.exit(ExitCode.NETWORK);
18
26
  }
19
- const creds = store.load(host);
27
+ const creds = await store.load(host);
20
28
  if (!creds) {
21
29
  return this.respond({ host, loggedIn: false }, () => this.log(`Not logged in to ${host}. Run \`flipstream auth login\`.`));
22
30
  }
23
- const claims = tokenClaims(host, store) ?? {};
31
+ const claims = (await tokenClaims(host, store)) ?? {};
24
32
  // JSON keeps the stable subject id (agent-friendly); the human line prefers
25
33
  // the friendlier email when present.
26
34
  const account = [claims.sub, claims.email].find((value) => typeof value === 'string') ?? null;
@@ -37,11 +45,30 @@ export default class AuthStatus extends BaseCommand {
37
45
  scopes: creds.scopes,
38
46
  });
39
47
  return this.respond(status, () => {
40
- const relative = expired ? 'expired' : `expires in ${Math.round((creds.expiresAt - nowSeconds) / 3600)}h`;
48
+ // ONE formatter for both the stdout line and the stderr note a status
49
+ // command must not contradict itself about its own subject (E11-2 review).
50
+ const remaining = formatRemaining(creds.expiresAt - nowSeconds);
51
+ const relative = expired ? 'expired' : `expires in ${remaining}`;
52
+ // Disclosure tier (E11-4): mask the account in CI human output. This is
53
+ // HYGIENE for the human line, not a privacy control — the --json path
54
+ // (the one agents/CI actually consume) keeps the stable value by
55
+ // contract, so a public build log is not made safe by this alone.
56
+ const shown = typeof displayAccount === 'string' ? displayAccount : 'unknown';
41
57
  this.log(`Logged in to ${host}`);
42
- this.log(` account: ${typeof displayAccount === 'string' ? displayAccount : 'unknown'}`);
58
+ this.log(` account: ${isCI() ? maskAccount(shown) : shown}`);
43
59
  this.log(` expires: ${expiresAtIso} (${relative})`);
44
60
  this.log(` scopes: ${creds.scopes.join(' ')}`);
61
+ // Expiry narration (E11-2, #101): state the consequence, not just the fact.
62
+ // An expired ACCESS token is normal — commands refresh it on use — so the
63
+ // warning names the failure mode that actually needs the human (refresh
64
+ // rejected → session_expired) rather than crying wolf.
65
+ if (expired) {
66
+ this.note('the access token has expired; commands refresh it automatically — if one fails with ' +
67
+ 'session_expired, run `flipstream auth login`');
68
+ }
69
+ else if (creds.expiresAt - nowSeconds < 24 * 3600) {
70
+ this.note(`the access token expires in ${remaining} and will refresh automatically on use`);
71
+ }
45
72
  });
46
73
  }
47
74
  }
@@ -0,0 +1,15 @@
1
+ import { BaseCommand } from '../lib/command/base.js';
2
+ export default class Catalog extends BaseCommand<typeof Catalog> {
3
+ static args: {
4
+ source: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ 'auth-host': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ static summary: string;
13
+ run(): Promise<unknown>;
14
+ private renderSource;
15
+ }
@@ -0,0 +1,110 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../lib/command/base.js';
3
+ import { plannerFlags } from '../lib/command/flags.js';
4
+ import { authedPlannerClient } from '../lib/command/planner.js';
5
+ import { resolveHost, resolvePlannerUrl } from '../lib/config/xdg.js';
6
+ import { AuthRequiredError } from '../lib/errors.js';
7
+ import { redact } from '../lib/output/redact.js';
8
+ import { sanitizeTerminal } from '../lib/output/sanitize.js';
9
+ import { renderTable } from '../lib/output/table.js';
10
+ import { parseCatalogIndex, parseCatalogSource } from '../lib/planner/catalog.js';
11
+ import { mapCatalogError } from '../lib/planner/errors.js';
12
+ import { createStore } from '../lib/store/index.js';
13
+ export default class Catalog extends BaseCommand {
14
+ static args = {
15
+ source: Args.string({
16
+ description: 'A source name (omit to list every source).',
17
+ // oclif fills a MISSING positional from stdin when stdin is not a TTY, so
18
+ // `echo x | flipstream catalog` would silently become `catalog x` — and a
19
+ // spawned process (the normal case for an agent) has a non-TTY stdin it may
20
+ // never close, which makes that read hang forever. Nothing here wants stdin.
21
+ ignoreStdin: true,
22
+ required: false,
23
+ }),
24
+ };
25
+ static description = 'Print the request vocabulary the query planner accepts. The names shown ARE what `query` takes: `-d` and ' +
26
+ "`-m` and filter keys use an item's `name` (the wire name, e.g. search_date), never its `label` (what a human " +
27
+ 'reads, e.g. Date). Read from the SERVICE, never from a local data-model checkout — the two differ exactly ' +
28
+ 'when it matters most, right after a version bump.';
29
+ static examples = [
30
+ '<%= config.bin %> catalog',
31
+ '<%= config.bin %> catalog gsc',
32
+ '<%= config.bin %> catalog gsc --json',
33
+ ];
34
+ static flags = { ...plannerFlags };
35
+ static summary = 'Show the dimensions and metrics a source accepts.';
36
+ async run() {
37
+ const url = resolvePlannerUrl({ urlFlag: this.flags.url });
38
+ const authHost = resolveHost({ hostFlag: this.flags['auth-host'] ?? this.flags.host });
39
+ const store = createStore();
40
+ if (!(await store.load(authHost)))
41
+ throw AuthRequiredError.notLoggedIn();
42
+ const client = authedPlannerClient({ authHost, store, timeoutMs: this.flags.timeout, url });
43
+ const { source } = this.args;
44
+ let raw;
45
+ const started = Date.now();
46
+ try {
47
+ raw = await client.getCatalog(source, { timeoutMs: this.flags.timeout });
48
+ }
49
+ catch (error) {
50
+ throw mapCatalogError(error, { bin: this.config.bin, source, url });
51
+ }
52
+ // Which host answered (E11-2): the planner and the admin API are different
53
+ // origins, and "which one did I just talk to" is the first debugging question.
54
+ this.verboseLog(`GET ${url}/catalog${source === undefined ? '' : `/${source}`} (${Date.now() - started} ms)`);
55
+ // Redact BEFORE the document becomes output, exactly as `query` does. The
56
+ // served catalog is an allowlist projection built by data-model, so nothing
57
+ // secret should be in it — but "should" is the service's promise, and this is
58
+ // our own output boundary. A projection here would instead drop fields the
59
+ // service legitimately adds, so redaction (which masks secret-NAMED keys and
60
+ // leaves the rest untouched) is the right tool: it keeps `--json` faithful to
61
+ // the document as served while still refusing to print a credential.
62
+ const payload = redact(raw);
63
+ if (source === undefined) {
64
+ const index = parseCatalogIndex(payload);
65
+ return this.respond(payload, () => {
66
+ renderTable(index.sources.map((entry) => ({ ...entry })), [
67
+ { key: 'name', name: 'Source' },
68
+ { key: 'label', name: 'Label' },
69
+ { key: 'dimensions', name: 'Dimensions' },
70
+ { key: 'metrics', name: 'Metrics' },
71
+ ], 'No sources.');
72
+ // Footer to STDERR: it is guidance, not data, and stdout must stay clean.
73
+ const hint = index.defaultSource === undefined ? '' : `, default ${index.defaultSource}`;
74
+ this.footer(`\n${index.sources.length} source(s)${hint}; \`${this.config.bin} catalog ${index.defaultSource ?? '<source>'}\` for its names`);
75
+ });
76
+ }
77
+ const entry = parseCatalogSource(payload);
78
+ return this.respond(payload, () => this.renderSource(entry));
79
+ }
80
+ renderSource(entry) {
81
+ this.log(sanitizeTerminal(`${entry.name} — ${entry.label}`));
82
+ if (entry.description)
83
+ this.log(sanitizeTerminal(entry.description));
84
+ for (const [heading, items] of [
85
+ ['DIMENSIONS', entry.dimensions],
86
+ ['METRICS', entry.metrics],
87
+ ]) {
88
+ this.log(`\n${heading}`);
89
+ renderTable(items.map((item) => ({ ...item })), [
90
+ { key: 'name', name: 'Name' },
91
+ { key: 'label', name: 'Label' },
92
+ { key: 'type', name: 'Type' },
93
+ { key: 'description', name: 'Description' },
94
+ ], '(none)');
95
+ }
96
+ if (entry.customDimensionsMax !== undefined) {
97
+ this.log(`\nCUSTOM DIMENSIONS up to ${entry.customDimensionsMax} per connection`);
98
+ }
99
+ // A runnable next command built from THIS source's real names (E11-3, #102):
100
+ // the reader of a catalog is one edit away from a working query, so hand
101
+ // them that edit. stderr — guidance, not data.
102
+ const dimension = entry.dimensions[0]?.name;
103
+ const metric = entry.metrics[0]?.name;
104
+ if (dimension !== undefined && metric !== undefined) {
105
+ this.footer(`\nTry: ${this.config.bin} query --source ${entry.name} --connection-id <id|name> ` +
106
+ `-d ${dimension} -m ${metric} --json\n` +
107
+ ` (${this.config.bin} connections list prints the ids)`);
108
+ }
109
+ }
110
+ }
@@ -16,4 +16,5 @@ export default class ConnectionsList extends BaseCommand<typeof ConnectionsList>
16
16
  count: number;
17
17
  records: unknown[];
18
18
  }>;
19
+ private printUsageFooter;
19
20
  }