flipstream 0.4.0 → 0.6.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 (103) hide show
  1. package/README.md +357 -27
  2. package/dist/commands/auth/login.js +7 -1
  3. package/dist/commands/auth/status.js +29 -3
  4. package/dist/commands/catalog.d.ts +15 -0
  5. package/dist/commands/catalog.js +110 -0
  6. package/dist/commands/connections/list.d.ts +1 -0
  7. package/dist/commands/connections/list.js +31 -4
  8. package/dist/commands/contract.d.ts +11 -0
  9. package/dist/commands/contract.js +35 -0
  10. package/dist/commands/health.d.ts +10 -0
  11. package/dist/commands/health.js +31 -0
  12. package/dist/commands/log/add.d.ts +16 -0
  13. package/dist/commands/log/add.js +48 -0
  14. package/dist/commands/log/list.d.ts +19 -0
  15. package/dist/commands/log/list.js +43 -0
  16. package/dist/commands/query.d.ts +15 -2
  17. package/dist/commands/query.js +255 -42
  18. package/dist/commands/skills/install.d.ts +16 -0
  19. package/dist/commands/skills/install.js +55 -0
  20. package/dist/commands/workspaces/connections.js +6 -3
  21. package/dist/commands/workspaces/get.js +4 -2
  22. package/dist/commands/workspaces/list.js +3 -0
  23. package/dist/lib/api/admin-client.d.ts +5 -0
  24. package/dist/lib/api/admin-client.js +19 -0
  25. package/dist/lib/api/connections.d.ts +0 -1
  26. package/dist/lib/api/connections.js +0 -25
  27. package/dist/lib/api/errors.d.ts +1 -0
  28. package/dist/lib/api/errors.js +13 -2
  29. package/dist/lib/api/http.d.ts +2 -0
  30. package/dist/lib/api/http.js +40 -4
  31. package/dist/lib/api/hydrate.d.ts +10 -0
  32. package/dist/lib/api/hydrate.js +46 -0
  33. package/dist/lib/api/ids.d.ts +1 -0
  34. package/dist/lib/api/ids.js +5 -0
  35. package/dist/lib/api/log.d.ts +22 -0
  36. package/dist/lib/api/log.js +56 -0
  37. package/dist/lib/api/projections.d.ts +1 -0
  38. package/dist/lib/api/projections.js +23 -0
  39. package/dist/lib/api/short-uuid.d.ts +1 -0
  40. package/dist/lib/api/short-uuid.js +30 -0
  41. package/dist/lib/auth/claims.js +3 -3
  42. package/dist/lib/auth/flow.js +8 -1
  43. package/dist/lib/auth/headless.js +14 -10
  44. package/dist/lib/auth/refresh.js +21 -1
  45. package/dist/lib/command/admin.d.ts +1 -0
  46. package/dist/lib/command/admin.js +21 -0
  47. package/dist/lib/command/base.d.ts +4 -0
  48. package/dist/lib/command/base.js +97 -3
  49. package/dist/lib/command/flags.d.ts +4 -0
  50. package/dist/lib/command/flags.js +11 -0
  51. package/dist/lib/command/planner.d.ts +9 -0
  52. package/dist/lib/command/planner.js +14 -0
  53. package/dist/lib/config/constants.d.ts +3 -1
  54. package/dist/lib/config/constants.js +14 -1
  55. package/dist/lib/config/xdg.d.ts +4 -0
  56. package/dist/lib/config/xdg.js +56 -1
  57. package/dist/lib/errors.d.ts +20 -1
  58. package/dist/lib/errors.js +132 -13
  59. package/dist/lib/output/dialogs.d.ts +27 -0
  60. package/dist/lib/output/dialogs.js +94 -0
  61. package/dist/lib/output/interactivity.d.ts +11 -0
  62. package/dist/lib/output/interactivity.js +48 -0
  63. package/dist/lib/output/redact.d.ts +1 -0
  64. package/dist/lib/output/redact.js +12 -0
  65. package/dist/lib/output/runlog.d.ts +3 -0
  66. package/dist/lib/output/runlog.js +72 -0
  67. package/dist/lib/output/sanitize.d.ts +2 -0
  68. package/dist/lib/output/sanitize.js +57 -0
  69. package/dist/lib/output/sidecar.d.ts +30 -0
  70. package/dist/lib/output/sidecar.js +58 -0
  71. package/dist/lib/output/table.js +5 -1
  72. package/dist/lib/output/trace.d.ts +11 -0
  73. package/dist/lib/output/trace.js +89 -0
  74. package/dist/lib/planner/catalog.d.ts +26 -0
  75. package/dist/lib/planner/catalog.js +60 -0
  76. package/dist/lib/planner/client.d.ts +14 -0
  77. package/dist/lib/planner/client.js +47 -0
  78. package/dist/lib/planner/connection.d.ts +14 -0
  79. package/dist/lib/planner/connection.js +139 -0
  80. package/dist/lib/planner/diagnose.d.ts +8 -0
  81. package/dist/lib/planner/diagnose.js +50 -0
  82. package/dist/lib/planner/errors.d.ts +14 -0
  83. package/dist/lib/planner/errors.js +129 -0
  84. package/dist/lib/planner/filters.d.ts +8 -0
  85. package/dist/lib/planner/filters.js +74 -0
  86. package/dist/lib/planner/request.d.ts +24 -0
  87. package/dist/lib/planner/request.js +51 -0
  88. package/dist/lib/planner/suggest.d.ts +2 -0
  89. package/dist/lib/planner/suggest.js +45 -0
  90. package/dist/lib/planner/vocabulary.d.ts +9 -0
  91. package/dist/lib/planner/vocabulary.js +95 -0
  92. package/dist/lib/skills/install.d.ts +24 -0
  93. package/dist/lib/skills/install.js +69 -0
  94. package/dist/lib/store/keyring.d.ts +3 -0
  95. package/dist/lib/store/keyring.js +45 -2
  96. package/dist/lib/store/memory-store.d.ts +1 -0
  97. package/dist/lib/store/memory-store.js +5 -0
  98. package/docs/AGENT-CONTRACT.md +238 -0
  99. package/oclif.manifest.json +606 -8
  100. package/package.json +22 -3
  101. package/skill/SKILL.md +55 -0
  102. package/dist/lib/auth/register.d.ts +0 -4
  103. package/dist/lib/auth/register.js +0 -43
