flipstream 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +357 -27
  2. package/dist/commands/auth/login.js +7 -1
  3. package/dist/commands/auth/status.js +29 -3
  4. package/dist/commands/catalog.d.ts +15 -0
  5. package/dist/commands/catalog.js +110 -0
  6. package/dist/commands/connections/list.d.ts +1 -0
  7. package/dist/commands/connections/list.js +31 -4
  8. package/dist/commands/contract.d.ts +11 -0
  9. package/dist/commands/contract.js +35 -0
  10. package/dist/commands/health.d.ts +10 -0
  11. package/dist/commands/health.js +31 -0
  12. package/dist/commands/log/add.d.ts +16 -0
  13. package/dist/commands/log/add.js +48 -0
  14. package/dist/commands/log/list.d.ts +19 -0
  15. package/dist/commands/log/list.js +43 -0
  16. package/dist/commands/query.d.ts +15 -2
  17. package/dist/commands/query.js +255 -42
  18. package/dist/commands/skills/install.d.ts +16 -0
  19. package/dist/commands/skills/install.js +55 -0
  20. package/dist/commands/workspaces/connections.js +6 -3
  21. package/dist/commands/workspaces/get.js +4 -2
  22. package/dist/commands/workspaces/list.js +3 -0
  23. package/dist/lib/api/admin-client.d.ts +5 -0
  24. package/dist/lib/api/admin-client.js +19 -0
  25. package/dist/lib/api/connections.d.ts +0 -1
  26. package/dist/lib/api/connections.js +0 -25
  27. package/dist/lib/api/errors.d.ts +1 -0
  28. package/dist/lib/api/errors.js +13 -2
  29. package/dist/lib/api/http.d.ts +2 -0
  30. package/dist/lib/api/http.js +40 -4
  31. package/dist/lib/api/hydrate.d.ts +10 -0
  32. package/dist/lib/api/hydrate.js +46 -0
  33. package/dist/lib/api/ids.d.ts +1 -0
  34. package/dist/lib/api/ids.js +5 -0
  35. package/dist/lib/api/log.d.ts +22 -0
  36. package/dist/lib/api/log.js +56 -0
  37. package/dist/lib/api/projections.d.ts +1 -0
  38. package/dist/lib/api/projections.js +23 -0
  39. package/dist/lib/api/short-uuid.d.ts +1 -0
  40. package/dist/lib/api/short-uuid.js +30 -0
  41. package/dist/lib/auth/claims.js +3 -3
  42. package/dist/lib/auth/flow.js +8 -1
  43. package/dist/lib/auth/headless.js +14 -10
  44. package/dist/lib/auth/refresh.js +21 -1
  45. package/dist/lib/command/admin.d.ts +1 -0
  46. package/dist/lib/command/admin.js +21 -0
  47. package/dist/lib/command/base.d.ts +4 -0
  48. package/dist/lib/command/base.js +97 -3
  49. package/dist/lib/command/flags.d.ts +4 -0
  50. package/dist/lib/command/flags.js +11 -0
  51. package/dist/lib/command/planner.d.ts +9 -0
  52. package/dist/lib/command/planner.js +14 -0
  53. package/dist/lib/config/constants.d.ts +3 -1
  54. package/dist/lib/config/constants.js +14 -1
  55. package/dist/lib/config/xdg.d.ts +4 -0
  56. package/dist/lib/config/xdg.js +56 -1
  57. package/dist/lib/errors.d.ts +20 -1
  58. package/dist/lib/errors.js +132 -13
  59. package/dist/lib/output/dialogs.d.ts +27 -0
  60. package/dist/lib/output/dialogs.js +94 -0
  61. package/dist/lib/output/interactivity.d.ts +11 -0
  62. package/dist/lib/output/interactivity.js +48 -0
  63. package/dist/lib/output/redact.d.ts +1 -0
  64. package/dist/lib/output/redact.js +12 -0
  65. package/dist/lib/output/runlog.d.ts +3 -0
  66. package/dist/lib/output/runlog.js +72 -0
  67. package/dist/lib/output/sanitize.d.ts +2 -0
  68. package/dist/lib/output/sanitize.js +57 -0
  69. package/dist/lib/output/sidecar.d.ts +30 -0
  70. package/dist/lib/output/sidecar.js +58 -0
  71. package/dist/lib/output/table.js +5 -1
  72. package/dist/lib/output/trace.d.ts +11 -0
  73. package/dist/lib/output/trace.js +89 -0
  74. package/dist/lib/planner/catalog.d.ts +26 -0
  75. package/dist/lib/planner/catalog.js +60 -0
  76. package/dist/lib/planner/client.d.ts +14 -0
  77. package/dist/lib/planner/client.js +47 -0
  78. package/dist/lib/planner/connection.d.ts +14 -0
  79. package/dist/lib/planner/connection.js +139 -0
  80. package/dist/lib/planner/diagnose.d.ts +8 -0
  81. package/dist/lib/planner/diagnose.js +50 -0
  82. package/dist/lib/planner/errors.d.ts +14 -0
  83. package/dist/lib/planner/errors.js +129 -0
  84. package/dist/lib/planner/filters.d.ts +8 -0
  85. package/dist/lib/planner/filters.js +74 -0
  86. package/dist/lib/planner/request.d.ts +24 -0
  87. package/dist/lib/planner/request.js +51 -0
  88. package/dist/lib/planner/suggest.d.ts +2 -0
  89. package/dist/lib/planner/suggest.js +45 -0
  90. package/dist/lib/planner/vocabulary.d.ts +9 -0
  91. package/dist/lib/planner/vocabulary.js +95 -0
  92. package/dist/lib/skills/install.d.ts +24 -0
  93. package/dist/lib/skills/install.js +69 -0
  94. package/dist/lib/store/keyring.d.ts +3 -0
  95. package/dist/lib/store/keyring.js +45 -2
  96. package/dist/lib/store/memory-store.d.ts +1 -0
  97. package/dist/lib/store/memory-store.js +5 -0
  98. package/docs/AGENT-CONTRACT.md +238 -0
  99. package/oclif.manifest.json +606 -8
  100. package/package.json +22 -3
  101. package/skill/SKILL.md +55 -0
  102. package/dist/lib/auth/register.d.ts +0 -4
  103. package/dist/lib/auth/register.js +0 -43
