flipstream 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +357 -27
- package/dist/commands/auth/login.js +7 -1
- package/dist/commands/auth/status.js +29 -3
- package/dist/commands/catalog.d.ts +15 -0
- package/dist/commands/catalog.js +110 -0
- package/dist/commands/connections/list.d.ts +1 -0
- package/dist/commands/connections/list.js +31 -4
- package/dist/commands/contract.d.ts +11 -0
- package/dist/commands/contract.js +35 -0
- package/dist/commands/health.d.ts +10 -0
- package/dist/commands/health.js +31 -0
- package/dist/commands/log/add.d.ts +16 -0
- package/dist/commands/log/add.js +48 -0
- package/dist/commands/log/list.d.ts +19 -0
- package/dist/commands/log/list.js +43 -0
- package/dist/commands/query.d.ts +15 -2
- package/dist/commands/query.js +255 -42
- package/dist/commands/skills/install.d.ts +16 -0
- package/dist/commands/skills/install.js +55 -0
- package/dist/commands/workspaces/connections.js +6 -3
- package/dist/commands/workspaces/get.js +4 -2
- package/dist/commands/workspaces/list.js +3 -0
- package/dist/lib/api/admin-client.d.ts +5 -0
- package/dist/lib/api/admin-client.js +19 -0
- package/dist/lib/api/connections.d.ts +0 -1
- package/dist/lib/api/connections.js +0 -25
- package/dist/lib/api/errors.d.ts +1 -0
- package/dist/lib/api/errors.js +13 -2
- package/dist/lib/api/http.d.ts +2 -0
- package/dist/lib/api/http.js +40 -4
- package/dist/lib/api/hydrate.d.ts +10 -0
- package/dist/lib/api/hydrate.js +46 -0
- package/dist/lib/api/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +5 -0
- package/dist/lib/api/log.d.ts +22 -0
- package/dist/lib/api/log.js +56 -0
- package/dist/lib/api/projections.d.ts +1 -0
- package/dist/lib/api/projections.js +23 -0
- package/dist/lib/api/short-uuid.d.ts +1 -0
- package/dist/lib/api/short-uuid.js +30 -0
- package/dist/lib/auth/claims.js +3 -3
- package/dist/lib/auth/flow.js +8 -1
- package/dist/lib/auth/headless.js +14 -10
- package/dist/lib/auth/refresh.js +21 -1
- package/dist/lib/command/admin.d.ts +1 -0
- package/dist/lib/command/admin.js +21 -0
- package/dist/lib/command/base.d.ts +4 -0
- package/dist/lib/command/base.js +97 -3
- package/dist/lib/command/flags.d.ts +4 -0
- package/dist/lib/command/flags.js +11 -0
- package/dist/lib/command/planner.d.ts +9 -0
- package/dist/lib/command/planner.js +14 -0
- package/dist/lib/config/constants.d.ts +3 -1
- package/dist/lib/config/constants.js +14 -1
- package/dist/lib/config/xdg.d.ts +4 -0
- package/dist/lib/config/xdg.js +56 -1
- package/dist/lib/errors.d.ts +20 -1
- package/dist/lib/errors.js +132 -13
- package/dist/lib/output/dialogs.d.ts +27 -0
- package/dist/lib/output/dialogs.js +94 -0
- package/dist/lib/output/interactivity.d.ts +11 -0
- package/dist/lib/output/interactivity.js +48 -0
- package/dist/lib/output/redact.d.ts +1 -0
- package/dist/lib/output/redact.js +12 -0
- package/dist/lib/output/runlog.d.ts +3 -0
- package/dist/lib/output/runlog.js +72 -0
- package/dist/lib/output/sanitize.d.ts +2 -0
- package/dist/lib/output/sanitize.js +57 -0
- package/dist/lib/output/sidecar.d.ts +30 -0
- package/dist/lib/output/sidecar.js +58 -0
- package/dist/lib/output/table.js +5 -1
- package/dist/lib/output/trace.d.ts +11 -0
- package/dist/lib/output/trace.js +89 -0
- package/dist/lib/planner/catalog.d.ts +26 -0
- package/dist/lib/planner/catalog.js +60 -0
- package/dist/lib/planner/client.d.ts +14 -0
- package/dist/lib/planner/client.js +47 -0
- package/dist/lib/planner/connection.d.ts +14 -0
- package/dist/lib/planner/connection.js +139 -0
- package/dist/lib/planner/diagnose.d.ts +8 -0
- package/dist/lib/planner/diagnose.js +50 -0
- package/dist/lib/planner/errors.d.ts +14 -0
- package/dist/lib/planner/errors.js +129 -0
- package/dist/lib/planner/filters.d.ts +8 -0
- package/dist/lib/planner/filters.js +74 -0
- package/dist/lib/planner/request.d.ts +24 -0
- package/dist/lib/planner/request.js +51 -0
- package/dist/lib/planner/suggest.d.ts +2 -0
- package/dist/lib/planner/suggest.js +45 -0
- package/dist/lib/planner/vocabulary.d.ts +9 -0
- package/dist/lib/planner/vocabulary.js +95 -0
- package/dist/lib/skills/install.d.ts +24 -0
- package/dist/lib/skills/install.js +69 -0
- package/dist/lib/store/keyring.d.ts +3 -0
- package/dist/lib/store/keyring.js +45 -2
- package/dist/lib/store/memory-store.d.ts +1 -0
- package/dist/lib/store/memory-store.js +5 -0
- package/docs/AGENT-CONTRACT.md +238 -0
- package/oclif.manifest.json +606 -8
- package/package.json +22 -3
- package/skill/SKILL.md +55 -0
- package/dist/lib/auth/register.d.ts +0 -4
- package/dist/lib/auth/register.js +0 -43
|
@@ -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,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
|
}
|