@@ -1,76 +1,289 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { readFileSync } from 'node:fs';
3
- import { mapDataError } from '../lib/api/errors.js';
4
- import { createAuthedDataClient } from '../lib/api/retry.js';
5
- import { refresh } from '../lib/auth/refresh.js';
3
+ import { tokenClaims } from '../lib/auth/claims.js';
4
+ import { authedAdminClient } from '../lib/command/admin.js';
6
5
  import { BaseCommand } from '../lib/command/base.js';
7
- import { DATA_HOST } from '../lib/config/constants.js';
8
- import { resolveHost } from '../lib/config/xdg.js';
9
- import { AuthRequiredError, UsageError } from '../lib/errors.js';
6
+ import { plannerFlags } from '../lib/command/flags.js';
7
+ import { authedPlannerClient } from '../lib/command/planner.js';
8
+ import { resolveHost, resolvePlannerUrl } from '../lib/config/xdg.js';
9
+ import { AuthRequiredError, DataHttpError, UsageError } from '../lib/errors.js';
10
10
  import { redact } from '../lib/output/redact.js';
11
+ import { assertConnectionActive, connectionIsVisible, resolveConnection } from '../lib/planner/connection.js';
12
+ import { attachVocabularyDiagnosis } from '../lib/planner/diagnose.js';
13
+ import { mapPlannerError, plannerCode } from '../lib/planner/errors.js';
14
+ import { describeSelections } from '../lib/planner/filters.js';
15
+ import { buildLogicalRequest } from '../lib/planner/request.js';
11
16
  import { createStore } from '../lib/store/index.js';
