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.
- package/README.md +168 -12
- package/dist/commands/auth/clear-all.d.ts +1 -0
- package/dist/commands/auth/clear-all.js +91 -24
- package/dist/commands/auth/login.js +8 -2
- package/dist/commands/auth/logout.js +2 -2
- package/dist/commands/auth/status.js +35 -8
- 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 +28 -2
- 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.js +7 -3
- package/dist/commands/log/list.js +1 -1
- package/dist/commands/query.d.ts +15 -2
- package/dist/commands/query.js +256 -43
- package/dist/commands/skills/install.d.ts +16 -0
- package/dist/commands/skills/install.js +55 -0
- package/dist/commands/workspaces/connections.js +4 -2
- package/dist/commands/workspaces/get.js +5 -3
- package/dist/commands/workspaces/list.js +4 -1
- 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/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +5 -0
- package/dist/lib/api/retry.d.ts +1 -1
- package/dist/lib/api/retry.js +1 -1
- package/dist/lib/api/short-uuid.d.ts +1 -0
- package/dist/lib/api/short-uuid.js +30 -0
- package/dist/lib/auth/claims.d.ts +1 -1
- package/dist/lib/auth/claims.js +2 -2
- package/dist/lib/auth/flow.js +10 -2
- package/dist/lib/auth/headless.js +14 -10
- package/dist/lib/auth/refresh.js +33 -3
- package/dist/lib/auth/session.js +1 -1
- package/dist/lib/command/base.d.ts +4 -0
- package/dist/lib/command/base.js +102 -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 +125 -17
- 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 +54 -0
- package/dist/lib/output/machine-mode.d.ts +2 -0
- package/dist/lib/output/machine-mode.js +25 -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/keychain-child.d.ts +11 -0
- package/dist/lib/store/keychain-child.js +135 -0
- package/dist/lib/store/keyring.d.ts +27 -10
- package/dist/lib/store/keyring.js +288 -17
- package/dist/lib/store/memory-store.d.ts +6 -5
- package/dist/lib/store/memory-store.js +14 -6
- package/docs/AGENT-CONTRACT.md +238 -0
- package/oclif.manifest.json +392 -8
- package/package.json +7 -3
- package/skill/SKILL.md +55 -0
|
@@ -27,9 +27,10 @@ export default class ConnectionsList extends BaseCommand {
|
|
|
27
27
|
assertUuid(workspace, 'workspace id'); // exit 2, no request
|
|
28
28
|
const host = this.resolvedHost();
|
|
29
29
|
const store = createStore();
|
|
30
|
-
if (!store.load(host))
|
|
30
|
+
if (!(await store.load(host)))
|
|
31
31
|
throw AuthRequiredError.notLoggedIn();
|
|
32
32
|
const client = authedAdminClient(host, store, this.flags.timeout);
|
|
33
|
+
const startedMs = Date.now();
|
|
33
34
|
const projected = await fetchConnections(client, {
|
|
34
35
|
all: this.flags.all,
|
|
35
36
|
limit: this.flags.limit,
|
|
@@ -39,6 +40,31 @@ export default class ConnectionsList extends BaseCommand {
|
|
|
39
40
|
workspace,
|
|
40
41
|
});
|
|
41
42
|
await hydrate(client, projected.records, [WORKSPACE_NAME]);
|
|
42
|
-
|
|
43
|
+
// Which origin answered (E11-2): planner vs admin is the first debugging question.
|
|
44
|
+
this.verboseLog(`GET ${host}/connections (${Date.now() - startedMs} ms)`);
|
|
45
|
+
return this.respondList(projected, (records) => {
|
|
46
|
+
renderConnectionsTable(records);
|
|
47
|
+
this.printUsageFooter(records);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// The exact next command with a REAL id from the results (E11-3, #102) — the
|
|
51
|
+
// whole reason an agent lists connections is to feed one into `query`.
|
|
52
|
+
// stderr — guidance, not data; only in the human path (respondList gates it).
|
|
53
|
+
printUsageFooter(records) {
|
|
54
|
+
const first = records.find((record) => {
|
|
55
|
+
const candidate = record;
|
|
56
|
+
return typeof candidate?.id === 'string' && candidate.active !== false;
|
|
57
|
+
});
|
|
58
|
+
if (first === undefined) {
|
|
59
|
+
// All-inactive is exactly when the reader is about to be confused:
|
|
60
|
+
// `query` will refuse every one of these ids.
|
|
61
|
+
if (records.length > 0) {
|
|
62
|
+
this.footer('\nNo ACTIVE connection in these results — query refuses an inactive connection.');
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// The id is a server-supplied string — footer() redacts + sanitizes it.
|
|
67
|
+
this.footer(`\nTry: ${this.config.bin} query --source <source> --connection-id ${first.id} -d <dim> -m <metric> --json\n` +
|
|
68
|
+
` (${this.config.bin} catalog lists the sources and their names)`);
|
|
43
69
|
}
|
|
44
70
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { BaseCommand } from '../lib/command/base.js';
|
|
2
|
+
export default class Contract extends BaseCommand<typeof Contract> {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {};
|
|
6
|
+
static summary: string;
|
|
7
|
+
run(): Promise<{
|
|
8
|
+
contractVersion: string;
|
|
9
|
+
markdown: string;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { BaseCommand } from '../lib/command/base.js';
|
|
5
|
+
import { CONTRACT_VERSION } from '../lib/config/constants.js';
|
|
6
|
+
import { CliError } from '../lib/errors.js';
|
|
7
|
+
import { ExitCode } from '../lib/exit-codes.js';
|
|
8
|
+
// <package root>/docs/AGENT-CONTRACT.md, resolved relative to this module so it
|
|
9
|
+
// works from dist in the published tarball and from src in dev.
|
|
10
|
+
function contractPath() {
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
return join(here, '..', '..', 'docs', 'AGENT-CONTRACT.md');
|
|
13
|
+
}
|
|
14
|
+
export default class Contract extends BaseCommand {
|
|
15
|
+
static description = 'Print docs/AGENT-CONTRACT.md — the frozen machine contract (output modes, exit codes, error envelope, ' +
|
|
16
|
+
'per-command JSON shapes) — to stdout, from the installed package, no network needed. Context self-service ' +
|
|
17
|
+
'for agents: read this before driving the CLI programmatically.';
|
|
18
|
+
static examples = ['<%= config.bin %> contract', '<%= config.bin %> contract --json'];
|
|
19
|
+
static flags = {};
|
|
20
|
+
static summary = 'Print the agent contract (machine-facing promises) to stdout.';
|
|
21
|
+
async run() {
|
|
22
|
+
let markdown;
|
|
23
|
+
try {
|
|
24
|
+
markdown = readFileSync(contractPath(), 'utf8');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Never a raw ENOENT: the class name is not a contract code, and the
|
|
28
|
+
// absolute path (with the OS username) does not belong on stdout.
|
|
29
|
+
throw new CliError('The packaged agent contract is missing from this installation.', 'contract_unavailable', ExitCode.GENERIC).withDetails({ hint: 'Reinstall the CLI: npm i -g flipstream', retryable: false });
|
|
30
|
+
}
|
|
31
|
+
// The document IS the data here, so human mode prints it verbatim on
|
|
32
|
+
// stdout; --json wraps it with the version for programmatic consumers.
|
|
33
|
+
return this.respond({ contractVersion: CONTRACT_VERSION, markdown }, () => this.log(markdown));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BaseCommand } from '../lib/command/base.js';
|
|
2
|
+
export default class Health extends BaseCommand<typeof Health> {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
};
|
|
8
|
+
static summary: string;
|
|
9
|
+
run(): Promise<unknown>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { mapDataError } from '../lib/api/errors.js';
|
|
3
|
+
import { BaseCommand } from '../lib/command/base.js';
|
|
4
|
+
import { resolvePlannerUrl } from '../lib/config/xdg.js';
|
|
5
|
+
import { getPlannerHealth } from '../lib/planner/client.js';
|
|
6
|
+
export default class Health extends BaseCommand {
|
|
7
|
+
static description = 'Check that the query planner is answering. This is the ONE endpoint that takes no token, so it separates ' +
|
|
8
|
+
'"the service is down" from "my credentials are wrong" before any auth debugging starts.';
|
|
9
|
+
static examples = [
|
|
10
|
+
'<%= config.bin %> health',
|
|
11
|
+
'<%= config.bin %> health --json',
|
|
12
|
+
'<%= config.bin %> health --url http://localhost:8080',
|
|
13
|
+
];
|
|
14
|
+
static flags = {
|
|
15
|
+
url: Flags.string({
|
|
16
|
+
description: 'Query-planner base URL (default: the prod planner; env FLIPSTREAM_PLANNER_URL).',
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
static summary = 'Check that the query planner is reachable.';
|
|
20
|
+
async run() {
|
|
21
|
+
const url = resolvePlannerUrl({ urlFlag: this.flags.url });
|
|
22
|
+
let payload;
|
|
23
|
+
try {
|
|
24
|
+
payload = await getPlannerHealth(url, { timeoutMs: this.flags.timeout });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw mapDataError(error);
|
|
28
|
+
}
|
|
29
|
+
return this.respond(payload, (data) => this.log(JSON.stringify(data, null, 2)));
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/commands/log/add.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createStore } from '../../lib/store/index.js';
|
|
|
8
8
|
export default class LogAdd extends BaseCommand {
|
|
9
9
|
static aliases = ['logs:add'];
|
|
10
10
|
static args = {
|
|
11
|
-
message: Args.string({ description: 'The log entry text.', required: true }),
|
|
11
|
+
message: Args.string({ description: 'The log entry text.', ignoreStdin: true, required: true }),
|
|
12
12
|
};
|
|
13
13
|
static description = "Add an entry to a workspace's log (the logbook). You give the workspace (--workspace/--ws) and the message; the " +
|
|
14
14
|
'organization is derived from the workspace, and the dates default to now (override with --start/--end). ' +
|
|
@@ -20,14 +20,18 @@ export default class LogAdd extends BaseCommand {
|
|
|
20
20
|
static flags = {
|
|
21
21
|
end: Flags.string({ description: 'End date (ISO 8601). Defaults to now.' }),
|
|
22
22
|
start: Flags.string({ description: 'Start date (ISO 8601). Defaults to now.' }),
|
|
23
|
-
workspace: Flags.string({
|
|
23
|
+
workspace: Flags.string({
|
|
24
|
+
aliases: ['ws'],
|
|
25
|
+
description: 'Workspace id (UUID) the entry belongs to.',
|
|
26
|
+
required: true,
|
|
27
|
+
}),
|
|
24
28
|
};
|
|
25
29
|
static summary = "Add an entry to a workspace's log.";
|
|
26
30
|
async run() {
|
|
27
31
|
assertUuid(this.flags.workspace, 'workspace id'); // exit 2, no request
|
|
28
32
|
const host = this.resolvedHost();
|
|
29
33
|
const store = createStore();
|
|
30
|
-
if (!store.load(host))
|
|
34
|
+
if (!(await store.load(host)))
|
|
31
35
|
throw AuthRequiredError.notLoggedIn();
|
|
32
36
|
const client = authedAdminClient(host, store, this.flags.timeout);
|
|
33
37
|
const organizationId = await resolveWorkspaceOrg(client, this.flags.workspace);
|
|
@@ -26,7 +26,7 @@ export default class LogList extends BaseCommand {
|
|
|
26
26
|
assertUuid(this.flags.workspace, 'workspace id'); // exit 2, no request
|
|
27
27
|
const host = this.resolvedHost();
|
|
28
28
|
const store = createStore();
|
|
29
|
-
if (!store.load(host))
|
|
29
|
+
if (!(await store.load(host)))
|
|
30
30
|
throw AuthRequiredError.notLoggedIn();
|
|
31
31
|
const client = authedAdminClient(host, store, this.flags.timeout);
|
|
32
32
|
const projected = await fetchLog(client, {
|
package/dist/commands/query.d.ts
CHANGED
|
@@ -3,11 +3,24 @@ export default class Query extends BaseCommand<typeof Query> {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
-
'auth-host': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
6
|
body: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
7
|
'body-file': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
'connection-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
dimension: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
filter: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
metric: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
offset: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
rows: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
sort: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
table: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
'auth-host': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
20
|
};
|
|
10
21
|
static summary: string;
|
|
11
22
|
run(): Promise<unknown>;
|
|
12
|
-
private
|
|
23
|
+
private engineFailureDetails;
|
|
24
|
+
private enrichPlannerError;
|
|
25
|
+
private readVerbatimBody;
|
|
13
26
|
}
|
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
|
-
|
|
35
|
-
// session (the wrapper raises session_expired on a null refresh).
|
|
36
|
-
if (!store.load(authHost))
|
|
97
|
+
if (!(await 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 ((await 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 = (await 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 = ((await 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
|
+
}
|