flipstream 0.5.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 (85) hide show
  1. package/README.md +168 -12
  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 +27 -1
  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.js +6 -2
  13. package/dist/commands/query.d.ts +15 -2
  14. package/dist/commands/query.js +255 -42
  15. package/dist/commands/skills/install.d.ts +16 -0
  16. package/dist/commands/skills/install.js +55 -0
  17. package/dist/commands/workspaces/connections.js +3 -1
  18. package/dist/commands/workspaces/get.js +4 -2
  19. package/dist/commands/workspaces/list.js +3 -0
  20. package/dist/lib/api/errors.d.ts +1 -0
  21. package/dist/lib/api/errors.js +13 -2
  22. package/dist/lib/api/http.d.ts +2 -0
  23. package/dist/lib/api/http.js +40 -4
  24. package/dist/lib/api/ids.d.ts +1 -0
  25. package/dist/lib/api/ids.js +5 -0
  26. package/dist/lib/api/short-uuid.d.ts +1 -0
  27. package/dist/lib/api/short-uuid.js +30 -0
  28. package/dist/lib/auth/flow.js +8 -1
  29. package/dist/lib/auth/headless.js +14 -10
  30. package/dist/lib/auth/refresh.js +21 -1
  31. package/dist/lib/command/base.d.ts +4 -0
  32. package/dist/lib/command/base.js +97 -3
  33. package/dist/lib/command/flags.d.ts +4 -0
  34. package/dist/lib/command/flags.js +11 -0
  35. package/dist/lib/command/planner.d.ts +9 -0
  36. package/dist/lib/command/planner.js +14 -0
  37. package/dist/lib/config/constants.d.ts +3 -1
  38. package/dist/lib/config/constants.js +14 -1
  39. package/dist/lib/config/xdg.d.ts +4 -0
  40. package/dist/lib/config/xdg.js +56 -1
  41. package/dist/lib/errors.d.ts +20 -1
  42. package/dist/lib/errors.js +125 -17
  43. package/dist/lib/output/dialogs.d.ts +27 -0
  44. package/dist/lib/output/dialogs.js +94 -0
  45. package/dist/lib/output/interactivity.d.ts +11 -0
  46. package/dist/lib/output/interactivity.js +48 -0
  47. package/dist/lib/output/redact.d.ts +1 -0
  48. package/dist/lib/output/redact.js +12 -0
  49. package/dist/lib/output/runlog.d.ts +3 -0
  50. package/dist/lib/output/runlog.js +72 -0
  51. package/dist/lib/output/sanitize.d.ts +2 -0
  52. package/dist/lib/output/sanitize.js +57 -0
  53. package/dist/lib/output/sidecar.d.ts +30 -0
  54. package/dist/lib/output/sidecar.js +58 -0
  55. package/dist/lib/output/table.js +5 -1
  56. package/dist/lib/output/trace.d.ts +11 -0
  57. package/dist/lib/output/trace.js +89 -0
  58. package/dist/lib/planner/catalog.d.ts +26 -0
  59. package/dist/lib/planner/catalog.js +60 -0
  60. package/dist/lib/planner/client.d.ts +14 -0
  61. package/dist/lib/planner/client.js +47 -0
  62. package/dist/lib/planner/connection.d.ts +14 -0
  63. package/dist/lib/planner/connection.js +139 -0
  64. package/dist/lib/planner/diagnose.d.ts +8 -0
  65. package/dist/lib/planner/diagnose.js +50 -0
  66. package/dist/lib/planner/errors.d.ts +14 -0
  67. package/dist/lib/planner/errors.js +129 -0
  68. package/dist/lib/planner/filters.d.ts +8 -0
  69. package/dist/lib/planner/filters.js +74 -0
  70. package/dist/lib/planner/request.d.ts +24 -0
  71. package/dist/lib/planner/request.js +51 -0
  72. package/dist/lib/planner/suggest.d.ts +2 -0
  73. package/dist/lib/planner/suggest.js +45 -0
  74. package/dist/lib/planner/vocabulary.d.ts +9 -0
  75. package/dist/lib/planner/vocabulary.js +95 -0
  76. package/dist/lib/skills/install.d.ts +24 -0
  77. package/dist/lib/skills/install.js +69 -0
  78. package/dist/lib/store/keyring.d.ts +3 -0
  79. package/dist/lib/store/keyring.js +45 -2
  80. package/dist/lib/store/memory-store.d.ts +1 -0
  81. package/dist/lib/store/memory-store.js +5 -0
  82. package/docs/AGENT-CONTRACT.md +238 -0
  83. package/oclif.manifest.json +392 -8
  84. package/package.json +7 -3
  85. package/skill/SKILL.md +55 -0
