@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.
- package/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +217 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +162 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/cloudflare-provider.test.ts +9 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +206 -2
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/credential-staging.test.ts +659 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +1 -1
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/inference.test.ts +19 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +125 -8
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +31 -20
- package/src/__tests__/pipeline-integration.test.ts +1 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +659 -0
- package/src/__tests__/recipe.test.ts +4 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +41 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +29 -52
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +36 -8
- package/src/detect.ts +13 -5
- package/src/http.ts +6 -5
- package/src/index.ts +15 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom.ts +193 -15
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- 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
|
+
}
|
package/src/ai/catalog-index.ts
CHANGED
|
@@ -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",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
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(
|
|
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
|
|
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(
|
|
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]
|
|
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]
|
|
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]
|
|
108
|
+
while (this.failureTimes.length && (this.failureTimes[0] ?? 0) < cutoff) {
|
|
112
109
|
this.failureTimes.shift();
|
|
113
110
|
}
|
|
114
111
|
return this.failureTimes.length;
|
package/src/ai/cost-tracker.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
summary.perBackend[r.backend]
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
package/src/ai/inference.ts
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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,
|
|
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 --
|
|
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 (${
|
|
359
|
-
(opts.localOptions?.endpoints ?? [DEFAULT_LM_STUDIO,
|
|
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;
|