17
+ // The connection_id carried by a body, whoever built it.
18
+ function readConnectionId(body) {
19
+ const value = body?.connection_id;
20
+ return typeof value === 'string' ? value : undefined;
21
+ }
22
+ // Sub-second runs read as milliseconds — "0.0s" looks like a broken timer.
23
+ function formatElapsed(ms) {
24
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
25
+ }
26
+ // Row count of a planner/engine response, when it has the conventional shape.
27
+ // undefined (not 0) when the shape is anything else — the narration then simply
28
+ // omits the count rather than asserting a wrong one.
29
+ function countRows(response) {
30
+ const rows = response?.rows;
31
+ return Array.isArray(rows) ? rows.length : undefined;
32
+ }
12
33
  export default class Query extends BaseCommand {
13
- static description = 'Send an OPAQUE JSON body (--body, --body-file, or piped stdin) to the Flipstream data API and print the ' +
14
- 'structured JSON response. The CLI never constructs, validates, or transforms the query payload — the body is ' +
15
- 'forwarded verbatim (your agent/Skill builds it).';
34
+ static description = 'Send a LOGICAL query (source + dimensions + metrics + filters) to the Flipstream query planner, which ' +
35
+ 'resolves which physical table answers it and returns the rows. Run `flipstream catalog <source>` first — the ' +
36
+ 'names it prints ARE what -d, -m and filter keys take. --body/--body-file/stdin still forwards a JSON body ' +
37
+ 'verbatim for shapes the flags cannot express.';
16
38
  static examples = [
17
- '<%= config.bin %> query --body \'{"opaque":true}\'',
18
- 'echo \'{"opaque":true}\' | <%= config.bin %> query',
19
- '<%= config.bin %> query --body-file query.json --json',
39
+ '<%= config.bin %> catalog gsc',
40
+ "<%= config.bin %> 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",
41
+ '<%= config.bin %> query --source gsc --connection-id <id> -d query -m clicks --dry-run',
42
+ '<%= config.bin %> query --body-file request.json --json',
20
43
  ];
21
44
  static flags = {
22
- 'auth-host': Flags.string({
23
- description: 'OAuth issuer host for credentials/refresh (defaults to the prod issuer).',
45
+ ...plannerFlags,
46
+ body: Flags.string({ description: 'Send this JSON body verbatim.', exclusive: ['body-file'] }),
47
+ 'body-file': Flags.string({ description: 'Path to a file holding the JSON body verbatim ("-" reads stdin).' }),
48
+ 'connection-id': Flags.string({ description: 'Connection UUID, or a name to look up.' }),
49
+ dimension: Flags.string({ char: 'd', description: 'Dimension name (repeatable).', multiple: true }),
50
+ 'dry-run': Flags.boolean({ description: 'Print the request body and send nothing.' }),
51
+ filter: Flags.string({
52
+ char: 'f',
53
+ description: "Filter as col=a,b | col=from..to | col=<json> (repeatable). 'col=' is refused.",
54
+ multiple: true,
24
55
  }),
25
- body: Flags.string({ description: 'Opaque JSON request body as a string.', exclusive: ['body-file'] }),
26
- 'body-file': Flags.string({ description: 'Path to a file holding the opaque JSON request body.' }),
56
+ metric: Flags.string({ char: 'm', description: 'Metric name (repeatable).', multiple: true }),
57
+ offset: Flags.integer({ description: 'Row offset.' }),
58
+ rows: Flags.integer({ description: 'Row limit (default 100).' }),
59
+ sort: Flags.string({ char: 's', description: 'FIELD[:asc|desc] (repeatable).', multiple: true }),
60
+ source: Flags.string({ description: 'The dataset discriminator, e.g. gsc.' }),
61
+ table: Flags.string({ description: 'Physical table — honoured only for an unmodelled source.' }),
27
62
  };
28
- static summary = 'Run an opaque query against the Flipstream data API.';
63
+ static summary = 'Run a logical query against the Flipstream query planner.';
29
64
  async run() {
30
- // --host overrides the DATA query target; --auth-host the issuer holding creds.
31
- const dataHost = this.flags.host ?? DATA_HOST;
32
- const authHost = resolveHost({ hostFlag: this.flags['auth-host'] });
65
+ const url = resolvePlannerUrl({ urlFlag: this.flags.url });
66
+ const authHost = resolveHost({ hostFlag: this.flags['auth-host'] ?? this.flags.host });
67
+ const verbatim = this.readVerbatimBody();
68
+ const built = verbatim === undefined
69
+ ? buildLogicalRequest({
70
+ connectionId: this.flags['connection-id'],
71
+ dimensions: this.flags.dimension,
72
+ filters: this.flags.filter,
73
+ metrics: this.flags.metric,
74
+ offset: this.flags.offset,
75
+ rows: this.flags.rows,
76
+ sort: this.flags.sort,
77
+ source: this.flags.source,
78
+ table: this.flags.table,
79
+ }, this.config.bin)
80
+ : undefined;
81
+ const body = verbatim ?? built;
82
+ // Narrate the filter interpretation (E11-2): the selection-length predicate
83
+ // routes silently, so say which reading each filter got — in the encoder
84
+ // module's own words (filters.ts owns the predicate). Flags path only; a
85
+ // verbatim body is the caller's own construction.
86
+ if (built !== undefined)
87
+ for (const line of describeSelections(built.filters))
88
+ this.note(line);
89
+ // Deliberately OFFLINE, and deliberately BEFORE the credential check: it
90
+ // prints what you typed, so a connection NAME is left as you wrote it.
91
+ // Resolving it would make a dry run need a token, which defeats the point —
92
+ // `flipstream connections list` is the command for that.
93
+ if (this.flags['dry-run']) {
94
+ return this.respond(body, (data) => this.log(JSON.stringify(data, null, 2)));
95
+ }
33
96
  const store = createStore();
34
- // Distinguish never-logged-in (no creds) from a later expired/refresh-rejected
35
- // session (the wrapper raises session_expired on a null refresh).
36
97
  if (!store.load(authHost))
37
98
  throw AuthRequiredError.notLoggedIn();
38
- const body = this.parseBody();
39
- const client = createAuthedDataClient({
40
- accessTokenIfFresh: (host) => store.accessTokenIfFresh(host),
41
- authHost,
42
- host: dataHost,
43
- refresh: (host) => refresh(host, { store, timeoutMs: this.flags.timeout }),
44
- timeoutMs: this.flags.timeout,
45
- });
99
+ const admin = authedAdminClient(authHost, store, this.flags.timeout);
100
+ // Resolve + check the connection for BOTH paths. Name resolution is only for
101
+ // the flags path (a verbatim body stays verbatim), but the inactive check
102
+ // applies to whatever connection_id is about to be sent — otherwise --body
103
+ // would be a one-flag bypass of it.
104
+ const target = verbatim === undefined ? this.flags['connection-id'] : readConnectionId(body);
105
+ let connectionLabel = target === undefined || target.length === 0 ? 'unspecified' : target;
106
+ if (target !== undefined && target.length > 0) {
107
+ // The resolution note ("connection acme → uuid") is an environmental
108
+ // inference like any other narration: routed through note() so it is
109
+ // redacted, sanitized, and silent under --json/--ndjson (E11-2).
110
+ const resolved = await resolveConnection(admin, target, {
111
+ note: (message) => this.note(message),
112
+ });
113
+ assertConnectionActive(resolved);
114
+ if (verbatim === undefined)
115
+ body.connection_id = resolved.id;
116
+ connectionLabel = resolved.name.length > 0 ? `${resolved.name} (${resolved.id})` : resolved.id;
117
+ }
118
+ const client = authedPlannerClient({ authHost, store, timeoutMs: this.flags.timeout, url });
119
+ const started = Date.now();
46
120
  let response;
47
121
  try {
48
122
  response = await client.postQuery(body, { timeoutMs: this.flags.timeout });
49
123
  }
50
124
  catch (error) {
51
- throw mapDataError(error);
125
+ const engine = await this.engineFailureDetails(error, admin, body);
126
+ throw await this.enrichPlannerError(mapPlannerError(error), body, { authHost, client, engine, store });
52
127
  }
53
- // Redact known secret KEY NAMES (E3-4) so a token-like key echoed by the DATA
54
- // host never reaches --json/NDJSON/human output.
128
+ const elapsedMs = Date.now() - started;
129
+ // Timing to STDERR — diagnostics, never part of the data (protects --json).
130
+ this.verboseLog(`POST ${url}/query (${elapsedMs} ms)`);
131
+ // Resolved-context line (E11-2): which host answered, as which connection,
132
+ // for which source, and how much came back — the facts an agent (or a human
133
+ // sanity-checking numbers) otherwise has to reconstruct from four places.
134
+ const source = body?.source;
135
+ const rows = countRows(response);
136
+ this.note(`planner ${url} · connection ${connectionLabel} · source ${typeof source === 'string' ? source : 'unspecified'}` +
137
+ `${rows === undefined ? '' : ` · ${rows} row${rows === 1 ? '' : 's'}`} in ${formatElapsed(elapsedMs)}`);
138
+ // Redact known secret KEY NAMES so a token-like key echoed by the planner or
139
+ // the engine behind it never reaches --json/NDJSON/human output.
55
140
  const safe = redact(response);
56
141
  return this.respond(safe, (data) => this.log(JSON.stringify(data, null, 2)));
57
142
  }
58
- // Forward the body VERBATIM: parse-only, no schema and no field handling.
59
- parseBody() {
143
+ // ENGINE_FAILED covers everything from a bad connection to broken SQL, and the
144
+ // distinguishing detail stays in the planner's log on purpose (it names tables).
145
+ // The most common cause by far is a connection this token cannot resolve — a
146
+ // question we CAN ask pulse-admin ourselves. Returns envelope DETAILS (not a
147
+ // stderr side effect) so the --json consumer — the one the contract is written
148
+ // for — sees the diagnosis too. Only speaks on a definite no; a confident
149
+ // wrong hint in front of a real failure is worse than none.
150
+ async engineFailureDetails(error, admin, body) {
151
+ if (!(error instanceof DataHttpError) || plannerCode(error.bodyText) !== 'ENGINE_FAILED')
152
+ return undefined;
153
+ const connectionId = readConnectionId(body);
154
+ if (connectionId === undefined || connectionId.length === 0)
155
+ return undefined;
156
+ if ((await connectionIsVisible(admin, connectionId)) !== false)
157
+ return undefined;
158
+ return {
159
+ hint: 'Your token cannot see this connection — pulse-data resolves it through pulse-admin as you, ' +
160
+ 'and gets nothing. Pick one your token can reach.',
161
+ next: [`${this.config.bin} connections list --json`],
162
+ retryable: false,
163
+ };
164
+ }
165
+ // Post-map enrichment (E11-1): attach the remediation only a command with the
166
+ // full request in hand can know. The mapper stays generic; this layer knows the
167
+ // source that was queried and the token that was used.
168
+ async enrichPlannerError(error, body, context) {
169
+ if (context.engine !== undefined)
170
+ error.withDetails(context.engine);
171
+ // An UNKNOWN_* vocabulary rejection: diagnose against the catalog FIRST
172
+ // (specific hint + runnable next — lib/planner/diagnose.ts), then fill any
173
+ // remaining gap with generic remediation, never displacing the diagnosis.
174
+ if (error.details.upstreamCode?.startsWith('UNKNOWN_')) {
175
+ // Diagnosis never triggers a token refresh: an error-path bonus fetch
176
+ // must not be able to mutate persistent auth state (review, #110).
177
+ if (context.store.accessTokenIfFresh(context.authHost) === null) {
178
+ this.verboseLog('vocabulary diagnosis skipped: no fresh access token (diagnosis never refreshes)');
179
+ }
180
+ else {
181
+ await attachVocabularyDiagnosis(error, {
182
+ bin: this.config.bin,
183
+ body,
184
+ // A bonus fetch gets a bonus-sized budget — a hanging catalog must
185
+ // not double the time-to-error of a fast deterministic 400.
186
+ fetchCatalog: (catalogSource) => context.client.getCatalog(catalogSource, { timeoutMs: Math.min(this.flags.timeout ?? 30_000, 5000) }),
187
+ onSkip: (reason) => this.verboseLog(`vocabulary diagnosis skipped: ${reason}`),
188
+ });
189
+ }
190
+ // Gap-filling generic remediation. The interpolated source comes from
191
+ // the request body (caller-supplied JSON under --body/stdin) and `next`
192
+ // strings are commands the contract tells an agent to RUN: only a
193
+ // catalog-key-shaped name is embedded, and never for UNKNOWN_SOURCE —
194
+ // that would suggest the very name the planner just rejected.
195
+ const source = body?.source;
196
+ const sourceName = typeof source === 'string' && /^[\w.-]{1,64}$/.test(source) ? source : undefined;
197
+ if (error.details.next === undefined) {
198
+ const useSource = error.details.upstreamCode !== 'UNKNOWN_SOURCE' && sourceName !== undefined;
199
+ error.withDetails({ next: [`${this.config.bin} catalog${useSource ? ` ${sourceName}` : ''}`] });
200
+ }
201
+ if (error.details.hint === undefined) {
202
+ error.withDetails({
203
+ hint: 'Names for -d, -m and filter keys must come from the catalog — no aliases, no guessing.',
204
+ });
205
+ }
206
+ error.withDetails({ retryable: false });
207
+ }
208
+ // A 403 two services away is near-undebuggable without knowing what the token
209
+ // actually carried. Scopes/roles are display-only claims (never used to gate
210
+ // anything client-side) and are not secrets; both lists are bounded so a
211
+ // token from a hostile --auth-host issuer cannot balloon the envelope.
212
+ if (error.code === 'role_forbidden') {
213
+ const claims = tokenClaims(context.authHost, context.store) ?? {};
214
+ const roles = (Array.isArray(claims.roles) ? claims.roles.filter((r) => typeof r === 'string') : [])
215
+ .slice(0, 20)
216
+ .map((role) => role.slice(0, 64));
217
+ const scopes = (context.store.load(context.authHost)?.scopes ?? [])
218
+ .slice(0, 20)
219
+ .map((scope) => scope.slice(0, 64));
220
+ error.withDetails({
221
+ hint: `Your token carries scopes [${scopes.join(' ')}] and roles [${roles.join(' ')}]. ` +
222
+ 'The planner admits a caller with ANY allowed role OR all required scopes ' +
223
+ '(grid_data:read + connections:read) — re-login if these look stale.',
224
+ next: [`${this.config.bin} auth status`, `${this.config.bin} auth login`],
225
+ retryable: false,
226
+ });
227
+ }
228
+ return error;
229
+ }
230
+ // A verbatim body short-circuits the whole builder. Returns undefined when the
231
+ // flags path should be used instead.
232
+ readVerbatimBody() {
233
+ const file = this.flags['body-file'];
60
234
  let raw;
61
- if (this.flags.body !== undefined)
235
+ let label;
236
+ if (this.flags.body !== undefined) {
62
237
  raw = this.flags.body;
63
- else if (this.flags['body-file'] !== undefined)
64
- raw = readFileSync(this.flags['body-file'], 'utf8');
65
- else if (process.stdin.isTTY)
66
- throw new UsageError('Provide a body via --body, --body-file <path>, or piped stdin.');
67
- else
238
+ label = '--body';
239
+ }
240
+ else if (file !== undefined) {
241
+ raw = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
242
+ label = file === '-' ? 'stdin (--body-file -)' : `--body-file ${file}`;
243
+ }
244
+ else if (this.flags.source === undefined && this.flags['connection-id'] === undefined && !process.stdin.isTTY) {
245
+ // Piped stdin with no flags: the pre-E8 invocation, still supported. This
246
+ // is an INFERENCE — a spawned process has non-TTY stdin by default — so
247
+ // it is announced, never silent (E11-2), and an empty read means "no
248
+ // request was given at all", not "bad JSON" (E11-1).
249
+ this.note('reading the request body from stdin (no query flags given and stdin is piped)');
68
250
  raw = readFileSync(0, 'utf8');
251
+ label = 'stdin';
252
+ if (raw.trim() === '') {
253
+ // Empty stdin + builder flags (-d/-m/-f/...) means the caller wanted
254
+ // the FLAGS path: fall through to the builder, whose missing_source_*
255
+ // errors name exactly what is absent — never claim "no flags" when
256
+ // flags were passed (#100's own rule).
257
+ const builderFlagGiven = this.flags.dimension !== undefined ||
258
+ this.flags.metric !== undefined ||
259
+ this.flags.filter !== undefined ||
260
+ this.flags.sort !== undefined ||
261
+ this.flags.rows !== undefined ||
262
+ this.flags.offset !== undefined ||
263
+ this.flags.table !== undefined;
264
+ if (builderFlagGiven)
265
+ return undefined;
266
+ throw new UsageError('No request given: no --source/--connection-id flags, and stdin was empty.', 'missing_request').withDetails({
267
+ docs: 'docs/AGENT-CONTRACT.md#the-query-request-shape-logicalrequest',
268
+ hint: 'Build the query from flags (--source, --connection-id, -d, -m), or pipe a JSON body on stdin.',
269
+ next: [`${this.config.bin} catalog`, `${this.config.bin} connections list --json`],
270
+ retryable: false,
271
+ });
272
+ }
273
+ }
274
+ else
275
+ return undefined;
69
276
  try {
70
277
  return JSON.parse(raw);
71
278
  }
72
279
  catch {
73
- throw new UsageError('Request body is not valid JSON.');
280
+ // Name where the body came from under the stdin inference the caller may
281
+ // not even realise a body was being read.
282
+ throw new UsageError(`Request body from ${label} is not valid JSON.`, 'invalid_body_json').withDetails({
283
+ hint: 'The body must be one JSON object in LogicalRequest shape; flags build it for you.',
284
+ next: [`${this.config.bin} query --source <source> --connection-id <id> -d <dim> -m <metric> --dry-run`],
285
+ retryable: false,
286
+ });
74
287
  }
75
288
  }
76
289
  }
@@ -0,0 +1,16 @@
1
+ import { BaseCommand } from '../../lib/command/base.js';
2
+ export default class SkillsInstall extends BaseCommand<typeof SkillsInstall> {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ agent: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ };
9
+ static summary: string;
10
+ run(): Promise<{
11
+ installed: Array<{
12
+ agent: string;
13
+ path: string;
14
+ }>;
15
+ }>;
16
+ }
@@ -0,0 +1,55 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../lib/command/base.js';
3
+ import { confirm } from '../../lib/output/dialogs.js';
4
+ import { isCI } from '../../lib/output/interactivity.js';
5
+ import { installSkill, resolveTargets, SUPPORTED_AGENTS } from '../../lib/skills/install.js';
6
+ export default class SkillsInstall extends BaseCommand {
7
+ static description = 'Install the flipstream skill (SKILL.md) into the global skills directory of every detected AI coding agent ' +
8
+ '(Claude Code, Codex, Cursor, OpenCode). Detection is local env/dir inspection only — nothing is reported ' +
9
+ 'anywhere. The skill teaches an agent to retrieve the command surface and data vocabulary from the CLI ' +
10
+ 'instead of guessing.';
11
+ static examples = [
12
+ '<%= config.bin %> skills install',
13
+ '<%= config.bin %> skills install --yes',
14
+ '<%= config.bin %> skills install --agent claude-code --agent codex',
15
+ ];
16
+ static flags = {
17
+ agent: Flags.string({
18
+ description: `Install for this agent id only (repeatable). Known: ${SUPPORTED_AGENTS.map((a) => a.id).join(', ')}.`,
19
+ multiple: true,
20
+ options: SUPPORTED_AGENTS.map((a) => a.id),
21
+ }),
22
+ yes: Flags.boolean({ char: 'y', default: false, description: 'Install without asking.' }),
23
+ };
24
+ static summary = 'Install the flipstream skill for detected AI coding agents.';
25
+ async run() {
26
+ // CI never installs skills: the runner's home is ephemeral and nobody asked.
27
+ if (isCI()) {
28
+ // Diagnostics, not data — stderr, per the catalog-footer precedent.
29
+ return this.respond({ installed: [] }, () => process.stderr.write('CI environment detected — skipping skill install.\n'));
30
+ }
31
+ const targets = resolveTargets({ explicit: this.flags.agent });
32
+ if (targets.length === 0) {
33
+ return this.respond({ installed: [] }, () => process.stderr.write('No supported agents detected (looked for a driving agent and for ' +
34
+ `${SUPPORTED_AGENTS.map((a) => a.id).join(', ')} home directories).\n`));
35
+ }
36
+ // Running `skills install` already states intent, so the non-interactive
37
+ // fallback is yes (announced) — --yes exists to skip the question entirely.
38
+ const proceed = this.flags.yes ||
39
+ (await confirm(`Install the flipstream skill for: ${targets.map((t) => t.name).join(', ')}?`, {
40
+ defaultValue: true,
41
+ fallbackValue: true,
42
+ unblockFlag: '--yes',
43
+ }));
44
+ if (!proceed) {
45
+ return this.respond({ installed: [] }, () => process.stderr.write('Skipped.\n'));
46
+ }
47
+ const result = installSkill(targets);
48
+ return this.respond(result, () => {
49
+ for (const entry of result.installed) {
50
+ const backup = entry.backup === undefined ? '' : ` (previous copy preserved at ${entry.backup})`;
51
+ this.log(`${entry.status} ${entry.agent}: ${entry.path}${backup}`);
52
+ }
53
+ });
54
+ }
55
+ }
@@ -1,5 +1,6 @@
1
1
  import { Args } from '@oclif/core';
2
- import { fetchConnections, resolveWorkspaceNames } from '../../lib/api/connections.js';
2
+ import { fetchConnections } from '../../lib/api/connections.js';
3
+ import { hydrate, WORKSPACE_NAME } from '../../lib/api/hydrate.js';
3
4
  import { assertUuid } from '../../lib/api/ids.js';
4
5
  import { authedAdminClient, renderConnectionsTable } from '../../lib/command/admin.js';
5
6
  import { BaseCommand } from '../../lib/command/base.js';
@@ -9,7 +10,7 @@ import { createStore } from '../../lib/store/index.js';
9
10
  export default class WorkspacesConnections extends BaseCommand {
10
11
  static aliases = ['ws:connections'];
11
12
  static args = {
12
- id: Args.string({ description: 'Workspace id (UUID).', required: true }),
13
+ id: Args.string({ description: 'Workspace id (UUID).', ignoreStdin: true, required: true }),
13
14
  };
14
15
  static description = "List one workspace's connections — the ergonomic form of `connections list --workspace <id>`. " +
15
16
  'Targets the OAuth/admin host.';
@@ -27,6 +28,7 @@ export default class WorkspacesConnections extends BaseCommand {
27
28
  if (!store.load(host))
28
29
  throw AuthRequiredError.notLoggedIn();
29
30
  const client = authedAdminClient(host, store, this.flags.timeout);
31
+ const startedMs = Date.now();
30
32
  const projected = await fetchConnections(client, {
31
33
  all: this.flags.all,
32
34
  limit: this.flags.limit,
@@ -35,7 +37,8 @@ export default class WorkspacesConnections extends BaseCommand {
35
37
  sort: this.flags.sort,
36
38
  workspace: this.args.id,
37
39
  });
38
- await resolveWorkspaceNames(client, projected.records);
40
+ await hydrate(client, projected.records, [WORKSPACE_NAME]);
41
+ this.verboseLog(`GET ${host}/connections (${Date.now() - startedMs} ms)`);
39
42
  return this.respondList(projected, (records) => renderConnectionsTable(records));
40
43
  }
41
44
  }
@@ -11,10 +11,10 @@ import { createStore } from '../../lib/store/index.js';
11
11
  export default class WorkspacesGet extends BaseCommand {
12
12
  static aliases = ['ws:get'];
13
13
  static args = {
14
- id: Args.string({ description: 'Workspace id (UUID).', required: true }),
14
+ id: Args.string({ description: 'Workspace id (UUID).', ignoreStdin: true, required: true }),
15
15
  };
16
16
  static description = 'Fetch one workspace by id (the admin API "Client"). A foreign or missing id collapses to a single ' +
17
- "not-found message (no existence leak). Targets the OAuth/admin host.";
17
+ 'not-found message (no existence leak). Targets the OAuth/admin host.';
18
18
  static examples = [
19
19
  '<%= config.bin %> workspaces get <id>',
20
20
  '<%= config.bin %> ws get <id> --json',
@@ -42,7 +42,9 @@ export default class WorkspacesGet extends BaseCommand {
42
42
  });
43
43
  let raw;
44
44
  try {
45
+ const startedMs = Date.now();
45
46
  raw = await client.get(`/clients/${this.args.id}`, { query: { refresh: this.flags.refresh || undefined } });
47
+ this.verboseLog(`GET ${host}/clients/${this.args.id} (${Date.now() - startedMs} ms)`);
46
48
  }
47
49
  catch (error) {
48
50
  // Collapse a foreign-id 403 and a 404 to ONE message so neither leaks existence.
@@ -53,7 +53,10 @@ export default class WorkspacesList extends BaseCommand {
53
53
  }
54
54
  };
55
55
  const start = { limit: this.flags.limit, offset: this.flags.offset };
56
+ const startedMs = Date.now();
56
57
  const envelope = this.flags.all ? await drainPages(fetchPage, start) : await fetchPage(start);
58
+ // Which origin answered (E11-2): planner vs admin is the first debugging question.
59
+ this.verboseLog(`GET ${host}/clients (${Date.now() - startedMs} ms)`);
57
60
  const projected = { count: envelope.count, records: envelope.records.map((record) => projectWorkspace(record)) };
58
61
  if (this.flags['with-connection-counts'])
59
62
  await this.addConnectionCounts(client, projected.records);
@@ -3,8 +3,13 @@ export interface AdminGetOptions {
3
3
  query?: Record<string, boolean | number | string | undefined>;
4
4
  timeoutMs?: number;
5
5
  }
6
+ export interface AdminPostOptions {
7
+ body?: unknown;
8
+ timeoutMs?: number;
9
+ }
6
10
  export interface AdminClient {
7
11
  get(path: string, options?: AdminGetOptions): Promise<unknown>;
12
+ post(path: string, options?: AdminPostOptions): Promise<unknown>;
8
13
  }
9
14
  export interface AdminClientOptions {
10
15
  getToken: () => null | Promise<null | string> | string;
@@ -22,6 +22,16 @@ export function createAdminClient(options) {
22
22
  url: `${base}${path}`,
23
23
  });
24
24
  },
25
+ async post(path, postOptions = {}) {
26
+ const token = await options.getToken();
27
+ return requestJson({
28
+ body: postOptions.body,
29
+ method: 'POST',
30
+ timeoutMs: postOptions.timeoutMs ?? defaultTimeout,
31
+ token,
32
+ url: `${base}${path}`,
33
+ });
34
+ },
25
35
  };
26
36
  }
27
37
  // The authed admin client: the SAME withFreshToken 401->refresh->retry-once
@@ -40,5 +50,14 @@ export function createAuthedAdminClient(deps) {
40
50
  url: `${base}${path}`,
41
51
  }));
