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.
- package/README.md +357 -27
- package/dist/commands/auth/login.js +7 -1
- package/dist/commands/auth/status.js +29 -3
- package/dist/commands/catalog.d.ts +15 -0
- package/dist/commands/catalog.js +110 -0
- package/dist/commands/connections/list.d.ts +1 -0
- package/dist/commands/connections/list.js +31 -4
- package/dist/commands/contract.d.ts +11 -0
- package/dist/commands/contract.js +35 -0
- package/dist/commands/health.d.ts +10 -0
- package/dist/commands/health.js +31 -0
- package/dist/commands/log/add.d.ts +16 -0
- package/dist/commands/log/add.js +48 -0
- package/dist/commands/log/list.d.ts +19 -0
- package/dist/commands/log/list.js +43 -0
- package/dist/commands/query.d.ts +15 -2
- package/dist/commands/query.js +255 -42
- package/dist/commands/skills/install.d.ts +16 -0
- package/dist/commands/skills/install.js +55 -0
- package/dist/commands/workspaces/connections.js +6 -3
- package/dist/commands/workspaces/get.js +4 -2
- package/dist/commands/workspaces/list.js +3 -0
- package/dist/lib/api/admin-client.d.ts +5 -0
- package/dist/lib/api/admin-client.js +19 -0
- package/dist/lib/api/connections.d.ts +0 -1
- package/dist/lib/api/connections.js +0 -25
- package/dist/lib/api/errors.d.ts +1 -0
- package/dist/lib/api/errors.js +13 -2
- package/dist/lib/api/http.d.ts +2 -0
- package/dist/lib/api/http.js +40 -4
- package/dist/lib/api/hydrate.d.ts +10 -0
- package/dist/lib/api/hydrate.js +46 -0
- package/dist/lib/api/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +5 -0
- package/dist/lib/api/log.d.ts +22 -0
- package/dist/lib/api/log.js +56 -0
- package/dist/lib/api/projections.d.ts +1 -0
- package/dist/lib/api/projections.js +23 -0
- package/dist/lib/api/short-uuid.d.ts +1 -0
- package/dist/lib/api/short-uuid.js +30 -0
- package/dist/lib/auth/claims.js +3 -3
- package/dist/lib/auth/flow.js +8 -1
- package/dist/lib/auth/headless.js +14 -10
- package/dist/lib/auth/refresh.js +21 -1
- package/dist/lib/command/admin.d.ts +1 -0
- package/dist/lib/command/admin.js +21 -0
- package/dist/lib/command/base.d.ts +4 -0
- package/dist/lib/command/base.js +97 -3
- package/dist/lib/command/flags.d.ts +4 -0
- package/dist/lib/command/flags.js +11 -0
- package/dist/lib/command/planner.d.ts +9 -0
- package/dist/lib/command/planner.js +14 -0
- package/dist/lib/config/constants.d.ts +3 -1
- package/dist/lib/config/constants.js +14 -1
- package/dist/lib/config/xdg.d.ts +4 -0
- package/dist/lib/config/xdg.js +56 -1
- package/dist/lib/errors.d.ts +20 -1
- package/dist/lib/errors.js +132 -13
- package/dist/lib/output/dialogs.d.ts +27 -0
- package/dist/lib/output/dialogs.js +94 -0
- package/dist/lib/output/interactivity.d.ts +11 -0
- package/dist/lib/output/interactivity.js +48 -0
- package/dist/lib/output/redact.d.ts +1 -0
- package/dist/lib/output/redact.js +12 -0
- package/dist/lib/output/runlog.d.ts +3 -0
- package/dist/lib/output/runlog.js +72 -0
- package/dist/lib/output/sanitize.d.ts +2 -0
- package/dist/lib/output/sanitize.js +57 -0
- package/dist/lib/output/sidecar.d.ts +30 -0
- package/dist/lib/output/sidecar.js +58 -0
- package/dist/lib/output/table.js +5 -1
- package/dist/lib/output/trace.d.ts +11 -0
- package/dist/lib/output/trace.js +89 -0
- package/dist/lib/planner/catalog.d.ts +26 -0
- package/dist/lib/planner/catalog.js +60 -0
- package/dist/lib/planner/client.d.ts +14 -0
- package/dist/lib/planner/client.js +47 -0
- package/dist/lib/planner/connection.d.ts +14 -0
- package/dist/lib/planner/connection.js +139 -0
- package/dist/lib/planner/diagnose.d.ts +8 -0
- package/dist/lib/planner/diagnose.js +50 -0
- package/dist/lib/planner/errors.d.ts +14 -0
- package/dist/lib/planner/errors.js +129 -0
- package/dist/lib/planner/filters.d.ts +8 -0
- package/dist/lib/planner/filters.js +74 -0
- package/dist/lib/planner/request.d.ts +24 -0
- package/dist/lib/planner/request.js +51 -0
- package/dist/lib/planner/suggest.d.ts +2 -0
- package/dist/lib/planner/suggest.js +45 -0
- package/dist/lib/planner/vocabulary.d.ts +9 -0
- package/dist/lib/planner/vocabulary.js +95 -0
- package/dist/lib/skills/install.d.ts +24 -0
- package/dist/lib/skills/install.js +69 -0
- package/dist/lib/store/keyring.d.ts +3 -0
- package/dist/lib/store/keyring.js +45 -2
- package/dist/lib/store/memory-store.d.ts +1 -0
- package/dist/lib/store/memory-store.js +5 -0
- package/docs/AGENT-CONTRACT.md +238 -0
- package/oclif.manifest.json +606 -8
- package/package.json +22 -3
- package/skill/SKILL.md +55 -0
- package/dist/lib/auth/register.d.ts +0 -4
- package/dist/lib/auth/register.js +0 -43
package/dist/commands/query.js
CHANGED
|
@@ -1,76 +1,289 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
|
14
|
-
'
|
|
15
|
-
'
|
|
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 %>
|
|
18
|
-
|
|
19
|
-
'<%= config.bin %> query --
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
63
|
+
static summary = 'Run a logical query against the Flipstream query planner.';
|
|
29
64
|
async run() {
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
125
|
+
const engine = await this.engineFailureDetails(error, admin, body);
|
|
126
|
+
throw await this.enrichPlannerError(mapPlannerError(error), body, { authHost, client, engine, store });
|
|
52
127
|
}
|
|
53
|
-
|
|
54
|
-
//
|
|
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
|
-
//
|
|
59
|
-
|
|
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
|
-
|
|
235
|
+
let label;
|
|
236
|
+
if (this.flags.body !== undefined) {
|
|
62
237
|
raw = this.flags.body;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
else if (
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
package/dist/lib/api/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/api/errors.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/api/http.d.ts
CHANGED
|
@@ -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>;
|