@@ -0,0 +1,50 @@
1
+ import { parseCatalogIndex, parseCatalogSource } from './catalog.js';
2
+ import { didYouMean } from './suggest.js';
3
+ import { describeProblems, diagnoseUnknownNames } from './vocabulary.js';
4
+ export async function attachVocabularyDiagnosis(error, options) {
5
+ const upstream = error.details.upstreamCode;
6
+ if (upstream === undefined || !upstream.startsWith('UNKNOWN_'))
7
+ return;
8
+ // Structural guard, not just the planner's vocabulary discipline: a
9
+ // retryable failure (429/5xx) must never trigger an immediate follow-up
10
+ // fetch against a service that just asked us to back off.
11
+ if (error.details.retryable === true)
12
+ return;
13
+ const source = options.body?.source;
14
+ const sourceName = typeof source === 'string' && /^[\w.-]{1,64}$/.test(source) ? source : undefined;
15
+ try {
16
+ if (upstream === 'UNKNOWN_SOURCE') {
17
+ if (sourceName === undefined)
18
+ return;
19
+ const index = parseCatalogIndex(await options.fetchCatalog());
20
+ const suggestion = didYouMean(sourceName, index.sources.map((entry) => entry.name).filter((name) => name.length > 0));
21
+ // The next command must be RUNNABLE: never the name the planner just
22
+ // rejected — the bare index when there is no better idea, the suggested
23
+ // source when there is.
24
+ error.withDetails({
25
+ hint: suggestion === undefined
26
+ ? `Unknown source "${sourceName}" — list the real ones first.`
27
+ : `Unknown source "${sourceName}" — did you mean "${suggestion}"?`,
28
+ next: [`${options.bin} catalog${suggestion === undefined ? '' : ` ${suggestion}`}`],
29
+ retryable: false,
30
+ });
31
+ return;
32
+ }
33
+ if (sourceName === undefined)
34
+ return;
35
+ const catalog = parseCatalogSource(await options.fetchCatalog(sourceName));
36
+ // An empty vocabulary means we did NOT get a catalog: a 200 carrying an
37
+ // HTML interstitial or a drifted shape parses to empty arrays, and diffing
38
+ // against that would accuse every CORRECT name in the request.
39
+ if (catalog.dimensions.length === 0 && catalog.metrics.length === 0) {
40
+ options.onSkip?.('catalog response carried no vocabulary');
41
+ return;
42
+ }
43
+ const problems = diagnoseUnknownNames(options.body, catalog);
44
+ if (problems.length > 0)
45
+ error.withDetails({ hint: describeProblems(problems) });
46
+ }
47
+ catch (error_) {
48
+ options.onSkip?.(error_ instanceof Error ? error_.message : String(error_));
49
+ }
50
+ }
@@ -0,0 +1,14 @@
1
+ import { CliError } from '../errors.js';
2
+ export interface PlannerFailure {
3
+ code: string;
4
+ message: string;
5
+ }
6
+ export declare function parsePlannerFailure(bodyText: string): PlannerFailure | undefined;
7
+ export declare function plannerCode(bodyText: string): string;
8
+ export interface CatalogContext {
9
+ bin: string;
10
+ source?: string;
11
+ url: string;
12
+ }
13
+ export declare function mapCatalogError(error: unknown, context: CatalogContext): CliError;
14
+ export declare function mapPlannerError(error: unknown): CliError;
@@ -0,0 +1,129 @@
1
+ import { mapDataError, rateLimited } from '../api/errors.js';
2
+ import { AuthFailedError, CliError, DataHttpError, NetworkError, retryPolicy, UsageError } from '../errors.js';
3
+ import { ExitCode } from '../exit-codes.js';
4
+ import { redact } from '../output/redact.js';
5
+ const MESSAGE_MAX = 500;
6
+ // The planner's codes are SCREAMING_SNAKE enum members. Anything else is not a
7
+ // planner code and gets no trust: `code` flows to stdout as `upstream_code`,
8
+ // so this shape check is what keeps that field bounded and unable to smuggle
9
+ // tokens/ANSI — the one envelope field that would otherwise skip redaction.
10
+ const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/;
11
+ // The planner's own error envelope is {code, message} — NOT the {error,
12
+ // error_description} of OAuth nor the {detail} of FastAPI's own validation, so
13
+ // api/errors.ts cannot read it. `detail` (which names tables and columns) is
14
+ // logged server-side and never returned, so there is nothing else to look for.
15
+ export function parsePlannerFailure(bodyText) {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(bodyText);
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
24
+ return undefined;
25
+ const record = parsed;
26
+ if (typeof record.code !== 'string' || !CODE_SHAPE.test(record.code))
27
+ return undefined;
28
+ return {
29
+ code: record.code,
30
+ message: typeof record.message === 'string' ? record.message.slice(0, MESSAGE_MAX) : '',
31
+ };
32
+ }
33
+ // The service's own error code for a response body, or '' when this did not come
34
+ // from the planner. Telling those apart is the difference between "typo" and
35
+ // "wrong build" — see the two 404s the catalog command distinguishes.
36
+ export function plannerCode(bodyText) {
37
+ return parsePlannerFailure(bodyText)?.code ?? '';
38
+ }
39
+ // A catalog failure, with the ONE distinction the generic mapper cannot make.
40
+ //
41
+ // Two different 404s reach this endpoint and they send you to different places:
42
+ // the planner's own UNKNOWN_SOURCE names a source that is not in the catalog (a
43
+ // typo), while FastAPI's bare "Not Found" means the route does not exist at all
44
+ // (an older deployment). Collapsing them into "not found" is how you spend an
45
+ // afternoon checking spelling against a build that never had the endpoint.
46
+ export function mapCatalogError(error, context) {
47
+ if (!(error instanceof DataHttpError) || error.status !== 404)
48
+ return mapPlannerError(error);
49
+ if (plannerCode(error.bodyText) === 'UNKNOWN_SOURCE') {
50
+ return new CliError(`No catalog entry for '${context.source ?? ''}' — run \`${context.bin} catalog\` to list them.`, 'not_found', ExitCode.GENERIC).withDetails({
51
+ hint: 'The source name is a catalog key, not a table name.',
52
+ next: [`${context.bin} catalog`],
53
+ retryable: false,
54
+ upstreamCode: 'UNKNOWN_SOURCE',
55
+ });
56
+ }
57
+ const path = context.source === undefined ? '/catalog' : `/catalog/${context.source}`;
58
+ return new CliError(`${context.url} serves no ${path} — is it running a build that has one?`, 'not_found', ExitCode.GENERIC);
59
+ }
60
+ // Map a planner failure onto the E3 error model. Falls through to mapDataError for
61
+ // anything that is not a recognisable planner envelope, so transport failures,
62
+ // timeouts and already-typed CliErrors keep their existing behaviour.
63
+ //
64
+ // Every planner `message` is built from PUBLIC vocabulary only — the logical names
65
+ // the caller already sent us — which is why it is safe to surface verbatim
66
+ // (redacted defensively all the same).
67
+ export function mapPlannerError(error) {
68
+ if (!(error instanceof DataHttpError))
69
+ return mapDataError(error);
70
+ const failure = parsePlannerFailure(error.bodyText);
71
+ if (!failure)
72
+ return mapDataError(error);
73
+ // Every planner-mapped error carries the planner's own code verbatim (#96):
74
+ // ENGINE_FAILED and UNKNOWN_DIMENSION are different situations for an agent
75
+ // even when our own classification of them coincides.
76
+ return classifyPlannerFailure(error, failure).withDetails({ upstreamCode: failure.code });
77
+ }
78
+ function classifyPlannerFailure(error, failure) {
79
+ const message = redact(failure.message || `HTTP ${error.status}`);
80
+ switch (failure.code) {
81
+ // Unlike the generic 403 mapper (which returns a FIXED message so an upstream
82
+ // body cannot leak), the planner's INSUFFICIENT_SCOPE message is designed to be
83
+ // returned: it names the missing scope, or the roles that would do instead.
84
+ // That is the whole point of checking scopes at the front door rather than
85
+ // letting a 403 surface two services away as an opaque PULSE_SERVICE_ERROR.
86
+ // Deterministic auth failures: explicitly NOT retryable — re-presenting the
87
+ // same token gets the same answer, and `false` is the anti-retry-loop
88
+ // signal the envelope exists to carry.
89
+ case 'INSUFFICIENT_SCOPE': {
90
+ return new AuthFailedError(message, 'role_forbidden').withDetails({ retryable: false });
91
+ }
92
+ // The planner's own "I broke, try again" — NetworkError is retryable by
93
+ // class; thread the server-stated wait through when one was sent.
94
+ case 'INTERNAL_ERROR': {
95
+ return new NetworkError(message, 'data_upstream_error').withDetails(retryPolicy(error.retryAfterMs));
96
+ }
97
+ case 'INVALID_AUDIENCE': {
98
+ return new AuthFailedError(`invalid_target: re-bind required — ${message}`, 'invalid_target').withDetails({
99
+ retryable: false,
100
+ });
101
+ }
102
+ // TOKEN_EXPIRED reaching here means the 401->refresh->retry wrapper already
103
+ // refreshed successfully and the planner rejected the NEW token too — which is
104
+ // an auth failure, not an expiry. A refresh that cannot renew raises
105
+ // session_expired (exit 4) from the wrapper and never gets this far.
106
+ case 'INVALID_TOKEN':
107
+ case 'TOKEN_EXPIRED': {
108
+ return new AuthFailedError(message, 'data_auth_failed').withDetails({ retryable: false });
109
+ }
110
+ default: {
111
+ break;
112
+ }
113
+ }
114
+ // ENGINE_FAILED carries the engine's status: 502 for transport/5xx, 400 when the
115
+ // engine itself refused. Classify on the status rather than the code so a
116
+ // retryable upstream failure is exit 7 and a bad request stays exit 1.
117
+ if (error.status >= 500) {
118
+ return new NetworkError(message, 'data_upstream_error').withDetails(retryPolicy(error.retryAfterMs));
119
+ }
120
+ if (error.status === 429)
121
+ return rateLimited(message, error.retryAfterMs);
122
+ if (error.status === 404)
123
+ return new CliError(message, 'not_found', ExitCode.GENERIC);
124
+ // A 422 is FastAPI refusing the body shape (`extra="forbid"`), which means WE
125
+ // emitted something the contract does not allow — a usage error on our side.
126
+ if (error.status === 422)
127
+ return new UsageError(message);
128
+ return new CliError(message, 'data_request_failed', ExitCode.GENERIC);
129
+ }
@@ -0,0 +1,8 @@
1
+ export type FilterEntry = Record<string, unknown>;
2
+ export interface SortSpec {
3
+ by: string;
4
+ dir: 'asc' | 'desc';
5
+ }
6
+ export declare function parseFilter(raw: string): FilterEntry;
7
+ export declare function describeSelections(entries: FilterEntry[]): string[];
8
+ export declare function parseSort(spec: string): SortSpec;
@@ -0,0 +1,74 @@
1
+ import { UsageError } from '../errors.js';
2
+ export function parseFilter(raw) {
3
+ const at = raw.indexOf('=');
4
+ const column = at === -1 ? '' : raw.slice(0, at).trim();
5
+ if (at === -1 || column.length === 0) {
6
+ throw new UsageError(`--filter must be written col=value, got '${raw}'`);
7
+ }
8
+ const value = raw.slice(at + 1);
9
+ // Verbatim JSON — the escape hatch for everything the shorthand will not say.
10
+ if (value.startsWith('[') || value.startsWith('{')) {
11
+ try {
12
+ return { [column]: JSON.parse(value) };
13
+ }
14
+ catch {
15
+ throw new UsageError(`--filter '${raw}' is not valid JSON.`);
16
+ }
17
+ }
18
+ // Refused rather than guessed: the two spellings route to different tables.
19
+ if (value.length === 0) {
20
+ throw new UsageError(`--filter '${raw}' has no value; write ${column}=[] for no selection or ${column}=[""] for a ` +
21
+ 'selection of the empty string — they route differently.');
22
+ }
23
+ if (value.includes('..')) {
24
+ const cut = value.indexOf('..');
25
+ const from = value.slice(0, cut);
26
+ const to = value.slice(cut + 2);
27
+ // Either bound may be absent; the pair keeps its position either way.
28
+ return { [column]: { from: from || null, to: to || null } };
29
+ }
30
+ return { [column]: value.split(',') };
31
+ }
32
+ // Human narration of the selection-length reading, one line per LIST
33
+ // selection (ranges are not selections and say nothing). Lives HERE because
34
+ // this module owns the length predicate ([] inactive / non-empty active) —
35
+ // a command restating it is how the two would drift (E11-2 review).
36
+ //
37
+ // The copy is deliberately conditional ("if <col> is routing-relevant"):
38
+ // only translate.py knows which columns actually route, and this narration
39
+ // echoes the encoding, it never decides. Values are echoed (capped) because
40
+ // `[""]` and a real value both read "1 value" otherwise — and `[""]` is the
41
+ // exact trap the module refuses shorthand for.
42
+ const PREVIEW_VALUES = 3;
43
+ const PREVIEW_CHARS = 32;
44
+ export function describeSelections(entries) {
45
+ const lines = [];
46
+ for (const entry of entries) {
47
+ for (const [column, selection] of Object.entries(entry)) {
48
+ if (!Array.isArray(selection))
49
+ continue;
50
+ if (selection.length === 0) {
51
+ lines.push(`filter ${column}: [] — read as an INACTIVE selection; if ${column} is routing-relevant, ` +
52
+ 'the planner answers from the summary table');
53
+ continue;
54
+ }
55
+ const preview = selection
56
+ .slice(0, PREVIEW_VALUES)
57
+ .map((value) => JSON.stringify(value).slice(0, PREVIEW_CHARS))
58
+ .join(', ');
59
+ const more = selection.length > PREVIEW_VALUES ? ` +${selection.length - PREVIEW_VALUES} more` : '';
60
+ lines.push(`filter ${column}: ${selection.length} value${selection.length === 1 ? '' : 's'} (${preview}${more}) — ` +
61
+ `read as an ACTIVE selection; if ${column} is routing-relevant, the planner answers from its dimension table`);
62
+ }
63
+ }
64
+ return lines;
65
+ }
66
+ export function parseSort(spec) {
67
+ const at = spec.indexOf(':');
68
+ const by = at === -1 ? spec : spec.slice(0, at);
69
+ const dir = at === -1 ? 'asc' : spec.slice(at + 1);
70
+ if (dir !== 'asc' && dir !== 'desc') {
71
+ throw new UsageError(`--sort direction must be asc or desc, got '${dir}'`);
72
+ }
73
+ return { by, dir };
74
+ }
@@ -0,0 +1,24 @@
1
+ import { type FilterEntry, type SortSpec } from './filters.js';
2
+ export interface LogicalRequest {
3
+ connection_id: string;
4
+ dimensions: string[];
5
+ filters: FilterEntry[];
6
+ metrics: string[];
7
+ offset: number;
8
+ rows: number;
9
+ sort?: SortSpec[];
10
+ source: string;
11
+ table?: string;
12
+ }
13
+ export interface RequestFlags {
14
+ connectionId?: string;
15
+ dimensions?: string[];
16
+ filters?: string[];
17
+ metrics?: string[];
18
+ offset?: number;
19
+ rows?: number;
20
+ sort?: string[];
21
+ source?: string;
22
+ table?: string;
23
+ }
24
+ export declare function buildLogicalRequest(flags: RequestFlags, bin?: string): LogicalRequest;
@@ -0,0 +1,51 @@
1
+ import { UsageError } from '../errors.js';
2
+ import { parseFilter, parseSort } from './filters.js';
3
+ export function buildLogicalRequest(flags, bin = 'flipstream') {
4
+ // Name EXACTLY what is missing (never "A and B are required" when A was given),
5
+ // and point at the command that produces the missing value (E11-1, #100).
6
+ const { connectionId, source } = flags;
7
+ if (!source && !connectionId) {
8
+ throw new UsageError('Missing --source and --connection-id (or pass a full JSON body via --body / --body-file).', 'missing_source_and_connection_id').withDetails({
9
+ docs: 'docs/AGENT-CONTRACT.md#the-query-request-shape-logicalrequest',
10
+ hint: 'Pick a source from the catalog and a connection id from your connections.',
11
+ next: [`${bin} catalog`, `${bin} connections list --json`],
12
+ retryable: false,
13
+ });
14
+ }
15
+ if (!source) {
16
+ throw new UsageError('Missing --source.', 'missing_source').withDetails({
17
+ docs: 'docs/AGENT-CONTRACT.md#the-query-request-shape-logicalrequest',
18
+ hint: 'List the queryable sources, then pass one as --source.',
19
+ next: [`${bin} catalog`],
20
+ retryable: false,
21
+ });
22
+ }
23
+ if (!connectionId) {
24
+ throw new UsageError('Missing --connection-id.', 'missing_connection_id').withDetails({
25
+ docs: 'docs/AGENT-CONTRACT.md#the-query-request-shape-logicalrequest',
26
+ hint: 'List your connections and pass one of their ids (or its name).',
27
+ next: [`${bin} connections list --json`],
28
+ retryable: false,
29
+ });
30
+ }
31
+ const body = {
32
+ connection_id: connectionId,
33
+ dimensions: flags.dimensions ?? [],
34
+ // One entry per --filter: the list carries ORDER and REPEATS on purpose, so
35
+ // two filters on the same column stay two entries and the planner decides
36
+ // whether they merge or conflict.
37
+ filters: (flags.filters ?? []).map((raw) => parseFilter(raw)),
38
+ metrics: flags.metrics ?? [],
39
+ offset: flags.offset ?? 0,
40
+ rows: flags.rows ?? 100,
41
+ source,
42
+ };
43
+ if (flags.sort && flags.sort.length > 0)
44
+ body.sort = flags.sort.map((spec) => parseSort(spec));
45
+ // The passthrough gate, not a routing override — absent unless explicitly given.
46
+ // For a MODELLED source the planner ignores it rather than rejecting it, which
47
+ // is what lets a source become modelled without any caller changing.
48
+ if (flags.table !== undefined)
49
+ body.table = flags.table;
50
+ return body;
51
+ }
@@ -0,0 +1,2 @@
1
+ export declare function editDistance(a: string, b: string): number;
2
+ export declare function didYouMean(input: string, candidates: readonly string[], maxDistance?: number): string | undefined;
@@ -0,0 +1,45 @@
1
+ // "Did you mean" for catalog names (E11-3, #102). Wrangler ships this for
2
+ // command names but never wired it to values; we wire it to the vocabulary the
3
+ // planner actually rejects: sources, dimensions, metrics, filter keys.
4
+ //
5
+ // The scaling heuristic is ported from wrangler's did-you-mean verbatim in
6
+ // spirit: a candidate only qualifies when the edit distance is within BOTH the
7
+ // caller's ceiling AND two-thirds of the shorter name's length — which is what
8
+ // stops a 2-letter metric from "matching" an unrelated 3-letter typo.
9
+ // Classic Levenshtein, O(a.length * b.length); names are short.
10
+ export function editDistance(a, b) {
11
+ if (a === b)
12
+ return 0;
13
+ if (a.length === 0)
14
+ return b.length;
15
+ if (b.length === 0)
16
+ return a.length;
17
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
18
+ for (let i = 1; i <= a.length; i++) {
19
+ const current = [i];
20
+ for (let j = 1; j <= b.length; j++) {
21
+ const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1);
22
+ current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution));
23
+ }
24
+ previous = current;
25
+ }
26
+ return previous[b.length] ?? 0;
27
+ }
28
+ // The closest candidate within the scaled threshold, or undefined when nothing
29
+ // is close enough — no suggestion beats a wrong suggestion.
30
+ export function didYouMean(input, candidates, maxDistance = 3) {
31
+ const needle = input.toLowerCase();
32
+ let best;
33
+ let bestDistance = Number.POSITIVE_INFINITY;
34
+ for (const candidate of candidates) {
35
+ const distance = editDistance(needle, candidate.toLowerCase());
36
+ // Scale the ceiling down for short names so e.g. "xy" never matches an
37
+ // unrelated two-letter metric two edits away.
38
+ const ceiling = Math.min(maxDistance, Math.floor((Math.min(needle.length, candidate.length) * 2) / 3));
39
+ if (distance <= ceiling && distance < bestDistance) {
40
+ best = candidate;
41
+ bestDistance = distance;
42
+ }
43
+ }
44
+ return best;
45
+ }
@@ -0,0 +1,9 @@
1
+ import { type CatalogSource } from './catalog.js';
2
+ export interface NameProblem {
3
+ kind: 'dimension' | 'filter key' | 'metric' | 'sort field';
4
+ name: string;
5
+ suggestion?: string;
6
+ wrongKind?: 'dimension' | 'metric';
7
+ }
8
+ export declare function diagnoseUnknownNames(body: unknown, catalog: CatalogSource): NameProblem[];
9
+ export declare function describeProblems(problems: NameProblem[]): string;
@@ -0,0 +1,95 @@
1
+ import { didYouMean } from './suggest.js';
2
+ function strings(value) {
3
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
4
+ }
5
+ function filterKeys(value) {
6
+ if (!Array.isArray(value))
7
+ return [];
8
+ const keys = [];
9
+ for (const entry of value) {
10
+ if (typeof entry === 'object' && entry !== null && !Array.isArray(entry)) {
11
+ keys.push(...Object.keys(entry));
12
+ }
13
+ }
14
+ return keys;
15
+ }
16
+ function sortFields(value) {
17
+ if (!Array.isArray(value))
18
+ return [];
19
+ return value
20
+ .map((entry) => (typeof entry === 'object' && entry !== null ? entry.by : undefined))
21
+ .filter((by) => typeof by === 'string');
22
+ }
23
+ export function diagnoseUnknownNames(body, catalog) {
24
+ const names = (body ?? {});
25
+ // Empty-string names are catalog parsing artifacts, never suggestions.
26
+ const dimensionNames = new Set(catalog.dimensions.map((item) => item.name).filter((name) => name.length > 0));
27
+ const metricNames = new Set(catalog.metrics.map((item) => item.name).filter((name) => name.length > 0));
28
+ const problems = [];
29
+ const seen = new Set();
30
+ const push = (problem) => {
31
+ // Dedupe per (kind, name): the same typo twice is one problem — but the
32
+ // same name wrong as a dimension AND as a filter key stays two.
33
+ const key = `${problem.kind} ${problem.name}`;
34
+ if (seen.has(key))
35
+ return;
36
+ seen.add(key);
37
+ problems.push(problem);
38
+ };
39
+ for (const name of strings(names.dimensions)) {
40
+ if (dimensionNames.has(name))
41
+ continue;
42
+ if (metricNames.has(name))
43
+ push({ kind: 'dimension', name, wrongKind: 'metric' });
44
+ else
45
+ push({ kind: 'dimension', name, suggestion: didYouMean(name, [...dimensionNames]) });
46
+ }
47
+ for (const name of strings(names.metrics)) {
48
+ if (metricNames.has(name))
49
+ continue;
50
+ if (dimensionNames.has(name))
51
+ push({ kind: 'metric', name, wrongKind: 'dimension' });
52
+ else
53
+ push({ kind: 'metric', name, suggestion: didYouMean(name, [...metricNames]) });
54
+ }
55
+ // Filter keys are dimension names in the logical contract (open question for
56
+ // the planner whether metric filters are ever legal — the wrongKind copy
57
+ // below stays honest either way).
58
+ for (const name of filterKeys(names.filters)) {
59
+ if (dimensionNames.has(name))
60
+ continue;
61
+ if (metricNames.has(name))
62
+ push({ kind: 'filter key', name, wrongKind: 'metric' });
63
+ else
64
+ push({ kind: 'filter key', name, suggestion: didYouMean(name, [...dimensionNames]) });
65
+ }
66
+ // Sort fields may reference either kind.
67
+ for (const name of sortFields(names.sort)) {
68
+ if (dimensionNames.has(name) || metricNames.has(name))
69
+ continue;
70
+ push({ kind: 'sort field', name, suggestion: didYouMean(name, [...dimensionNames, ...metricNames]) });
71
+ }
72
+ return problems;
73
+ }
74
+ const HINT_MAX_PROBLEMS = 5;
75
+ // ONE imperative hint (the contract's definition of `hint`): what to change,
76
+ // capped, with the catalog rule kept — the diagnosis must never displace the
77
+ // rule the caller most needs at the moment they broke it.
78
+ export function describeProblems(problems) {
79
+ const clauses = problems.slice(0, HINT_MAX_PROBLEMS).map((problem) => {
80
+ if (problem.wrongKind !== undefined) {
81
+ const flag = problem.wrongKind === 'metric' ? '-m' : '-d';
82
+ if (problem.kind === 'filter key') {
83
+ return `"${problem.name}" is a ${problem.wrongKind}, not a filterable dimension`;
84
+ }
85
+ return `"${problem.name}" is a ${problem.wrongKind} — pass it with ${flag}`;
86
+ }
87
+ if (problem.suggestion !== undefined) {
88
+ return `replace ${problem.kind} "${problem.name}" with "${problem.suggestion}"`;
89
+ }
90
+ return (`${problem.kind} "${problem.name}" is not in this source's published catalog ` +
91
+ '(per-connection custom dimensions are not listed there)');
92
+ });
93
+ const more = problems.length > HINT_MAX_PROBLEMS ? `; …and ${problems.length - HINT_MAX_PROBLEMS} more` : '';
94
+ return `${clauses.join('; ')}${more} — every name must come from the catalog (no aliases).`;
95
+ }
@@ -0,0 +1,24 @@
1
+ export interface AgentTarget {
2
+ detected: string[];
3
+ id: string;
4
+ name: string;
5
+ skillsDir: string;
6
+ }
7
+ export declare const SUPPORTED_AGENTS: AgentTarget[];
8
+ export declare function packagedSkillPath(): string;
9
+ export declare function resolveTargets(options?: {
10
+ explicit?: string[];
11
+ home?: string;
12
+ }): AgentTarget[];
13
+ export interface InstalledEntry {
14
+ agent: string;
15
+ backup?: string;
16
+ path: string;
17
+ status: 'installed' | 'unchanged' | 'updated';
18
+ }
19
+ export interface InstallResult {
20
+ installed: InstalledEntry[];
21
+ }
22
+ export declare function installSkill(targets: AgentTarget[], options?: {
23
+ home?: string;
24
+ }): InstallResult;
@@ -0,0 +1,69 @@
1
+ import { detectAgenticEnvironment } from 'am-i-vibing';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ // The agents with a known global skills layout. An entry is only ever written
7
+ // under the user's own home directory.
8
+ export const SUPPORTED_AGENTS = [
9
+ { detected: ['claude-code'], id: 'claude-code', name: 'Claude Code', skillsDir: join('.claude', 'skills') },
10
+ { detected: ['codex'], id: 'codex', name: 'Codex', skillsDir: join('.codex', 'skills') },
11
+ { detected: ['cursor', 'cursor-agent'], id: 'cursor', name: 'Cursor', skillsDir: join('.cursor', 'skills') },
12
+ { detected: ['opencode'], id: 'opencode', name: 'OpenCode', skillsDir: join('.config', 'opencode', 'skills') },
13
+ ];
14
+ // The packaged skill: <package root>/skill/SKILL.md, resolved relative to this
15
+ // module so it works from dist in the published tarball and from src in dev.
16
+ export function packagedSkillPath() {
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ return join(here, '..', '..', '..', 'skill', 'SKILL.md');
19
+ }
20
+ // Which agents to install for: the one DRIVING this process (env detection),
21
+ // plus any whose global directory already exists on this machine. Explicit ids
22
+ // override everything.
23
+ export function resolveTargets(options = {}) {
24
+ const home = options.home ?? homedir();
25
+ if (options.explicit !== undefined && options.explicit.length > 0) {
26
+ return SUPPORTED_AGENTS.filter((agent) => options.explicit?.includes(agent.id));
27
+ }
28
+ // checkProcesses: false EXPLICITLY — env detection only. The dependency's
29
+ // current default agrees, but the property is load-bearing (no exec, no
30
+ // startup cost) so we assert it rather than inherit it.
31
+ const detection = detectAgenticEnvironment({ checkProcesses: false });
32
+ const driving = detection.type === 'agent' ? detection.id : null;
33
+ return SUPPORTED_AGENTS.filter((agent) => (driving !== null && agent.detected.includes(driving)) || existsSync(join(home, dirname(agent.skillsDir))));
34
+ }
35
+ // Copy the skill into each target's global skills dir. NEVER destroys local
36
+ // edits silently: an identical existing file is left untouched (`unchanged`),
37
+ // a differing one is preserved as SKILL.md.bak before the refresh (`updated`)
38
+ // — "yes, install" is not "yes, destroy my edits" (E11-5 review).
39
+ export function installSkill(targets, options = {}) {
40
+ const home = options.home ?? homedir();
41
+ const skill = readFileSync(packagedSkillPath(), 'utf8');
42
+ const installed = [];
43
+ for (const target of targets) {
44
+ const dir = join(home, target.skillsDir, 'flipstream');
45
+ mkdirSync(dir, { recursive: true });
46
+ const path = join(dir, 'SKILL.md');
47
+ let existing = null;
48
+ try {
49
+ existing = readFileSync(path, 'utf8');
50
+ }
51
+ catch {
52
+ // No existing file — plain install.
53
+ }
54
+ if (existing === skill) {
55
+ installed.push({ agent: target.id, path, status: 'unchanged' });
56
+ continue;
57
+ }
58
+ if (existing !== null) {
59
+ const backup = `${path}.bak`;
60
+ writeFileSync(backup, existing);
61
+ writeFileSync(path, skill);
62
+ installed.push({ agent: target.id, backup, path, status: 'updated' });
63
+ continue;
64
+ }
65
+ writeFileSync(path, skill);
66
+ installed.push({ agent: target.id, path, status: 'installed' });
67
+ }
68
+ return { installed };
69
+ }
@@ -5,12 +5,15 @@ export interface TokenStore {
5
5
  available(): boolean;
6
6
  clear(host: string): void;
7
7
  load(host: string): Credentials | null;
8
+ reload(host: string): Credentials | null;
8
9
  save(host: string, creds: Credentials): void;
9
10
  }
10
11
  export declare class KeyringStore implements TokenStore {
12
+ #private;
11
13
  accessTokenIfFresh(host: string): null | string;
12
14
  available(): boolean;
13
15
  clear(host: string): void;
14
16
  load(host: string): Credentials | null;
17
+ reload(host: string): Credentials | null;
15
18
  save(host: string, creds: Credentials): void;
16
19
  }