flipstream 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +168 -12
  2. package/dist/commands/auth/clear-all.d.ts +1 -0
  3. package/dist/commands/auth/clear-all.js +91 -24
  4. package/dist/commands/auth/login.js +8 -2
  5. package/dist/commands/auth/logout.js +2 -2
  6. package/dist/commands/auth/status.js +35 -8
  7. package/dist/commands/catalog.d.ts +15 -0
  8. package/dist/commands/catalog.js +110 -0
  9. package/dist/commands/connections/list.d.ts +1 -0
  10. package/dist/commands/connections/list.js +28 -2
  11. package/dist/commands/contract.d.ts +11 -0
  12. package/dist/commands/contract.js +35 -0
  13. package/dist/commands/health.d.ts +10 -0
  14. package/dist/commands/health.js +31 -0
  15. package/dist/commands/log/add.js +7 -3
  16. package/dist/commands/log/list.js +1 -1
  17. package/dist/commands/query.d.ts +15 -2
  18. package/dist/commands/query.js +256 -43
  19. package/dist/commands/skills/install.d.ts +16 -0
  20. package/dist/commands/skills/install.js +55 -0
  21. package/dist/commands/workspaces/connections.js +4 -2
  22. package/dist/commands/workspaces/get.js +5 -3
  23. package/dist/commands/workspaces/list.js +4 -1
  24. package/dist/lib/api/errors.d.ts +1 -0
  25. package/dist/lib/api/errors.js +13 -2
  26. package/dist/lib/api/http.d.ts +2 -0
  27. package/dist/lib/api/http.js +40 -4
  28. package/dist/lib/api/ids.d.ts +1 -0
  29. package/dist/lib/api/ids.js +5 -0
  30. package/dist/lib/api/retry.d.ts +1 -1
  31. package/dist/lib/api/retry.js +1 -1
  32. package/dist/lib/api/short-uuid.d.ts +1 -0
  33. package/dist/lib/api/short-uuid.js +30 -0
  34. package/dist/lib/auth/claims.d.ts +1 -1
  35. package/dist/lib/auth/claims.js +2 -2
  36. package/dist/lib/auth/flow.js +10 -2
  37. package/dist/lib/auth/headless.js +14 -10
  38. package/dist/lib/auth/refresh.js +33 -3
  39. package/dist/lib/auth/session.js +1 -1
  40. package/dist/lib/command/base.d.ts +4 -0
  41. package/dist/lib/command/base.js +102 -3
  42. package/dist/lib/command/flags.d.ts +4 -0
  43. package/dist/lib/command/flags.js +11 -0
  44. package/dist/lib/command/planner.d.ts +9 -0
  45. package/dist/lib/command/planner.js +14 -0
  46. package/dist/lib/config/constants.d.ts +3 -1
  47. package/dist/lib/config/constants.js +14 -1
  48. package/dist/lib/config/xdg.d.ts +4 -0
  49. package/dist/lib/config/xdg.js +56 -1
  50. package/dist/lib/errors.d.ts +20 -1
  51. package/dist/lib/errors.js +125 -17
  52. package/dist/lib/output/dialogs.d.ts +27 -0
  53. package/dist/lib/output/dialogs.js +94 -0
  54. package/dist/lib/output/interactivity.d.ts +11 -0
  55. package/dist/lib/output/interactivity.js +54 -0
  56. package/dist/lib/output/machine-mode.d.ts +2 -0
  57. package/dist/lib/output/machine-mode.js +25 -0
  58. package/dist/lib/output/redact.d.ts +1 -0
  59. package/dist/lib/output/redact.js +12 -0
  60. package/dist/lib/output/runlog.d.ts +3 -0
  61. package/dist/lib/output/runlog.js +72 -0
  62. package/dist/lib/output/sanitize.d.ts +2 -0
  63. package/dist/lib/output/sanitize.js +57 -0
  64. package/dist/lib/output/sidecar.d.ts +30 -0
  65. package/dist/lib/output/sidecar.js +58 -0
  66. package/dist/lib/output/table.js +5 -1
  67. package/dist/lib/output/trace.d.ts +11 -0
  68. package/dist/lib/output/trace.js +89 -0
  69. package/dist/lib/planner/catalog.d.ts +26 -0
  70. package/dist/lib/planner/catalog.js +60 -0
  71. package/dist/lib/planner/client.d.ts +14 -0
  72. package/dist/lib/planner/client.js +47 -0
  73. package/dist/lib/planner/connection.d.ts +14 -0
  74. package/dist/lib/planner/connection.js +139 -0
  75. package/dist/lib/planner/diagnose.d.ts +8 -0
  76. package/dist/lib/planner/diagnose.js +50 -0
  77. package/dist/lib/planner/errors.d.ts +14 -0
  78. package/dist/lib/planner/errors.js +129 -0
  79. package/dist/lib/planner/filters.d.ts +8 -0
  80. package/dist/lib/planner/filters.js +74 -0
  81. package/dist/lib/planner/request.d.ts +24 -0
  82. package/dist/lib/planner/request.js +51 -0
  83. package/dist/lib/planner/suggest.d.ts +2 -0
  84. package/dist/lib/planner/suggest.js +45 -0
  85. package/dist/lib/planner/vocabulary.d.ts +9 -0
  86. package/dist/lib/planner/vocabulary.js +95 -0
  87. package/dist/lib/skills/install.d.ts +24 -0
  88. package/dist/lib/skills/install.js +69 -0
  89. package/dist/lib/store/keychain-child.d.ts +11 -0
  90. package/dist/lib/store/keychain-child.js +135 -0
  91. package/dist/lib/store/keyring.d.ts +27 -10
  92. package/dist/lib/store/keyring.js +288 -17
  93. package/dist/lib/store/memory-store.d.ts +6 -5
  94. package/dist/lib/store/memory-store.js +14 -6
  95. package/docs/AGENT-CONTRACT.md +238 -0
  96. package/oclif.manifest.json +392 -8
  97. package/package.json +7 -3
  98. package/skill/SKILL.md +55 -0
@@ -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
- return this.respondList(projected, (records) => renderConnectionsTable(records));
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
+ }
@@ -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({ aliases: ['ws'], description: 'Workspace id (UUID) the entry belongs to.', required: true }),
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, {
@@ -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 parseBody;
23
+ private engineFailureDetails;
24
+ private enrichPlannerError;
25
+ private readVerbatimBody;
13
26
  }
@@ -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
- if (!store.load(authHost))
97
+ if (!(await 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 ((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
- 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
+ }