42
52
  },
53
+ post(path, postOptions = {}) {
54
+ return withFreshToken(deps, (token) => requestJson({
55
+ body: postOptions.body,
56
+ method: 'POST',
57
+ timeoutMs: postOptions.timeoutMs ?? defaultTimeout,
58
+ token,
59
+ url: `${base}${path}`,
60
+ }));
61
+ },
43
62
  };
44
63
  }
@@ -11,4 +11,3 @@ export declare function fetchConnections(client: AdminClient, query: Connections
11
11
  count: number;
12
12
  records: Array<Record<string, unknown>>;
13
13
  }>;
14
- export declare function resolveWorkspaceNames(client: AdminClient, records: Array<Record<string, unknown>>): Promise<void>;
@@ -17,28 +17,3 @@ export async function fetchConnections(client, query) {
17
17
  const envelope = query.all ? await drainPages(fetchPage, start) : await fetchPage(start);
18
18
  return { count: envelope.count, records: envelope.records.map((record) => projectConnection(record)) };
19
19
  }
20
- // Fill `client_name` for connections that lack it (the API didn't nest the
21
- // client): one drained workspace lookup, keyed by id. Skipped when all already
22
- // have it; best-effort (leaves names unresolved rather than failing the list).
23
- export async function resolveWorkspaceNames(client, records) {
24
- const missing = records.some((record) => record.client_name === undefined && record.client_id !== undefined);
25
- if (!missing)
26
- return;
27
- const names = new Map();
28
- try {
29
- const workspaces = await drainPages(async (page) => normalizeList(await client.get('/clients', { query: { limit: page.limit, offset: page.offset } })), { limit: 100, offset: 0 });
30
- for (const workspace of workspaces.records) {
31
- const ws = workspace;
32
- if (ws.id)
33
- names.set(ws.id, ws.name ?? '');
34
- }
35
- }
36
- catch {
37
- return;
38
- }
39
- for (const record of records) {
40
- if (record.client_name === undefined && typeof record.client_id === 'string') {
41
- record.client_name = names.get(record.client_id) ?? null;
42
- }
43
- }
44
- }
@@ -4,4 +4,5 @@ export declare function parseDataErrorMessage(status: number, bodyText: string):
4
4
  message: string;