@@ -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
  }
@@ -7,7 +7,37 @@ import { freshAccessToken, keyOf, parseCredentials } from './credentials.js';
7
7
  export const KEYRING_SERVICE = 'io.flipstream.cli';
8
8
  // TokenStore backed by the OS keychain. The whole Credentials JSON is the single
9
9
  // secret per host (keyed by host).
10
+ //
11
+ // Reads are CACHED for the life of the process. Without this, one command touches
12
+ // the keychain several times — the auth gate reads it, then every authed request
13
+ // re-reads it through withFreshToken, and a paginated call re-reads it per page.
14
+ // On macOS each read is a separate access, so a user who granted "Allow" rather
15
+ // than "Always Allow" gets prompted once per read: two dialogs for `catalog`,
16
+ // three for `query`, more for anything that paginates or resolves a name.
17
+ //
18
+ // This costs nothing in EXPOSURE: the credentials are already in memory the moment
19
+ // they are read — they have to be, to go in an Authorization header — so holding
20
+ // them for the rest of a short-lived CLI process reveals nothing new. Nothing is
21
+ // written to disk, and the cache dies with the process.
22
+ //
23
+ // It does cost COHERENCE, and that is worth stating plainly rather than glossing.
24
+ // Another CLI process can log out or rotate tokens underneath us, and this cache
25
+ // will not notice. Two consequences, handled differently:
26
+ //
27
+ // Deleting someone else's session — handled. A failed refresh compare-and-deletes
28
+ // (see auth/refresh.ts): it re-reads past this cache and only clears when the
29
+ // stored token is still the one it tried. That race predates the cache; caching
30
+ // only widened the window.
31
+ //
32
+ // Using a token after a concurrent `auth logout` — accepted. Logout REVOKES
33
+ // upstream, so a stale cached token gets a 401 from the server rather than data,
34
+ // surfacing as data_auth_failed. Re-reading before every request would fix it and
35
+ // would also undo the entire point of caching, for a case that already fails
36
+ // safely.
10
37
  export class KeyringStore {
38
+ // `null` is cached too: "this host has no credentials" is an answer worth
39
+ // remembering, or a logged-out run re-reads on every check.
40
+ #cache = new Map();
11
41
  accessTokenIfFresh(host) {
12
42
  return freshAccessToken(this.load(host));
13
43
  }
@@ -23,6 +53,7 @@ export class KeyringStore {
23
53
  }
24
54
  }
25
55
  clear(host) {
56
+ this.#cache.set(host, null);
26
57
  try {
27
58
  new Entry(KEYRING_SERVICE, keyOf(host)).deletePassword();
28
59
  }
@@ -31,15 +62,27 @@ export class KeyringStore {
31
62
  }
32
63
  }
33
64
  load(host) {
65
+ const cached = this.#cache.get(host);
66
+ if (cached !== undefined)
67
+ return cached;
68
+ return this.reload(host);
69
+ }
70
+ reload(host) {
71
+ let creds = null;
34
72
  try {
35
73
  const secret = new Entry(KEYRING_SERVICE, keyOf(host)).getPassword();
36
- return secret ? parseCredentials(secret) : null;
74
+ creds = secret ? parseCredentials(secret) : null;
37
75
  }
38
76
  catch {
39
- return null;
77
+ creds = null;
40
78
  }
79
+ this.#cache.set(host, creds);
80
+ return creds;
41
81
  }
42
82
  save(host, creds) {
43
83
  new Entry(KEYRING_SERVICE, keyOf(host)).setPassword(JSON.stringify(creds));
84
+ // Keep the cache authoritative: a refresh saves rotated tokens mid-command,
85
+ // and a later read must see them rather than the ones it started with.
86
+ this.#cache.set(host, creds);
44
87
  }
45
88
  }
@@ -6,5 +6,6 @@ export declare class MemoryStore implements TokenStore {
6
6
  available(): boolean;
7
7
  clear(host: string): void;
8
8
  load(host: string): Credentials | null;
9
+ reload(host: string): Credentials | null;
9
10
  save(host: string, creds: Credentials): void;
10
11
  }
@@ -15,6 +15,11 @@ export class MemoryStore {
15
15
  load(host) {
16
16
  return this.entries.get(keyOf(host)) ?? null;
17
17
  }
18
+ // No cache here, so a forced re-read is the same read. Present so the interface
19
+ // is honest and tests exercise the same call sites as the real store.
20
+ reload(host) {
21
+ return this.load(host);
22
+ }
18
23
  save(host, creds) {
19
24
  this.entries.set(keyOf(host), creds);
20
25
  }