@ashlr/stack-core 0.1.1 → 0.2.2

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 (101) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. package/src/templates.ts +6 -2
@@ -0,0 +1,346 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { delimiter, dirname, join } from "node:path";
6
+
7
+ /**
8
+ * One-command onboarding: detect installed coding agents and register the
9
+ * Phantom gateway (`ashlr mcp`, server name `ashlr-hub`) in each one.
10
+ *
11
+ * Rules: plan by default (nothing is written), backup before every write,
12
+ * never overwrite an entry that differs from the expected one, no network, no
13
+ * vendor CLI, and undo restores byte for byte (refused if the file changed
14
+ * since we wrote it).
15
+ */
16
+
17
+ export const GATEWAY_SERVER_NAME = "ashlr-hub";
18
+ export const GATEWAY_COMMAND = "ashlr";
19
+ export const GATEWAY_ARGS = ["mcp"] as const;
20
+
21
+ export type AgentClient = "claude" | "codex" | "cursor" | "gemini";
22
+ export const AGENT_CLIENTS: readonly AgentClient[] = ["claude", "codex", "cursor", "gemini"];
23
+
24
+ interface ClientSpec {
25
+ client: AgentClient;
26
+ /** Config path relative to the home directory. */
27
+ configRel: string[];
28
+ /** Directory (relative to home) whose presence means the client is installed. */
29
+ detectDirRel: string[];
30
+ /** Binary whose presence on PATH means the client is installed. */
31
+ binary: string;
32
+ format: "json" | "toml";
33
+ /** Expected JSON entry (json clients). */
34
+ entry?: Record<string, unknown>;
35
+ }
36
+
37
+ const SPECS: Record<AgentClient, ClientSpec> = {
38
+ claude: {
39
+ client: "claude",
40
+ configRel: [".claude.json"],
41
+ detectDirRel: [".claude"],
42
+ binary: "claude",
43
+ format: "json",
44
+ entry: { type: "stdio", command: GATEWAY_COMMAND, args: [...GATEWAY_ARGS], env: {} },
45
+ },
46
+ codex: {
47
+ client: "codex",
48
+ configRel: [".codex", "config.toml"],
49
+ detectDirRel: [".codex"],
50
+ binary: "codex",
51
+ format: "toml",
52
+ },
53
+ cursor: {
54
+ client: "cursor",
55
+ configRel: [".cursor", "mcp.json"],
56
+ detectDirRel: [".cursor"],
57
+ binary: "cursor",
58
+ format: "json",
59
+ entry: { command: GATEWAY_COMMAND, args: [...GATEWAY_ARGS] },
60
+ },
61
+ gemini: {
62
+ client: "gemini",
63
+ configRel: [".gemini", "settings.json"],
64
+ detectDirRel: [".gemini"],
65
+ binary: "gemini",
66
+ format: "json",
67
+ entry: { command: GATEWAY_COMMAND, args: [...GATEWAY_ARGS] },
68
+ },
69
+ };
70
+
71
+ export type OnboardState = "not-detected" | "not-wired" | "wired" | "differs" | "error";
72
+
73
+ export interface OnboardEntryPlan {
74
+ client: AgentClient;
75
+ configPath: string;
76
+ detected: boolean;
77
+ state: OnboardState;
78
+ /** True when `--apply` would write this file. */
79
+ willWrite: boolean;
80
+ error?: string;
81
+ }
82
+
83
+ export interface OnboardOptions {
84
+ /** Home directory to read and write under. Defaults to the real home. */
85
+ home?: string;
86
+ /** PATH value used for binary detection. Defaults to process.env.PATH. */
87
+ pathEnv?: string;
88
+ /** Limit to these clients. Defaults to all four. */
89
+ clients?: readonly AgentClient[];
90
+ /** Also wire clients that are not detected (creates their config). */
91
+ includeUndetected?: boolean;
92
+ }
93
+
94
+ export function configPathFor(client: AgentClient, home: string): string {
95
+ return join(home, ...SPECS[client].configRel);
96
+ }
97
+
98
+ function binaryOnPath(binary: string, pathEnv: string): boolean {
99
+ for (const dir of pathEnv.split(delimiter)) {
100
+ if (!dir) continue;
101
+ const candidates =
102
+ process.platform === "win32"
103
+ ? [".exe", ".cmd", ".bat", ""].map((e) => join(dir, binary + e))
104
+ : [join(dir, binary)];
105
+ for (const c of candidates) {
106
+ try {
107
+ if (statSync(c).isFile()) return true;
108
+ } catch {
109
+ /* not here */
110
+ }
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function isDetected(spec: ClientSpec, home: string, pathEnv: string): boolean {
117
+ return existsSync(join(home, ...spec.detectDirRel)) || binaryOnPath(spec.binary, pathEnv);
118
+ }
119
+
120
+ function sameJson(a: unknown, b: unknown): boolean {
121
+ return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
122
+ }
123
+ function sortKeys(v: unknown): unknown {
124
+ if (Array.isArray(v)) return v.map(sortKeys);
125
+ if (v && typeof v === "object") {
126
+ return Object.fromEntries(
127
+ Object.entries(v as Record<string, unknown>)
128
+ .sort(([x], [y]) => x.localeCompare(y))
129
+ .map(([k, val]) => [k, sortKeys(val)]),
130
+ );
131
+ }
132
+ return v;
133
+ }
134
+
135
+ const TOML_BLOCK = `[mcp_servers.${GATEWAY_SERVER_NAME}]\ncommand = "${GATEWAY_COMMAND}"\nargs = [${GATEWAY_ARGS.map((a) => `"${a}"`).join(", ")}]\n`;
136
+
137
+ function codexSection(text: string): string | undefined {
138
+ const lines = text.split(/\r?\n/);
139
+ const header = new RegExp(
140
+ `^\\s*\\[mcp_servers\\.(?:"${GATEWAY_SERVER_NAME}"|${GATEWAY_SERVER_NAME})\\]\\s*(?:#.*)?$`,
141
+ );
142
+ const start = lines.findIndex((l) => header.test(l));
143
+ if (start < 0) return undefined;
144
+ const body: string[] = [];
145
+ for (let i = start + 1; i < lines.length; i++) {
146
+ if (/^\s*\[/.test(lines[i] as string)) break;
147
+ body.push(lines[i] as string);
148
+ }
149
+ return body.join("\n");
150
+ }
151
+
152
+ interface Inspection {
153
+ state: Exclude<OnboardState, "not-detected">;
154
+ error?: string;
155
+ }
156
+
157
+ function inspectFile(spec: ClientSpec, path: string): Inspection {
158
+ if (!existsSync(path)) return { state: "not-wired" };
159
+ let raw: string;
160
+ try {
161
+ raw = readFileSync(path, "utf-8");
162
+ } catch (err) {
163
+ return { state: "error", error: (err as Error).message };
164
+ }
165
+ if (spec.format === "toml") {
166
+ const body = codexSection(raw);
167
+ if (body === undefined) return { state: "not-wired" };
168
+ const command = /^\s*command\s*=\s*"([^"]*)"\s*(?:#.*)?$/m.exec(body)?.[1];
169
+ const args = /^\s*args\s*=\s*\[([^\]]*)\]\s*(?:#.*)?$/m.exec(body)?.[1]?.trim();
170
+ return command === GATEWAY_COMMAND && args === GATEWAY_ARGS.map((a) => `"${a}"`).join(", ")
171
+ ? { state: "wired" }
172
+ : { state: "differs" };
173
+ }
174
+ if (raw.trim() === "") return { state: "not-wired" };
175
+ try {
176
+ const parsed = JSON.parse(raw) as { mcpServers?: Record<string, unknown> };
177
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
178
+ return { state: "error", error: "config is not a JSON object" };
179
+ }
180
+ const servers = parsed.mcpServers;
181
+ if (servers !== undefined && (servers === null || typeof servers !== "object")) {
182
+ return { state: "error", error: "mcpServers is not an object" };
183
+ }
184
+ const entry = servers?.[GATEWAY_SERVER_NAME];
185
+ if (entry === undefined) return { state: "not-wired" };
186
+ return sameJson(entry, spec.entry) ? { state: "wired" } : { state: "differs" };
187
+ } catch (err) {
188
+ return { state: "error", error: `could not parse ${path}: ${(err as Error).message}` };
189
+ }
190
+ }
191
+
192
+ export function planOnboarding(opts: OnboardOptions = {}): OnboardEntryPlan[] {
193
+ const home = opts.home ?? homedir();
194
+ const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
195
+ const clients = opts.clients ?? AGENT_CLIENTS;
196
+ return clients.map((client) => {
197
+ const spec = SPECS[client];
198
+ const configPath = configPathFor(client, home);
199
+ const detected = isDetected(spec, home, pathEnv);
200
+ const inspected = inspectFile(spec, configPath);
201
+ if (!detected && !opts.includeUndetected && inspected.state === "not-wired") {
202
+ return { client, configPath, detected, state: "not-detected", willWrite: false };
203
+ }
204
+ return {
205
+ client,
206
+ configPath,
207
+ detected,
208
+ state: inspected.state,
209
+ willWrite: inspected.state === "not-wired",
210
+ ...(inspected.error ? { error: inspected.error } : {}),
211
+ };
212
+ });
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // apply and undo
217
+ // ---------------------------------------------------------------------------
218
+
219
+ export interface ManifestEntry {
220
+ client: AgentClient;
221
+ configPath: string;
222
+ /** Backup copy of the original file, or null when the file did not exist. */
223
+ backupPath: string | null;
224
+ /** SHA-256 of the file as we wrote it. */
225
+ postHash: string;
226
+ }
227
+ export interface OnboardManifest {
228
+ schema: "ashlr.stack.onboard/v1";
229
+ createdAt: string;
230
+ entries: ManifestEntry[];
231
+ }
232
+
233
+ export function manifestPath(home: string): string {
234
+ return join(home, ".ashlr-stack", "onboard-manifest.json");
235
+ }
236
+
237
+ const sha = (buf: Buffer | string): string => createHash("sha256").update(buf).digest("hex");
238
+
239
+ async function atomicWrite(path: string, data: string): Promise<void> {
240
+ await mkdir(dirname(path), { recursive: true });
241
+ const tmp = `${path}.stack-tmp-${process.pid}`;
242
+ await writeFile(tmp, data, "utf-8");
243
+ await rename(tmp, path);
244
+ }
245
+
246
+ function renderJson(spec: ClientSpec, existing: string | undefined): string {
247
+ const base =
248
+ existing && existing.trim() !== "" ? (JSON.parse(existing) as Record<string, unknown>) : {};
249
+ const servers = { ...((base.mcpServers as Record<string, unknown> | undefined) ?? {}) };
250
+ servers[GATEWAY_SERVER_NAME] = spec.entry;
251
+ return `${JSON.stringify({ ...base, mcpServers: servers }, null, 2)}\n`;
252
+ }
253
+
254
+ function renderToml(existing: string | undefined): string {
255
+ if (!existing || existing.trim() === "") return TOML_BLOCK;
256
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n";
257
+ return `${existing}${sep}${TOML_BLOCK}`;
258
+ }
259
+
260
+ export interface ApplyResult {
261
+ written: ManifestEntry[];
262
+ skipped: OnboardEntryPlan[];
263
+ /** Plan re-run after writing, as proof. */
264
+ verified: OnboardEntryPlan[];
265
+ manifestPath: string | null;
266
+ }
267
+
268
+ export async function applyOnboarding(opts: OnboardOptions = {}): Promise<ApplyResult> {
269
+ const home = opts.home ?? homedir();
270
+ const plan = planOnboarding(opts);
271
+ const written: ManifestEntry[] = [];
272
+ const skipped: OnboardEntryPlan[] = [];
273
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
274
+ for (const item of plan) {
275
+ if (!item.willWrite) {
276
+ skipped.push(item);
277
+ continue;
278
+ }
279
+ const spec = SPECS[item.client];
280
+ const exists = existsSync(item.configPath);
281
+ const existing = exists ? await readFile(item.configPath, "utf-8") : undefined;
282
+ const next = spec.format === "toml" ? renderToml(existing) : renderJson(spec, existing);
283
+ let backupPath: string | null = null;
284
+ if (exists) {
285
+ backupPath = `${item.configPath}.stack-bak-${stamp}`;
286
+ await copyFile(item.configPath, backupPath);
287
+ }
288
+ await atomicWrite(item.configPath, next);
289
+ written.push({
290
+ client: item.client,
291
+ configPath: item.configPath,
292
+ backupPath,
293
+ postHash: sha(next),
294
+ });
295
+ }
296
+ let mPath: string | null = null;
297
+ if (written.length > 0) {
298
+ mPath = manifestPath(home);
299
+ const manifest: OnboardManifest = {
300
+ schema: "ashlr.stack.onboard/v1",
301
+ createdAt: new Date().toISOString(),
302
+ entries: written,
303
+ };
304
+ await atomicWrite(mPath, `${JSON.stringify(manifest, null, 2)}\n`);
305
+ }
306
+ return { written, skipped, verified: planOnboarding(opts), manifestPath: mPath };
307
+ }
308
+
309
+ export interface UndoResult {
310
+ restored: ManifestEntry[];
311
+ refused: Array<{ entry: ManifestEntry; reason: string }>;
312
+ }
313
+
314
+ export async function undoOnboarding(opts: { home?: string } = {}): Promise<UndoResult> {
315
+ const home = opts.home ?? homedir();
316
+ const mPath = manifestPath(home);
317
+ if (!existsSync(mPath)) throw new Error("No onboarding manifest found; nothing to undo.");
318
+ const manifest = JSON.parse(await readFile(mPath, "utf-8")) as OnboardManifest;
319
+ const restored: ManifestEntry[] = [];
320
+ const refused: UndoResult["refused"] = [];
321
+ for (const entry of manifest.entries) {
322
+ if (!existsSync(entry.configPath)) {
323
+ refused.push({ entry, reason: "config file no longer exists" });
324
+ continue;
325
+ }
326
+ if (sha(await readFile(entry.configPath)) !== entry.postHash) {
327
+ refused.push({ entry, reason: "file changed since onboarding wrote it" });
328
+ continue;
329
+ }
330
+ if (entry.backupPath === null) {
331
+ await rm(entry.configPath);
332
+ } else if (!existsSync(entry.backupPath)) {
333
+ refused.push({ entry, reason: "backup file is missing" });
334
+ continue;
335
+ } else {
336
+ await copyFile(entry.backupPath, entry.configPath);
337
+ }
338
+ restored.push(entry);
339
+ }
340
+ if (refused.length === 0) await rm(mPath);
341
+ else {
342
+ const remaining: OnboardManifest = { ...manifest, entries: refused.map((r) => r.entry) };
343
+ await atomicWrite(mPath, `${JSON.stringify(remaining, null, 2)}\n`);
344
+ }
345
+ return { restored, refused };
346
+ }
@@ -33,12 +33,63 @@ const FIELD_WEIGHTS = {
33
33
  type FieldName = keyof typeof FIELD_WEIGHTS;
34
34
 
35
35
  const STOPWORDS = new Set([
36
- "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from",
37
- "has", "have", "he", "i", "if", "in", "into", "is", "it", "its", "me",
38
- "my", "need", "of", "on", "or", "some", "such", "that", "the", "their",
39
- "then", "there", "these", "they", "this", "to", "us", "want", "was",
40
- "we", "were", "will", "with", "you", "your", "build", "building", "make",
41
- "making", "use", "using", "app", "apps", "application", "project",
36
+ "a",
37
+ "an",
38
+ "and",
39
+ "are",
40
+ "as",
41
+ "at",
42
+ "be",
43
+ "but",
44
+ "by",
45
+ "for",
46
+ "from",
47
+ "has",
48
+ "have",
49
+ "he",
50
+ "i",
51
+ "if",
52
+ "in",
53
+ "into",
54
+ "is",
55
+ "it",
56
+ "its",
57
+ "me",
58
+ "my",
59
+ "need",
60
+ "of",
61
+ "on",
62
+ "or",
63
+ "some",
64
+ "such",
65
+ "that",
66
+ "the",
67
+ "their",
68
+ "then",
69
+ "there",
70
+ "these",
71
+ "they",
72
+ "this",
73
+ "to",
74
+ "us",
75
+ "want",
76
+ "was",
77
+ "we",
78
+ "were",
79
+ "will",
80
+ "with",
81
+ "you",
82
+ "your",
83
+ "build",
84
+ "building",
85
+ "make",
86
+ "making",
87
+ "use",
88
+ "using",
89
+ "app",
90
+ "apps",
91
+ "application",
92
+ "project",
42
93
  ]);
43
94
 
44
95
  /**
@@ -178,7 +229,10 @@ function idf(term: string): number {
178
229
  return IDF_CACHE?.get(term) ?? Math.log(1 + (N + 0.5) / 1.5);
179
230
  }
180
231
 
181
- function scoreProvider(idx: IndexedProvider, qTokens: string[]): { score: number; matched: string[] } {
232
+ function scoreProvider(
233
+ idx: IndexedProvider,
234
+ qTokens: string[],
235
+ ): { score: number; matched: string[] } {
182
236
  let score = 0;
183
237
  const matched = new Set<string>();
184
238
  for (const token of qTokens) {
@@ -221,7 +275,7 @@ export function retrieve(query: string, opts: RetrieveOptions = {}): RetrievalHi
221
275
  if (qTokens.length === 0) return [];
222
276
 
223
277
  const candidates = opts.categories?.length
224
- ? getIndex().filter((idx) => opts.categories!.includes(idx.provider.category))
278
+ ? getIndex().filter((idx) => opts.categories?.includes(idx.provider.category))
225
279
  : getIndex();
226
280
 
227
281
  const hits: RetrievalHit[] = [];
@@ -240,11 +294,15 @@ export function retrieve(query: string, opts: RetrieveOptions = {}): RetrievalHi
240
294
  * Group the top hits by category. Handy for the MCP tool response shape —
241
295
  * Claude can reason over "one from each category" instead of a flat list.
242
296
  */
243
- export function retrieveByCategory(query: string, opts: RetrieveOptions = {}): Record<string, RetrievalHit[]> {
297
+ export function retrieveByCategory(
298
+ query: string,
299
+ opts: RetrieveOptions = {},
300
+ ): Record<string, RetrievalHit[]> {
244
301
  const hits = retrieve(query, { ...opts, k: opts.k ?? 20 });
245
302
  const grouped: Record<string, RetrievalHit[]> = {};
246
303
  for (const hit of hits) {
247
- (grouped[hit.provider.category] ??= []).push(hit);
304
+ if (!grouped[hit.provider.category]) grouped[hit.provider.category] = [];
305
+ grouped[hit.provider.category].push(hit);
248
306
  }
249
307
  return grouped;
250
308
  }
@@ -86,7 +86,7 @@ export class CircuitBreaker {
86
86
 
87
87
  this.failureTimes.push(now);
88
88
  const cutoff = now - this.windowMs;
89
- while (this.failureTimes.length && this.failureTimes[0]! < cutoff) {
89
+ while (this.failureTimes.length && (this.failureTimes[0] ?? 0) < cutoff) {
90
90
  this.failureTimes.shift();
91
91
  }
92
92
  if (this.failureTimes.length >= this.threshold) {
@@ -96,10 +96,7 @@ export class CircuitBreaker {
96
96
 
97
97
  /** Observable state. Resolves time-based transitions lazily. */
98
98
  getState(): CircuitState {
99
- if (
100
- this.state === "open" &&
101
- this.now() - this.lastFailureTime >= this.resetTimeMs
102
- ) {
99
+ if (this.state === "open" && this.now() - this.lastFailureTime >= this.resetTimeMs) {
103
100
  this.state = "half-open";
104
101
  }
105
102
  return this.state;
@@ -108,7 +105,7 @@ export class CircuitBreaker {
108
105
  /** Current failure count inside the rolling window. */
109
106
  getFailureCount(): number {
110
107
  const cutoff = this.now() - this.windowMs;
111
- while (this.failureTimes.length && this.failureTimes[0]! < cutoff) {
108
+ while (this.failureTimes.length && (this.failureTimes[0] ?? 0) < cutoff) {
112
109
  this.failureTimes.shift();
113
110
  }
114
111
  return this.failureTimes.length;
@@ -43,15 +43,10 @@ export class CostTracker {
43
43
  this.rates.set(backend, rate);
44
44
  }
45
45
 
46
- recordUsage(
47
- backend: string,
48
- inputTokens: number,
49
- outputTokens: number,
50
- ): UsageRecord {
46
+ recordUsage(backend: string, inputTokens: number, outputTokens: number): UsageRecord {
51
47
  const rate = this.rates.get(backend) ?? { inputPer1M: 0, outputPer1M: 0 };
52
48
  const costUsd =
53
- (inputTokens / 1_000_000) * rate.inputPer1M +
54
- (outputTokens / 1_000_000) * rate.outputPer1M;
49
+ (inputTokens / 1_000_000) * rate.inputPer1M + (outputTokens / 1_000_000) * rate.outputPer1M;
55
50
  const entry: UsageRecord = {
56
51
  backend,
57
52
  inputTokens,
@@ -74,14 +69,10 @@ export class CostTracker {
74
69
  summary.totalInputTokens += r.inputTokens;
75
70
  summary.totalOutputTokens += r.outputTokens;
76
71
  summary.totalCostUsd += r.costUsd;
77
- const slot =
78
- summary.perBackend[r.backend] ??
79
- (summary.perBackend[r.backend] = {
80
- inputTokens: 0,
81
- outputTokens: 0,
82
- costUsd: 0,
83
- calls: 0,
84
- });
72
+ if (!summary.perBackend[r.backend]) {
73
+ summary.perBackend[r.backend] = { inputTokens: 0, outputTokens: 0, costUsd: 0, calls: 0 };
74
+ }
75
+ const slot = summary.perBackend[r.backend];
85
76
  slot.inputTokens += r.inputTokens;
86
77
  slot.outputTokens += r.outputTokens;
87
78
  slot.costUsd += r.costUsd;
@@ -12,13 +12,15 @@
12
12
  * 2. LocalSLMBackend — when Stack runs standalone (no MCP host), talk to
13
13
  * a local OpenAI-compatible server. Default to LM Studio at :1234,
14
14
  * fall back to Ollama at :11434. Zero remote calls, zero SDKs — raw
15
- * `fetch` over `/v1/chat/completions`.
15
+ * `fetch` over `/v1/chat/completions`. `OLLAMA_HOST` overrides the
16
+ * Ollama address.
16
17
  *
17
18
  * Selection is driven by `STACK_MCP_MODE` / `MCP_CLIENT` env vars; the
18
19
  * loose coupling keeps the CLI entrypoint dumb and lets the MCP server
19
20
  * force-pick its own backend when needed.
20
21
  */
21
22
 
23
+ import { ollamaBaseUrl } from "../base-urls.ts";
22
24
  import { CircuitBreaker } from "./circuit-breaker.ts";
23
25
  import { type CostTracker, defaultCostTracker } from "./cost-tracker.ts";
24
26
 
@@ -141,7 +143,6 @@ export interface LocalSLMBackendOptions {
141
143
  }
142
144
 
143
145
  const LM_STUDIO_URL = "http://localhost:1234/v1";
144
- const OLLAMA_URL = "http://localhost:11434/v1";
145
146
 
146
147
  const DEFAULT_LM_STUDIO: LocalSLMEndpoint = {
147
148
  name: "lm-studio",
@@ -149,11 +150,16 @@ const DEFAULT_LM_STUDIO: LocalSLMEndpoint = {
149
150
  model: process.env.STACK_LM_MODEL ?? "local-model",
150
151
  };
151
152
 
152
- const DEFAULT_OLLAMA: LocalSLMEndpoint = {
153
- name: "ollama",
154
- baseUrl: OLLAMA_URL,
155
- model: process.env.STACK_OLLAMA_MODEL ?? "llama3.1",
156
- };
153
+ /** Ollama's OpenAI-compatible endpoint; `OLLAMA_HOST` is read on each call. */
154
+ export function defaultOllamaEndpoint(
155
+ env: Record<string, string | undefined> = process.env,
156
+ ): LocalSLMEndpoint {
157
+ return {
158
+ name: "ollama",
159
+ baseUrl: `${ollamaBaseUrl(env)}/v1`,
160
+ model: env.STACK_OLLAMA_MODEL ?? "llama3.1",
161
+ };
162
+ }
157
163
 
158
164
  const SYSTEM_PROMPT = [
159
165
  "You are Stack, a CLI that recommends third-party providers for a dev project.",
@@ -174,7 +180,7 @@ export class LocalSLMBackend implements InferenceBackend {
174
180
  private lastUsedEndpoint: string | null = null;
175
181
 
176
182
  constructor(opts: LocalSLMBackendOptions = {}) {
177
- this.endpoints = opts.endpoints ?? [DEFAULT_LM_STUDIO, DEFAULT_OLLAMA];
183
+ this.endpoints = opts.endpoints ?? [DEFAULT_LM_STUDIO, defaultOllamaEndpoint()];
178
184
  this.timeoutMs = opts.timeoutMs ?? 30_000;
179
185
  this.costTracker = opts.costTracker ?? defaultCostTracker;
180
186
  // `bind` keeps the right receiver without picking up Bun's
@@ -335,7 +341,7 @@ function isMcpMode(env: Record<string, string | undefined>): boolean {
335
341
  * Pick the right backend for the current process.
336
342
  *
337
343
  * Decision order:
338
- * 1. `preferLocal` — always local (CLI `stack recommend --local` path).
344
+ * 1. `preferLocal` — always local (the CLI's `stack recommend --synth` path).
339
345
  * 2. MCP env vars set → Claude delegated mode.
340
346
  * 3. Otherwise → local SLM (fail loudly if unreachable).
341
347
  *
@@ -355,8 +361,10 @@ export async function getInferenceBackend(
355
361
  const local = new LocalSLMBackend(opts.localOptions);
356
362
  if (!(await local.healthy())) {
357
363
  throw new NoInferenceBackendError(
358
- `No local inference endpoint reachable. Start LM Studio (${LM_STUDIO_URL.replace("/v1", "")}) or Ollama (${OLLAMA_URL.replace("/v1", "")}), or run Stack inside Claude Code (MCP mode).`,
359
- (opts.localOptions?.endpoints ?? [DEFAULT_LM_STUDIO, DEFAULT_OLLAMA]).map((e) => e.name),
364
+ `No local inference endpoint reachable. Start LM Studio (${LM_STUDIO_URL.replace("/v1", "")}) or Ollama (${ollamaBaseUrl(env)}), or run Stack inside Claude Code (MCP mode).`,
365
+ (opts.localOptions?.endpoints ?? [DEFAULT_LM_STUDIO, defaultOllamaEndpoint(env)]).map(
366
+ (e) => e.name,
367
+ ),
360
368
  );
361
369
  }
362
370
  return local;