5
5
  };
6
6
  export declare function notFoundError(): CliError;
7
+ export declare function rateLimited(message: string, retryAfterMs?: number): CliError;
7
8
  export declare function mapDataError(error: unknown): CliError;
@@ -1,4 +1,4 @@
1
- import { AuthFailedError, CliError, DataHttpError, NetworkError } from '../errors.js';
1
+ import { AuthFailedError, CliError, DataHttpError, NetworkError, retryPolicy } from '../errors.js';
2
2
  import { ExitCode } from '../exit-codes.js';
3
3
  import { redact } from '../output/redact.js';
4
4
  const DETAIL_MAX = 500;
@@ -33,6 +33,15 @@ export function parseDataErrorMessage(status, bodyText) {
33
33
  export function notFoundError() {
34
34
  return new CliError('Resource not found or not in your organization.', 'not_found', ExitCode.GENERIC);
35
35
  }
36
+ // The one 429 mapping, shared by the generic and planner mappers so the two
37
+ // can never drift: code `rate_limited`, exit 7 (transient), retry policy with
38
+ // the server-stated wait when one was sent.
39
+ export function rateLimited(message, retryAfterMs) {
40
+ return new CliError(message, 'rate_limited', ExitCode.NETWORK).withDetails({
41
+ hint: 'Wait, then re-run the same command.',
42
+ ...retryPolicy(retryAfterMs),
43
+ });
44
+ }
36
45
  // Map a DataClient/retry failure onto the E3 error model with a deterministic exit
37
46
  // code (AuthRequired->4, AuthFailed->5, Network->7, Timeout->8). Network, Timeout
38
47
  // and AuthRequired (session_expired) pass through unchanged; a DataHttpError is
@@ -54,8 +63,10 @@ export function mapDataError(error) {
54
63
  }
55
64
  if (error.status === 404)
56
65
  return notFoundError();
66
+ if (error.status === 429)
67
+ return rateLimited('Rate limited by the service.', error.retryAfterMs);
57
68
  if (error.status >= 500) {
58
- return new NetworkError(message, 'data_upstream_error');
69
+ return new NetworkError(message, 'data_upstream_error').withDetails(retryPolicy(error.retryAfterMs));
59
70
  }
60
71
  return new CliError(message, providerError ?? 'data_request_failed', ExitCode.GENERIC);
61
72
  }
@@ -6,4 +6,6 @@ export interface JsonRequest {
6
6
  token?: null | string;
7
7
  url: string;
8
8
  }
9
+ export declare const MAX_RETRY_AFTER_MS = 3600000;
10
+ export declare function parseRetryAfterMs(header: null | string): number | undefined;
9
11
  export declare function requestJson(req: JsonRequest): Promise<unknown>;