local-executor 0.1.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/LICENSE +21 -0
- package/README.md +301 -0
- package/dist/cli.js +2606 -0
- package/package.json +69 -0
- package/skill/adapters/claude-code/SKILL.md +45 -0
- package/skill/adapters/codex/AGENTS.block.md +13 -0
- package/skill/adapters/codex/SKILL.md +6 -0
- package/skill/adapters/cursor/local-executor.mdc +19 -0
- package/skill/adapters/windsurf/local-executor.md +18 -0
- package/skill/core/PIPELINE.md +109 -0
- package/skill/core/audit-prompt.md +51 -0
- package/skill/core/executor-system-prompt.md +34 -0
- package/skill/core/handoff-template.md +65 -0
- package/skill/core/modern-practices.md +61 -0
- package/skill/runtime/check_local.mjs +56 -0
- package/skill/runtime/config.json +10 -0
- package/skill/runtime/run_executor.mjs +225 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2606 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/util/fs.ts
|
|
13
|
+
import { access as access2, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "fs/promises";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { dirname, join as join3, resolve, sep } from "path";
|
|
16
|
+
async function exists(p2) {
|
|
17
|
+
try {
|
|
18
|
+
await access2(p2);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function isDirectory(p2) {
|
|
25
|
+
try {
|
|
26
|
+
return (await stat(p2)).isDirectory();
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function ensureDir(p2) {
|
|
32
|
+
await mkdir(p2, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
async function readJson(p2) {
|
|
35
|
+
return JSON.parse(await readFile(p2, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
async function readJsonOr(p2, fallback) {
|
|
38
|
+
try {
|
|
39
|
+
return await readJson(p2);
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function writeJson(p2, value) {
|
|
45
|
+
await ensureDir(dirname(p2));
|
|
46
|
+
await writeFile(p2, `${JSON.stringify(value, null, 2)}
|
|
47
|
+
`, "utf8");
|
|
48
|
+
}
|
|
49
|
+
async function writeText(p2, content) {
|
|
50
|
+
await ensureDir(dirname(p2));
|
|
51
|
+
await writeFile(p2, content, "utf8");
|
|
52
|
+
}
|
|
53
|
+
async function readTextOr(p2, fallback = "") {
|
|
54
|
+
try {
|
|
55
|
+
return await readFile(p2, "utf8");
|
|
56
|
+
} catch {
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function copyDir(src, dest) {
|
|
61
|
+
await ensureDir(dest);
|
|
62
|
+
await cp(src, dest, { recursive: true, force: true });
|
|
63
|
+
return listFiles(dest);
|
|
64
|
+
}
|
|
65
|
+
async function listFiles(dir) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const p2 = join3(dir, e.name);
|
|
70
|
+
if (e.isDirectory()) out.push(...await listFiles(p2));
|
|
71
|
+
else out.push(p2);
|
|
72
|
+
}
|
|
73
|
+
return out.sort();
|
|
74
|
+
}
|
|
75
|
+
async function removePath(p2) {
|
|
76
|
+
await rm(p2, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
function home() {
|
|
79
|
+
return homedir();
|
|
80
|
+
}
|
|
81
|
+
function contractTilde(p2) {
|
|
82
|
+
const h = home();
|
|
83
|
+
if (p2 === h) return "~";
|
|
84
|
+
if (p2.startsWith(h + sep)) return `~${p2.slice(h.length)}`;
|
|
85
|
+
return p2;
|
|
86
|
+
}
|
|
87
|
+
function toPosix(p2) {
|
|
88
|
+
return p2.split("\\").join("/");
|
|
89
|
+
}
|
|
90
|
+
async function findGitRoot(start) {
|
|
91
|
+
let dir = resolve(start);
|
|
92
|
+
for (; ; ) {
|
|
93
|
+
if (await exists(join3(dir, ".git"))) return dir;
|
|
94
|
+
const parent = dirname(dir);
|
|
95
|
+
if (parent === dir) return null;
|
|
96
|
+
dir = parent;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
var init_fs = __esm({
|
|
100
|
+
"src/util/fs.ts"() {
|
|
101
|
+
"use strict";
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// src/util/state.ts
|
|
106
|
+
var state_exports = {};
|
|
107
|
+
__export(state_exports, {
|
|
108
|
+
cacheDir: () => cacheDir,
|
|
109
|
+
emptyManifest: () => emptyManifest,
|
|
110
|
+
installKey: () => installKey,
|
|
111
|
+
lexHome: () => lexHome,
|
|
112
|
+
manifestPath: () => manifestPath,
|
|
113
|
+
readManifest: () => readManifest,
|
|
114
|
+
upsertInstall: () => upsertInstall,
|
|
115
|
+
writeManifest: () => writeManifest
|
|
116
|
+
});
|
|
117
|
+
import { join as join4 } from "path";
|
|
118
|
+
function lexHome() {
|
|
119
|
+
return process.env.LEX_HOME ?? join4(home(), ".local-executor");
|
|
120
|
+
}
|
|
121
|
+
function manifestPath() {
|
|
122
|
+
return join4(lexHome(), "manifest.json");
|
|
123
|
+
}
|
|
124
|
+
function cacheDir() {
|
|
125
|
+
return join4(lexHome(), "cache");
|
|
126
|
+
}
|
|
127
|
+
async function readManifest() {
|
|
128
|
+
const m = await readJsonOr(manifestPath(), emptyManifest());
|
|
129
|
+
if (!Array.isArray(m.installs)) m.installs = [];
|
|
130
|
+
return m;
|
|
131
|
+
}
|
|
132
|
+
async function writeManifest(m) {
|
|
133
|
+
m.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
134
|
+
await writeJson(manifestPath(), m);
|
|
135
|
+
}
|
|
136
|
+
function installKey(rec) {
|
|
137
|
+
const anchor = rec.scope === "project" ? rec.owned[0] ?? rec.marked[0] ?? "" : "";
|
|
138
|
+
return `${rec.agent}|${rec.scope}|${rec.root}|${anchor}`;
|
|
139
|
+
}
|
|
140
|
+
function upsertInstall(m, rec) {
|
|
141
|
+
const key = installKey(rec);
|
|
142
|
+
const rest = m.installs.filter((r) => installKey(r) !== key);
|
|
143
|
+
return { ...m, installs: [...rest, rec] };
|
|
144
|
+
}
|
|
145
|
+
var emptyManifest;
|
|
146
|
+
var init_state = __esm({
|
|
147
|
+
"src/util/state.ts"() {
|
|
148
|
+
"use strict";
|
|
149
|
+
init_fs();
|
|
150
|
+
emptyManifest = () => ({
|
|
151
|
+
schema: 1,
|
|
152
|
+
installs: [],
|
|
153
|
+
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// src/cli.ts
|
|
159
|
+
import { Command, Option } from "commander";
|
|
160
|
+
|
|
161
|
+
// src/agents/types.ts
|
|
162
|
+
var allAgents = ["claude", "codex", "cursor", "windsurf"];
|
|
163
|
+
var agentNames = {
|
|
164
|
+
claude: "Claude Code",
|
|
165
|
+
codex: "Codex CLI",
|
|
166
|
+
cursor: "Cursor",
|
|
167
|
+
windsurf: "Windsurf"
|
|
168
|
+
};
|
|
169
|
+
function parseAgentList(input) {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const raw of input.split(",")) {
|
|
172
|
+
const s = raw.trim().toLowerCase();
|
|
173
|
+
if (!s) continue;
|
|
174
|
+
const id = s === "claude-code" ? "claude" : s === "codex-cli" ? "codex" : s;
|
|
175
|
+
if (!allAgents.includes(id)) {
|
|
176
|
+
throw new Error(`Unknown agent "${raw.trim()}". Choose from: ${allAgents.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
if (!out.includes(id)) out.push(id);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/commands/doctor.ts
|
|
184
|
+
import { join as join5 } from "path";
|
|
185
|
+
|
|
186
|
+
// src/ollama/client.ts
|
|
187
|
+
var DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
188
|
+
var OllamaClient = class {
|
|
189
|
+
baseUrl;
|
|
190
|
+
fetchImpl;
|
|
191
|
+
constructor(baseUrl = DEFAULT_OLLAMA_URL, fetchImpl = (i, o) => fetch(i, o)) {
|
|
192
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
193
|
+
this.fetchImpl = fetchImpl;
|
|
194
|
+
}
|
|
195
|
+
/** Server version, or null when unreachable. Never throws. */
|
|
196
|
+
async version(timeoutMs = 3e3) {
|
|
197
|
+
try {
|
|
198
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/version`, {
|
|
199
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) return null;
|
|
202
|
+
const data = await res.json();
|
|
203
|
+
return data.version ?? null;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async isUp(timeoutMs = 3e3) {
|
|
209
|
+
return await this.version(timeoutMs) !== null;
|
|
210
|
+
}
|
|
211
|
+
/** Models present locally. Throws when the server is unreachable. */
|
|
212
|
+
async list() {
|
|
213
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/tags`, {
|
|
214
|
+
signal: AbortSignal.timeout(5e3)
|
|
215
|
+
});
|
|
216
|
+
if (!res.ok) throw new Error(`Ollama /api/tags returned HTTP ${res.status}`);
|
|
217
|
+
const data = await res.json();
|
|
218
|
+
return (data.models ?? []).map((m) => ({ name: m.name, sizeBytes: m.size ?? 0 }));
|
|
219
|
+
}
|
|
220
|
+
async hasModel(tag) {
|
|
221
|
+
const models = await this.list();
|
|
222
|
+
return models.some((m) => m.name === tag || m.name === `${tag}:latest`);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Pull a model with streaming progress. Calls `onEvent` for every NDJSON line.
|
|
226
|
+
* Resolves when the stream ends with a success status; rejects on error.
|
|
227
|
+
*/
|
|
228
|
+
async pull(tag, onEvent, signal) {
|
|
229
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/pull`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { "Content-Type": "application/json" },
|
|
232
|
+
body: JSON.stringify({ model: tag, stream: true }),
|
|
233
|
+
...signal ? { signal } : {}
|
|
234
|
+
});
|
|
235
|
+
if (!res.ok || !res.body) {
|
|
236
|
+
throw new Error(`Ollama /api/pull failed: HTTP ${res.status} ${await safeText(res)}`);
|
|
237
|
+
}
|
|
238
|
+
let sawSuccess = false;
|
|
239
|
+
for await (const event of ndjson(res.body)) {
|
|
240
|
+
const e = event;
|
|
241
|
+
if (e.error) throw new Error(`Ollama pull error: ${e.error}`);
|
|
242
|
+
onEvent(e);
|
|
243
|
+
if (e.status === "success") sawSuccess = true;
|
|
244
|
+
}
|
|
245
|
+
if (!sawSuccess) throw new Error("Ollama pull stream ended without a success status");
|
|
246
|
+
}
|
|
247
|
+
/** One short generation to load the model and measure throughput. */
|
|
248
|
+
async warmup(tag, keepAlive = "30m", timeoutMs = 18e4) {
|
|
249
|
+
const res = await this.fetchImpl(`${this.baseUrl}/api/generate`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
model: tag,
|
|
254
|
+
prompt: "Write a JavaScript one-liner that returns the sum of an array. Reply with code only.",
|
|
255
|
+
stream: false,
|
|
256
|
+
keep_alive: keepAlive,
|
|
257
|
+
options: { num_predict: 64, temperature: 0 }
|
|
258
|
+
}),
|
|
259
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
260
|
+
});
|
|
261
|
+
if (!res.ok)
|
|
262
|
+
throw new Error(`Ollama /api/generate failed: HTTP ${res.status} ${await safeText(res)}`);
|
|
263
|
+
const data = await res.json();
|
|
264
|
+
const evalCount = data.eval_count ?? 0;
|
|
265
|
+
const evalNs = data.eval_duration ?? 0;
|
|
266
|
+
return {
|
|
267
|
+
tokensPerSec: evalNs > 0 ? Math.round(evalCount / (evalNs / 1e9) * 10) / 10 : null,
|
|
268
|
+
evalCount,
|
|
269
|
+
evalDurationMs: Math.round(evalNs / 1e6),
|
|
270
|
+
loadDurationMs: Math.round((data.load_duration ?? 0) / 1e6),
|
|
271
|
+
totalDurationMs: Math.round((data.total_duration ?? 0) / 1e6),
|
|
272
|
+
reply: data.response ?? ""
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** Poll until the server answers or the deadline passes. */
|
|
276
|
+
async waitUntilUp(timeoutMs = 3e4, intervalMs = 500) {
|
|
277
|
+
const deadline = Date.now() + timeoutMs;
|
|
278
|
+
while (Date.now() < deadline) {
|
|
279
|
+
if (await this.isUp(1500)) return true;
|
|
280
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
async function safeText(res) {
|
|
286
|
+
try {
|
|
287
|
+
return (await res.text()).slice(0, 300);
|
|
288
|
+
} catch {
|
|
289
|
+
return "";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function* ndjson(body) {
|
|
293
|
+
const reader = body.getReader();
|
|
294
|
+
const decoder = new TextDecoder();
|
|
295
|
+
let buffer = "";
|
|
296
|
+
for (; ; ) {
|
|
297
|
+
const { value, done } = await reader.read();
|
|
298
|
+
if (done) break;
|
|
299
|
+
buffer += decoder.decode(value, { stream: true });
|
|
300
|
+
let nl = buffer.indexOf("\n");
|
|
301
|
+
while (nl >= 0) {
|
|
302
|
+
const line = buffer.slice(0, nl).trim();
|
|
303
|
+
buffer = buffer.slice(nl + 1);
|
|
304
|
+
if (line) yield JSON.parse(line);
|
|
305
|
+
nl = buffer.indexOf("\n");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const rest = buffer.trim();
|
|
309
|
+
if (rest) yield JSON.parse(rest);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/ollama/release.ts
|
|
313
|
+
import { join } from "path";
|
|
314
|
+
var OLLAMA_RELEASES_API = "https://api.github.com/repos/ollama/ollama/releases/latest";
|
|
315
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
316
|
+
function releaseCachePath(cacheDir2) {
|
|
317
|
+
return join(cacheDir2, "ollama-release.json");
|
|
318
|
+
}
|
|
319
|
+
async function latestOllamaVersion(deps) {
|
|
320
|
+
const cached2 = await deps.readCache().catch(() => null);
|
|
321
|
+
if (cached2 && deps.now() - cached2.fetchedAt < CACHE_TTL_MS) return cached2.tag;
|
|
322
|
+
try {
|
|
323
|
+
const res = await deps.fetchImpl(OLLAMA_RELEASES_API, {
|
|
324
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "local-executor" },
|
|
325
|
+
signal: AbortSignal.timeout(5e3)
|
|
326
|
+
});
|
|
327
|
+
if (!res.ok) return cached2?.tag ?? null;
|
|
328
|
+
const data = await res.json();
|
|
329
|
+
if (!data.tag_name) return cached2?.tag ?? null;
|
|
330
|
+
await deps.writeCache({ tag: data.tag_name, fetchedAt: deps.now() }).catch(() => void 0);
|
|
331
|
+
return data.tag_name;
|
|
332
|
+
} catch {
|
|
333
|
+
return cached2?.tag ?? null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function parseVersion(v) {
|
|
337
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
338
|
+
if (!m) return null;
|
|
339
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
340
|
+
}
|
|
341
|
+
function compareVersions(a, b) {
|
|
342
|
+
const pa = parseVersion(a);
|
|
343
|
+
const pb = parseVersion(b);
|
|
344
|
+
if (!pa || !pb) return null;
|
|
345
|
+
for (let i = 0; i < 3; i++) {
|
|
346
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
347
|
+
if (d !== 0) return d;
|
|
348
|
+
}
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
function isOutdated(installed, latest) {
|
|
352
|
+
const c = compareVersions(installed, latest);
|
|
353
|
+
return c !== null && c < 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/util/exec.ts
|
|
357
|
+
import { constants } from "fs";
|
|
358
|
+
import { access } from "fs/promises";
|
|
359
|
+
import { delimiter, join as join2 } from "path";
|
|
360
|
+
import { execa } from "execa";
|
|
361
|
+
async function run(file, args = [], opts = {}) {
|
|
362
|
+
const options = {
|
|
363
|
+
reject: false,
|
|
364
|
+
stdio: opts.inherit ? "inherit" : "pipe",
|
|
365
|
+
windowsHide: true,
|
|
366
|
+
...opts.cwd ? { cwd: opts.cwd } : {},
|
|
367
|
+
...opts.env ? { env: opts.env } : {},
|
|
368
|
+
...opts.timeoutMs ? { timeout: opts.timeoutMs } : {}
|
|
369
|
+
};
|
|
370
|
+
const result = await execa(file, args, options);
|
|
371
|
+
return {
|
|
372
|
+
ok: result.exitCode === 0,
|
|
373
|
+
exitCode: result.exitCode,
|
|
374
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
375
|
+
stderr: typeof result.stderr === "string" ? result.stderr : ""
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
async function which(cmd, env = process.env, platform = process.platform) {
|
|
379
|
+
const pathVar = env.PATH ?? env.Path ?? "";
|
|
380
|
+
const dirs = pathVar.split(delimiter).filter(Boolean);
|
|
381
|
+
const exts = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((e) => e.toLowerCase()) : [""];
|
|
382
|
+
for (const dir of dirs) {
|
|
383
|
+
for (const ext of exts) {
|
|
384
|
+
const candidate = join2(dir, cmd.toLowerCase().endsWith(ext) && ext ? cmd : cmd + ext);
|
|
385
|
+
try {
|
|
386
|
+
await access(candidate, platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
387
|
+
return candidate;
|
|
388
|
+
} catch {
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
function spawnDetached(file, args = [], opts = {}) {
|
|
395
|
+
const options = {
|
|
396
|
+
detached: true,
|
|
397
|
+
stdio: "ignore",
|
|
398
|
+
cleanup: false,
|
|
399
|
+
windowsHide: true,
|
|
400
|
+
reject: false,
|
|
401
|
+
...opts.cwd ? { cwd: opts.cwd } : {}
|
|
402
|
+
};
|
|
403
|
+
execa(file, args, options).unref();
|
|
404
|
+
}
|
|
405
|
+
async function openUrl(url, platform = process.platform) {
|
|
406
|
+
try {
|
|
407
|
+
if (platform === "darwin") await execa("open", [url], { reject: false });
|
|
408
|
+
else if (platform === "win32")
|
|
409
|
+
await execa("rundll32", ["url.dll,FileProtocolHandler", url], {
|
|
410
|
+
reject: false,
|
|
411
|
+
windowsHide: true
|
|
412
|
+
});
|
|
413
|
+
else await execa("xdg-open", [url], { reject: false });
|
|
414
|
+
return true;
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function displayCommand(file, args) {
|
|
420
|
+
const quote = (s) => /[\s"']/.test(s) ? JSON.stringify(s) : s;
|
|
421
|
+
return [file, ...args].map(quote).join(" ");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/commands/doctor.ts
|
|
425
|
+
init_fs();
|
|
426
|
+
|
|
427
|
+
// src/util/log.ts
|
|
428
|
+
import * as p from "@clack/prompts";
|
|
429
|
+
import pc from "picocolors";
|
|
430
|
+
var ui = { yes: false, json: false, tty: Boolean(process.stdout.isTTY) };
|
|
431
|
+
function configureUi(opts) {
|
|
432
|
+
Object.assign(ui, opts);
|
|
433
|
+
}
|
|
434
|
+
function nonInteractive() {
|
|
435
|
+
return ui.yes || ui.json || !ui.tty;
|
|
436
|
+
}
|
|
437
|
+
function silent() {
|
|
438
|
+
return ui.json;
|
|
439
|
+
}
|
|
440
|
+
function intro2(title) {
|
|
441
|
+
if (silent()) return;
|
|
442
|
+
p.intro(pc.bgCyan(pc.black(` ${title} `)));
|
|
443
|
+
}
|
|
444
|
+
function outro2(message2) {
|
|
445
|
+
if (silent()) return;
|
|
446
|
+
p.outro(message2);
|
|
447
|
+
}
|
|
448
|
+
function header(step, title, oneLiner) {
|
|
449
|
+
if (silent()) return;
|
|
450
|
+
p.log.step(`${pc.bold(pc.cyan(step))} ${pc.bold(title)}
|
|
451
|
+
${pc.dim(oneLiner)}`);
|
|
452
|
+
}
|
|
453
|
+
function info(msg) {
|
|
454
|
+
if (silent()) return;
|
|
455
|
+
p.log.info(msg);
|
|
456
|
+
}
|
|
457
|
+
function success(msg) {
|
|
458
|
+
if (silent()) return;
|
|
459
|
+
p.log.success(msg);
|
|
460
|
+
}
|
|
461
|
+
function warn(msg) {
|
|
462
|
+
if (silent()) return;
|
|
463
|
+
p.log.warn(pc.yellow(msg));
|
|
464
|
+
}
|
|
465
|
+
function error(msg) {
|
|
466
|
+
if (silent()) {
|
|
467
|
+
process.stderr.write(`${msg}
|
|
468
|
+
`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
p.log.error(pc.red(msg));
|
|
472
|
+
}
|
|
473
|
+
function message(msg) {
|
|
474
|
+
if (silent()) return;
|
|
475
|
+
p.log.message(msg);
|
|
476
|
+
}
|
|
477
|
+
function note2(body, title) {
|
|
478
|
+
if (silent()) return;
|
|
479
|
+
p.note(body, title);
|
|
480
|
+
}
|
|
481
|
+
function box2(body, title) {
|
|
482
|
+
if (silent()) return;
|
|
483
|
+
p.box(body, title, { rounded: true });
|
|
484
|
+
}
|
|
485
|
+
function formatTable(rows) {
|
|
486
|
+
const width = Math.max(0, ...rows.map(([k]) => k.length));
|
|
487
|
+
return rows.map(([k, v]) => `${pc.dim(k.padEnd(width))} ${v}`).join("\n");
|
|
488
|
+
}
|
|
489
|
+
function table(rows) {
|
|
490
|
+
if (silent()) return;
|
|
491
|
+
p.log.message(formatTable(rows));
|
|
492
|
+
}
|
|
493
|
+
function code(text3) {
|
|
494
|
+
return pc.cyan(text3);
|
|
495
|
+
}
|
|
496
|
+
function cancelled() {
|
|
497
|
+
if (!silent()) p.cancel("Cancelled.");
|
|
498
|
+
process.exit(130);
|
|
499
|
+
}
|
|
500
|
+
async function confirm2(message2, initial = true) {
|
|
501
|
+
if (nonInteractive()) return initial;
|
|
502
|
+
const v = await p.confirm({ message: message2, initialValue: initial });
|
|
503
|
+
if (p.isCancel(v)) cancelled();
|
|
504
|
+
return v;
|
|
505
|
+
}
|
|
506
|
+
async function select2(message2, options, initial) {
|
|
507
|
+
if (nonInteractive()) return initial;
|
|
508
|
+
const v = await p.select({
|
|
509
|
+
message: message2,
|
|
510
|
+
options: options.map(
|
|
511
|
+
(o) => o.hint === void 0 ? { value: o.value, label: o.label } : o
|
|
512
|
+
),
|
|
513
|
+
initialValue: initial,
|
|
514
|
+
maxItems: 12
|
|
515
|
+
});
|
|
516
|
+
if (p.isCancel(v)) cancelled();
|
|
517
|
+
return v;
|
|
518
|
+
}
|
|
519
|
+
async function multiselect2(message2, options, initial, required = false) {
|
|
520
|
+
if (nonInteractive()) return initial;
|
|
521
|
+
const v = await p.multiselect({
|
|
522
|
+
message: message2,
|
|
523
|
+
options: options.map(
|
|
524
|
+
(o) => o.hint === void 0 ? { value: o.value, label: o.label } : o
|
|
525
|
+
),
|
|
526
|
+
initialValues: initial,
|
|
527
|
+
required
|
|
528
|
+
});
|
|
529
|
+
if (p.isCancel(v)) cancelled();
|
|
530
|
+
return v;
|
|
531
|
+
}
|
|
532
|
+
async function text2(message2, placeholder, fallback) {
|
|
533
|
+
if (nonInteractive()) return fallback;
|
|
534
|
+
const v = await p.text({ message: message2, placeholder, defaultValue: fallback });
|
|
535
|
+
if (p.isCancel(v)) cancelled();
|
|
536
|
+
return v || fallback;
|
|
537
|
+
}
|
|
538
|
+
var PLAIN_PROGRESS_INTERVAL_MS = 1e4;
|
|
539
|
+
var noopSpinner = {
|
|
540
|
+
start: () => void 0,
|
|
541
|
+
message: () => void 0,
|
|
542
|
+
stop: () => void 0,
|
|
543
|
+
error: () => void 0
|
|
544
|
+
};
|
|
545
|
+
function spinner2() {
|
|
546
|
+
if (silent()) return noopSpinner;
|
|
547
|
+
if (!ui.tty) {
|
|
548
|
+
let lastPrinted = 0;
|
|
549
|
+
return {
|
|
550
|
+
start: (m) => process.stdout.write(`${m}
|
|
551
|
+
`),
|
|
552
|
+
message: (m) => {
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
if (now - lastPrinted >= PLAIN_PROGRESS_INTERVAL_MS) {
|
|
555
|
+
lastPrinted = now;
|
|
556
|
+
process.stdout.write(` ${m}
|
|
557
|
+
`);
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
stop: (m) => process.stdout.write(`${m}
|
|
561
|
+
`),
|
|
562
|
+
error: (m) => process.stderr.write(`${m}
|
|
563
|
+
`)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const s = p.spinner();
|
|
567
|
+
return {
|
|
568
|
+
start: (m) => s.start(m),
|
|
569
|
+
message: (m) => s.message(m),
|
|
570
|
+
stop: (m) => s.stop(m),
|
|
571
|
+
error: (m) => s.error(m)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/commands/doctor.ts
|
|
576
|
+
init_state();
|
|
577
|
+
async function collectDoctorChecks(ollamaUrl) {
|
|
578
|
+
const checks = [];
|
|
579
|
+
const client = new OllamaClient(ollamaUrl);
|
|
580
|
+
const bin = await which("ollama");
|
|
581
|
+
checks.push({ name: "ollama binary", ok: bin !== null, detail: bin ?? "not on PATH" });
|
|
582
|
+
const version = await client.version();
|
|
583
|
+
checks.push({
|
|
584
|
+
name: "ollama server",
|
|
585
|
+
ok: version !== null,
|
|
586
|
+
detail: version ? `v${version} at ${ollamaUrl}` : `not reachable at ${ollamaUrl}`
|
|
587
|
+
});
|
|
588
|
+
if (version) {
|
|
589
|
+
const cachePath = releaseCachePath(cacheDir());
|
|
590
|
+
const latest = await latestOllamaVersion({
|
|
591
|
+
fetchImpl: (i, o) => fetch(i, o),
|
|
592
|
+
readCache: () => readJsonOr(cachePath, null),
|
|
593
|
+
writeCache: (c) => writeJson(cachePath, c),
|
|
594
|
+
now: () => Date.now()
|
|
595
|
+
});
|
|
596
|
+
if (latest) {
|
|
597
|
+
const outdated = isOutdated(version, latest);
|
|
598
|
+
checks.push({
|
|
599
|
+
name: "ollama version",
|
|
600
|
+
ok: !outdated,
|
|
601
|
+
warn: true,
|
|
602
|
+
detail: outdated ? `latest is ${latest}` : `up to date (${latest})`
|
|
603
|
+
});
|
|
604
|
+
} else {
|
|
605
|
+
checks.push({
|
|
606
|
+
name: "ollama version",
|
|
607
|
+
ok: true,
|
|
608
|
+
warn: true,
|
|
609
|
+
detail: "latest release unknown (offline)"
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const manifest = await readManifest();
|
|
614
|
+
checks.push({
|
|
615
|
+
name: "install manifest",
|
|
616
|
+
ok: manifest.installs.length > 0,
|
|
617
|
+
detail: manifest.installs.length > 0 ? `${manifest.installs.length} install(s) in ${contractTilde(manifestPath())}` : "no installs recorded \u2014 run `lex init`"
|
|
618
|
+
});
|
|
619
|
+
const localModels = version ? await client.list().then((l) => l.map((m) => m.name)).catch(() => []) : [];
|
|
620
|
+
for (const rec of manifest.installs) {
|
|
621
|
+
const label = `${rec.agent}/${rec.scope}`;
|
|
622
|
+
const ownsRoot = rec.owned.includes(rec.root);
|
|
623
|
+
if (ownsRoot) {
|
|
624
|
+
const files = [
|
|
625
|
+
"core/PIPELINE.md",
|
|
626
|
+
"core/executor-system-prompt.md",
|
|
627
|
+
"runtime/run_executor.mjs",
|
|
628
|
+
"runtime/check_local.mjs",
|
|
629
|
+
"runtime/config.json"
|
|
630
|
+
];
|
|
631
|
+
const missing = [];
|
|
632
|
+
for (const f of files) if (!await exists(join5(rec.root, f))) missing.push(f);
|
|
633
|
+
checks.push({
|
|
634
|
+
name: `${label} files`,
|
|
635
|
+
ok: missing.length === 0,
|
|
636
|
+
detail: missing.length === 0 ? contractTilde(rec.root) : `missing ${missing.join(", ")} in ${contractTilde(rec.root)}`
|
|
637
|
+
});
|
|
638
|
+
const cfg = await readJsonOr(rec.configPath, {});
|
|
639
|
+
const model = cfg.model ?? "(none)";
|
|
640
|
+
const pulled = localModels.includes(model) || localModels.includes(`${model}:latest`);
|
|
641
|
+
checks.push({
|
|
642
|
+
name: `${label} model`,
|
|
643
|
+
ok: version === null ? false : pulled,
|
|
644
|
+
detail: version === null ? `${model} (server down, cannot check)` : pulled ? `${model} pulled` : `${model} not pulled \u2014 run: ollama pull ${model}`
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
for (const o of rec.owned.filter((p2) => p2 !== rec.root)) {
|
|
648
|
+
checks.push({ name: `${label} adapter`, ok: await exists(o), detail: contractTilde(o) });
|
|
649
|
+
}
|
|
650
|
+
for (const m of rec.marked) {
|
|
651
|
+
const text3 = await import("fs/promises").then((fs) => fs.readFile(m, "utf8")).catch(() => "");
|
|
652
|
+
checks.push({
|
|
653
|
+
name: `${label} block`,
|
|
654
|
+
ok: text3.includes("<!-- lex:start -->"),
|
|
655
|
+
detail: contractTilde(m)
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return checks;
|
|
660
|
+
}
|
|
661
|
+
async function runDoctor(opts) {
|
|
662
|
+
configureUi({ json: opts.json });
|
|
663
|
+
const checks = await collectDoctorChecks(opts.ollamaUrl);
|
|
664
|
+
const failures = checks.filter((c) => !c.ok && !c.warn);
|
|
665
|
+
if (opts.json) {
|
|
666
|
+
process.stdout.write(`${JSON.stringify({ ok: failures.length === 0, checks }, null, 2)}
|
|
667
|
+
`);
|
|
668
|
+
return failures.length === 0 ? 0 : 1;
|
|
669
|
+
}
|
|
670
|
+
intro2("lex doctor");
|
|
671
|
+
const rows = checks.map((c) => [
|
|
672
|
+
c.name,
|
|
673
|
+
`${c.ok ? pc.green("ok ") : c.warn ? pc.yellow("warn") : pc.red("FAIL")} ${c.detail}`
|
|
674
|
+
]);
|
|
675
|
+
table(rows);
|
|
676
|
+
outro2(
|
|
677
|
+
failures.length === 0 ? pc.green("All checks passed.") : pc.red(`${failures.length} check(s) failed.`)
|
|
678
|
+
);
|
|
679
|
+
return failures.length === 0 ? 0 : 1;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/agents/detect.ts
|
|
683
|
+
import { join as join6 } from "path";
|
|
684
|
+
function pathProbe(cmd) {
|
|
685
|
+
return { label: `${cmd} on PATH`, check: async (d) => await d.which(cmd) !== null };
|
|
686
|
+
}
|
|
687
|
+
function dirProbe(rel) {
|
|
688
|
+
return { label: `~/${rel} exists`, check: async (d) => d.exists(join6(d.home, rel)) };
|
|
689
|
+
}
|
|
690
|
+
function appProbe(platform, label, path) {
|
|
691
|
+
return {
|
|
692
|
+
label,
|
|
693
|
+
check: async (d) => d.platform === platform && d.exists(path(d))
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
var probes = {
|
|
697
|
+
claude: [pathProbe("claude"), dirProbe(".claude")],
|
|
698
|
+
codex: [pathProbe("codex"), dirProbe(".codex")],
|
|
699
|
+
cursor: [
|
|
700
|
+
pathProbe("cursor"),
|
|
701
|
+
dirProbe(".cursor"),
|
|
702
|
+
appProbe("darwin", "Cursor.app installed", () => "/Applications/Cursor.app"),
|
|
703
|
+
appProbe(
|
|
704
|
+
"win32",
|
|
705
|
+
"Cursor installed",
|
|
706
|
+
(d) => join6(d.env.LOCALAPPDATA ?? "", "Programs", "cursor")
|
|
707
|
+
)
|
|
708
|
+
],
|
|
709
|
+
windsurf: [
|
|
710
|
+
pathProbe("windsurf"),
|
|
711
|
+
dirProbe(".codeium/windsurf"),
|
|
712
|
+
appProbe("darwin", "Windsurf.app installed", () => "/Applications/Windsurf.app"),
|
|
713
|
+
appProbe(
|
|
714
|
+
"win32",
|
|
715
|
+
"Windsurf installed",
|
|
716
|
+
(d) => join6(d.env.LOCALAPPDATA ?? "", "Programs", "Windsurf")
|
|
717
|
+
)
|
|
718
|
+
]
|
|
719
|
+
};
|
|
720
|
+
async function detectAgents(deps) {
|
|
721
|
+
const out = [];
|
|
722
|
+
for (const id of allAgents) {
|
|
723
|
+
const evidence = [];
|
|
724
|
+
for (const p2 of probes[id]) {
|
|
725
|
+
if (await p2.check(deps).catch(() => false)) evidence.push(p2.label);
|
|
726
|
+
}
|
|
727
|
+
out.push({ id, name: agentNames[id], detected: evidence.length > 0, evidence });
|
|
728
|
+
}
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/steps/agents.ts
|
|
733
|
+
init_fs();
|
|
734
|
+
async function stepAgents(ctx) {
|
|
735
|
+
header(
|
|
736
|
+
"4/6",
|
|
737
|
+
"Agents",
|
|
738
|
+
"Which AI coding agents should learn the pipeline. Detected ones are preselected."
|
|
739
|
+
);
|
|
740
|
+
const platform = process.platform === "darwin" || process.platform === "win32" ? process.platform : "linux";
|
|
741
|
+
ctx.detections = await detectAgents({ which, exists, home: home(), platform, env: process.env });
|
|
742
|
+
const detected = ctx.detections.filter((d) => d.detected);
|
|
743
|
+
for (const d of ctx.detections) {
|
|
744
|
+
if (d.detected) success(`${d.name}: ${d.evidence.join(", ")}`);
|
|
745
|
+
else message(pc.dim(`${d.name}: not detected`));
|
|
746
|
+
}
|
|
747
|
+
if (ctx.opts.agents && ctx.opts.agents.length > 0) {
|
|
748
|
+
ctx.agents = ctx.opts.agents;
|
|
749
|
+
} else {
|
|
750
|
+
const initial = detected.map((d) => d.id);
|
|
751
|
+
ctx.agents = await multiselect2(
|
|
752
|
+
"Install the skill for",
|
|
753
|
+
allAgents.map((id) => ({
|
|
754
|
+
value: id,
|
|
755
|
+
label: agentNames[id],
|
|
756
|
+
hint: ctx.detections.find((d) => d.id === id)?.detected ? "detected" : "not detected \u2014 install anyway"
|
|
757
|
+
})),
|
|
758
|
+
initial,
|
|
759
|
+
false
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (ctx.agents.length === 0) {
|
|
763
|
+
warn(
|
|
764
|
+
"No agents selected. Nothing will be installed; run `lex init --agents claude,codex` later."
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
const notDetected = ctx.agents.filter((a) => !ctx.detections.find((d) => d.id === a)?.detected);
|
|
768
|
+
if (notDetected.length > 0) {
|
|
769
|
+
warn(
|
|
770
|
+
`Installing for agents that are not detected yet: ${notDetected.map((a) => agentNames[a]).join(", ")}. The files will be ready when you install them.`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
ctx.projectRoot = await findGitRoot(ctx.cwd);
|
|
774
|
+
const needsProject = ctx.agents.some((a) => a === "cursor" || a === "windsurf");
|
|
775
|
+
if (ctx.opts.project !== void 0) {
|
|
776
|
+
ctx.installProject = ctx.opts.project && ctx.projectRoot !== null;
|
|
777
|
+
if (ctx.opts.project && !ctx.projectRoot) {
|
|
778
|
+
warn(
|
|
779
|
+
"--project given but the current directory is not inside a git repository; skipping project-level files."
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
} else if (ctx.projectRoot && ctx.agents.length > 0) {
|
|
783
|
+
ctx.installProject = await confirm2(
|
|
784
|
+
`Also install project-level files into ${ctx.projectRoot}? (${needsProject ? "required for Cursor/Windsurf rules; " : ""}Claude gets a repo-local copy of the skill)`,
|
|
785
|
+
needsProject
|
|
786
|
+
);
|
|
787
|
+
} else {
|
|
788
|
+
ctx.installProject = false;
|
|
789
|
+
if (needsProject) {
|
|
790
|
+
warn(
|
|
791
|
+
"Cursor/Windsurf rules are per project. Run `lex init --agents cursor --project` inside a repository to add the rule file there."
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/hardware/detect.ts
|
|
798
|
+
import { join as join7, resolve as resolve2, sep as sep2 } from "path";
|
|
799
|
+
import si from "systeminformation";
|
|
800
|
+
var defaultDeps = () => ({
|
|
801
|
+
cpu: () => si.cpu(),
|
|
802
|
+
mem: () => si.mem(),
|
|
803
|
+
graphics: () => si.graphics(),
|
|
804
|
+
fsSize: () => si.fsSize(),
|
|
805
|
+
platform: process.platform,
|
|
806
|
+
arch: process.arch,
|
|
807
|
+
env: process.env,
|
|
808
|
+
homedir: process.env.HOME ?? process.env.USERPROFILE ?? ""
|
|
809
|
+
});
|
|
810
|
+
var GB = 1024 ** 3;
|
|
811
|
+
var round1 = (n) => Math.round(n * 10) / 10;
|
|
812
|
+
function toPlatform(p2) {
|
|
813
|
+
if (p2 === "darwin" || p2 === "win32") return p2;
|
|
814
|
+
return "linux";
|
|
815
|
+
}
|
|
816
|
+
function ollamaModelsDir(env, homedir2) {
|
|
817
|
+
if (env.OLLAMA_MODELS) return env.OLLAMA_MODELS;
|
|
818
|
+
return join7(homedir2, ".ollama", "models");
|
|
819
|
+
}
|
|
820
|
+
function classifyGpu(controllers, platform, arch) {
|
|
821
|
+
if (platform === "darwin" && arch === "arm64") {
|
|
822
|
+
const apple = controllers.find((c) => /apple/i.test(`${c.vendor} ${c.model}`));
|
|
823
|
+
return { kind: "apple", model: apple?.model || "Apple Silicon (unified memory)", vramGB: null };
|
|
824
|
+
}
|
|
825
|
+
let best = { kind: "none", model: "", vramGB: null };
|
|
826
|
+
const rank = { nvidia: 4, amd: 3, apple: 2, intel: 1, none: 0 };
|
|
827
|
+
for (const c of controllers) {
|
|
828
|
+
const text3 = `${c.vendor} ${c.model}`;
|
|
829
|
+
let kind = "none";
|
|
830
|
+
if (/nvidia/i.test(text3)) kind = "nvidia";
|
|
831
|
+
else if (/\b(amd|ati|radeon)\b/i.test(text3)) kind = "amd";
|
|
832
|
+
else if (/intel/i.test(text3)) kind = "intel";
|
|
833
|
+
else if (/apple/i.test(text3)) kind = "apple";
|
|
834
|
+
if (kind === "none") continue;
|
|
835
|
+
const mb = c.memoryTotal ?? c.vram ?? null;
|
|
836
|
+
const vramGB = mb === null ? null : round1(mb / 1024);
|
|
837
|
+
const better = rank[kind] > rank[best.kind] || rank[kind] === rank[best.kind] && (vramGB ?? 0) > (best.vramGB ?? 0);
|
|
838
|
+
if (better) best = { kind, model: c.model || c.vendor, vramGB };
|
|
839
|
+
}
|
|
840
|
+
return best;
|
|
841
|
+
}
|
|
842
|
+
function pickVolume(volumes, dir, platform) {
|
|
843
|
+
const norm = (p2) => {
|
|
844
|
+
let s = resolve2(p2);
|
|
845
|
+
if (platform === "win32") s = s.toLowerCase();
|
|
846
|
+
return s.endsWith(sep2) ? s : s + sep2;
|
|
847
|
+
};
|
|
848
|
+
const target = norm(dir);
|
|
849
|
+
let best;
|
|
850
|
+
let bestLen = -1;
|
|
851
|
+
for (const v of volumes) {
|
|
852
|
+
if (!v.mount) continue;
|
|
853
|
+
const m = norm(v.mount);
|
|
854
|
+
if (target.startsWith(m) && m.length > bestLen) {
|
|
855
|
+
best = v;
|
|
856
|
+
bestLen = m.length;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return best;
|
|
860
|
+
}
|
|
861
|
+
async function detectHardware(deps = defaultDeps()) {
|
|
862
|
+
const [cpu, mem, gfx, volumes] = await Promise.all([
|
|
863
|
+
deps.cpu().catch(() => ({ manufacturer: "", brand: "unknown", cores: 1, physicalCores: 1 })),
|
|
864
|
+
deps.mem().catch(() => ({ total: 0, available: 0, free: 0 })),
|
|
865
|
+
deps.graphics().catch(() => ({ controllers: [] })),
|
|
866
|
+
deps.fsSize().catch(() => [])
|
|
867
|
+
]);
|
|
868
|
+
const modelsDir = ollamaModelsDir(deps.env, deps.homedir);
|
|
869
|
+
const vol = pickVolume(volumes, modelsDir, deps.platform) ?? pickVolume(volumes, deps.homedir, deps.platform);
|
|
870
|
+
const physical = cpu.physicalCores > 0 ? cpu.physicalCores : cpu.cores;
|
|
871
|
+
const cpuModel = [cpu.manufacturer, cpu.brand].filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
|
|
872
|
+
return {
|
|
873
|
+
platform: toPlatform(deps.platform),
|
|
874
|
+
arch: deps.arch,
|
|
875
|
+
cpuModel: cpuModel || "unknown CPU",
|
|
876
|
+
physicalCores: physical,
|
|
877
|
+
logicalCores: cpu.cores,
|
|
878
|
+
totalRamGB: round1(mem.total / GB),
|
|
879
|
+
freeRamGB: round1((mem.available || mem.free) / GB),
|
|
880
|
+
gpu: classifyGpu(gfx.controllers, deps.platform, deps.arch),
|
|
881
|
+
freeDiskGB: round1((vol?.available ?? 0) / GB),
|
|
882
|
+
modelsDir
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/models/catalog.ts
|
|
887
|
+
var lastVerified = "2026-09-06";
|
|
888
|
+
var catalog = [
|
|
889
|
+
{
|
|
890
|
+
tag: "qwen3.5:2b",
|
|
891
|
+
family: "qwen3.5",
|
|
892
|
+
params: "2B dense",
|
|
893
|
+
sizeGB: 2.7,
|
|
894
|
+
contextK: 256,
|
|
895
|
+
notes: "Smallest usable executor; keep packets short and single-file."
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
tag: "gemma4:e2b",
|
|
899
|
+
family: "gemma4",
|
|
900
|
+
params: "2B effective (per-layer embeddings)",
|
|
901
|
+
sizeGB: 7.2,
|
|
902
|
+
contextK: 128,
|
|
903
|
+
notes: "Fast on CPU; larger download than its active size suggests."
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
tag: "qwen3.5:4b",
|
|
907
|
+
family: "qwen3.5",
|
|
908
|
+
params: "4B dense",
|
|
909
|
+
sizeGB: 3.4,
|
|
910
|
+
contextK: 256,
|
|
911
|
+
notes: "Light and fast; good for small, well-specified tasks."
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
tag: "gemma4:e4b",
|
|
915
|
+
family: "gemma4",
|
|
916
|
+
params: "4B effective (per-layer embeddings)",
|
|
917
|
+
sizeGB: 9.6,
|
|
918
|
+
contextK: 128,
|
|
919
|
+
notes: "Solid instruction following; heavier download than qwen3.5:4b."
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
tag: "qwen3.5:9b",
|
|
923
|
+
family: "qwen3.5",
|
|
924
|
+
params: "9B dense",
|
|
925
|
+
sizeGB: 6.6,
|
|
926
|
+
contextK: 256,
|
|
927
|
+
notes: "Best code quality that fits on a 16 GB machine."
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
tag: "qwen3.5:9b-mlx",
|
|
931
|
+
family: "qwen3.5",
|
|
932
|
+
params: "9B dense, MLX build",
|
|
933
|
+
sizeGB: 8.9,
|
|
934
|
+
contextK: 256,
|
|
935
|
+
notes: "Apple Silicon only; faster than the GGUF build, needs ~2 GB more memory.",
|
|
936
|
+
appleOnly: true
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
tag: "gemma4:12b",
|
|
940
|
+
family: "gemma4",
|
|
941
|
+
params: "12B dense",
|
|
942
|
+
sizeGB: 7.6,
|
|
943
|
+
contextK: 256,
|
|
944
|
+
notes: "Dense 12B; a good middle ground when 9B is not enough."
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
tag: "devstral",
|
|
948
|
+
family: "devstral",
|
|
949
|
+
params: "24B dense (Mistral, agentic coding)",
|
|
950
|
+
sizeGB: 14,
|
|
951
|
+
contextK: 128,
|
|
952
|
+
notes: "Purpose-built for coding; older but well-tested."
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
tag: "gemma4:26b",
|
|
956
|
+
family: "gemma4",
|
|
957
|
+
params: "26B MoE (4B active)",
|
|
958
|
+
sizeGB: 19,
|
|
959
|
+
contextK: 256,
|
|
960
|
+
notes: "Strong coding; MoE is fast per token but the whole 26B must fit in memory."
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
tag: "qwen3.6:27b-coding",
|
|
964
|
+
family: "qwen3.6",
|
|
965
|
+
params: "27B dense, coding tune",
|
|
966
|
+
sizeGB: 18,
|
|
967
|
+
contextK: 256,
|
|
968
|
+
notes: "Dense coding tune; slower per token than the 35B MoE but very capable."
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
tag: "gemma4:31b",
|
|
972
|
+
family: "gemma4",
|
|
973
|
+
params: "31B dense",
|
|
974
|
+
sizeGB: 20,
|
|
975
|
+
contextK: 256,
|
|
976
|
+
notes: "Largest Gemma that fits on a 48 GB machine with room for context."
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
tag: "qwen3.6:35b-a3b",
|
|
980
|
+
family: "qwen3.6",
|
|
981
|
+
params: "35B MoE (3B active)",
|
|
982
|
+
sizeGB: 23,
|
|
983
|
+
contextK: 256,
|
|
984
|
+
notes: "Frontier-adjacent local coding; fast per token thanks to 3B active."
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
tag: "qwen3.6:35b-a3b-coding",
|
|
988
|
+
family: "qwen3.6",
|
|
989
|
+
params: "35B MoE (3B active), coding tune",
|
|
990
|
+
sizeGB: 23,
|
|
991
|
+
contextK: 256,
|
|
992
|
+
notes: "Same footprint as qwen3.6:35b-a3b with a coding-specific fine-tune."
|
|
993
|
+
}
|
|
994
|
+
];
|
|
995
|
+
var tiers = [
|
|
996
|
+
{
|
|
997
|
+
minGB: 0,
|
|
998
|
+
maxGB: 6,
|
|
999
|
+
label: "< 6 GB",
|
|
1000
|
+
recommended: "qwen3.5:2b",
|
|
1001
|
+
alsoOffer: ["gemma4:e2b"],
|
|
1002
|
+
reason: "Very tight; short packets only"
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
minGB: 6,
|
|
1006
|
+
maxGB: 10,
|
|
1007
|
+
label: "6\u201310 GB",
|
|
1008
|
+
recommended: "qwen3.5:4b",
|
|
1009
|
+
alsoOffer: ["gemma4:e4b"],
|
|
1010
|
+
reason: "Light and fast; good for small tasks"
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
minGB: 10,
|
|
1014
|
+
maxGB: 14,
|
|
1015
|
+
label: "10\u201314 GB",
|
|
1016
|
+
recommended: "qwen3.5:9b",
|
|
1017
|
+
alsoOffer: ["gemma4:e4b", "qwen3.5:4b"],
|
|
1018
|
+
reason: "Best code quality that fits"
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
minGB: 14,
|
|
1022
|
+
maxGB: 22,
|
|
1023
|
+
label: "14\u201322 GB",
|
|
1024
|
+
recommended: "qwen3.5:9b",
|
|
1025
|
+
alsoOffer: ["gemma4:e4b", "qwen3.5:9b-mlx"],
|
|
1026
|
+
reason: "9B at Q4 fits with headroom"
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
minGB: 22,
|
|
1030
|
+
maxGB: 30,
|
|
1031
|
+
label: "22\u201330 GB",
|
|
1032
|
+
recommended: "gemma4:26b",
|
|
1033
|
+
alsoOffer: ["qwen3.5:9b", "devstral"],
|
|
1034
|
+
reason: "MoE 26B fits; strong coding"
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
minGB: 30,
|
|
1038
|
+
maxGB: 48,
|
|
1039
|
+
label: "30\u201348 GB",
|
|
1040
|
+
recommended: "qwen3.6:35b-a3b",
|
|
1041
|
+
alsoOffer: ["gemma4:26b", "gemma4:31b"],
|
|
1042
|
+
reason: "Frontier-adjacent local coding"
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
minGB: 48,
|
|
1046
|
+
maxGB: Number.POSITIVE_INFINITY,
|
|
1047
|
+
label: "\u2265 48 GB",
|
|
1048
|
+
recommended: "qwen3.6:35b-a3b",
|
|
1049
|
+
alsoOffer: ["gemma4:31b", "qwen3.6:35b-a3b-coding", "qwen3.6:27b-coding"],
|
|
1050
|
+
reason: "Room for big context"
|
|
1051
|
+
}
|
|
1052
|
+
];
|
|
1053
|
+
var diskSafetyFactor = 1.5;
|
|
1054
|
+
var minCpuOnlyCores = 4;
|
|
1055
|
+
var appleUnifiedMemoryShare = 0.7;
|
|
1056
|
+
var cpuOnlyRamShare = 0.5;
|
|
1057
|
+
var minDiscreteVramGB = 4;
|
|
1058
|
+
function findModel(tag) {
|
|
1059
|
+
return catalog.find((m) => m.tag === tag);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// src/models/recommend.ts
|
|
1063
|
+
function round12(n) {
|
|
1064
|
+
return Math.round(n * 10) / 10;
|
|
1065
|
+
}
|
|
1066
|
+
function isAppleSilicon(hw) {
|
|
1067
|
+
return hw.platform === "darwin" && hw.arch === "arm64";
|
|
1068
|
+
}
|
|
1069
|
+
function effectiveMemory(hw) {
|
|
1070
|
+
if (isAppleSilicon(hw) || hw.gpu.kind === "apple") {
|
|
1071
|
+
return {
|
|
1072
|
+
gb: round12(hw.totalRamGB * appleUnifiedMemoryShare),
|
|
1073
|
+
rule: `Apple Silicon: ${Math.round(appleUnifiedMemoryShare * 100)}% of ${round12(hw.totalRamGB)} GB unified memory`
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
const vram = hw.gpu.vramGB ?? 0;
|
|
1077
|
+
if ((hw.gpu.kind === "nvidia" || hw.gpu.kind === "amd") && vram >= minDiscreteVramGB) {
|
|
1078
|
+
const vendor = hw.gpu.kind === "nvidia" ? "NVIDIA" : "AMD";
|
|
1079
|
+
return {
|
|
1080
|
+
gb: round12(vram),
|
|
1081
|
+
rule: `${vendor} GPU: ${round12(vram)} GB VRAM (${hw.gpu.model})`
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
gb: round12(hw.totalRamGB * cpuOnlyRamShare),
|
|
1086
|
+
rule: `CPU only: ${Math.round(cpuOnlyRamShare * 100)}% of ${round12(hw.totalRamGB)} GB RAM`
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function tierFor(effectiveGB) {
|
|
1090
|
+
const found = tiers.find((t) => effectiveGB >= t.minGB && effectiveGB < t.maxGB);
|
|
1091
|
+
return found ?? tiers[0];
|
|
1092
|
+
}
|
|
1093
|
+
function tierIndex(tier) {
|
|
1094
|
+
return tiers.indexOf(tier);
|
|
1095
|
+
}
|
|
1096
|
+
function stepDown(tier) {
|
|
1097
|
+
const i = tierIndex(tier);
|
|
1098
|
+
return i > 0 ? tiers[i - 1] : void 0;
|
|
1099
|
+
}
|
|
1100
|
+
function diskNeededGB(model) {
|
|
1101
|
+
return round12(model.sizeGB * diskSafetyFactor);
|
|
1102
|
+
}
|
|
1103
|
+
function isCpuOnly(hw) {
|
|
1104
|
+
if (isAppleSilicon(hw) || hw.gpu.kind === "apple") return false;
|
|
1105
|
+
const vram = hw.gpu.vramGB ?? 0;
|
|
1106
|
+
return !((hw.gpu.kind === "nvidia" || hw.gpu.kind === "amd") && vram >= minDiscreteVramGB);
|
|
1107
|
+
}
|
|
1108
|
+
function buildRecommendation(hw, opts = {}) {
|
|
1109
|
+
const mem = effectiveMemory(hw);
|
|
1110
|
+
const naturalTier = tierFor(mem.gb);
|
|
1111
|
+
let tier = naturalTier;
|
|
1112
|
+
const adjustments = [];
|
|
1113
|
+
if (isCpuOnly(hw) && hw.physicalCores < minCpuOnlyCores) {
|
|
1114
|
+
const from = tier;
|
|
1115
|
+
let lower = stepDown(tier);
|
|
1116
|
+
while (lower && lower.recommended === from.recommended) lower = stepDown(lower);
|
|
1117
|
+
if (lower) {
|
|
1118
|
+
adjustments.push(
|
|
1119
|
+
`Stepped down from ${from.recommended} to ${lower.recommended}: only ${hw.physicalCores} physical cores on a CPU-only machine; a larger model would be too slow.`
|
|
1120
|
+
);
|
|
1121
|
+
tier = lower;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
for (; ; ) {
|
|
1125
|
+
const model = findModel(tier.recommended);
|
|
1126
|
+
if (!model) break;
|
|
1127
|
+
if (diskNeededGB(model) <= hw.freeDiskGB) break;
|
|
1128
|
+
const lower = stepDown(tier);
|
|
1129
|
+
if (!lower) {
|
|
1130
|
+
adjustments.push(
|
|
1131
|
+
`${tier.recommended} needs ${diskNeededGB(model)} GB free disk (download \xD7 ${diskSafetyFactor}) and only ${round12(hw.freeDiskGB)} GB is free. Nothing smaller is in the catalog; free some space before pulling.`
|
|
1132
|
+
);
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
adjustments.push(
|
|
1136
|
+
`Stepped down from ${tier.recommended} to ${lower.recommended}: ${tier.recommended} needs ${diskNeededGB(model)} GB free disk and only ${round12(hw.freeDiskGB)} GB is free.`
|
|
1137
|
+
);
|
|
1138
|
+
tier = lower;
|
|
1139
|
+
}
|
|
1140
|
+
const apple = isAppleSilicon(hw);
|
|
1141
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1142
|
+
const list = [];
|
|
1143
|
+
const push = (tag, reason, recommended) => {
|
|
1144
|
+
const model = findModel(tag);
|
|
1145
|
+
if (!model || seen.has(tag)) return;
|
|
1146
|
+
if (model.appleOnly && !apple) return;
|
|
1147
|
+
seen.add(tag);
|
|
1148
|
+
const needed = diskNeededGB(model);
|
|
1149
|
+
const fitsDisk = needed <= hw.freeDiskGB;
|
|
1150
|
+
const fitsMemory = model.sizeGB <= mem.gb;
|
|
1151
|
+
const warnings = [];
|
|
1152
|
+
if (!fitsDisk) {
|
|
1153
|
+
warnings.push(`Needs ${needed} GB free disk; ${round12(hw.freeDiskGB)} GB available.`);
|
|
1154
|
+
}
|
|
1155
|
+
if (!fitsMemory) {
|
|
1156
|
+
warnings.push(
|
|
1157
|
+
`Download (${model.sizeGB} GB) is larger than effective memory (${mem.gb} GB); expect swapping and very slow output.`
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
list.push({
|
|
1161
|
+
tag,
|
|
1162
|
+
sizeGB: model.sizeGB,
|
|
1163
|
+
diskNeededGB: needed,
|
|
1164
|
+
fitsDisk,
|
|
1165
|
+
fitsMemory,
|
|
1166
|
+
recommended,
|
|
1167
|
+
rank: list.length,
|
|
1168
|
+
reason,
|
|
1169
|
+
warnings,
|
|
1170
|
+
model
|
|
1171
|
+
});
|
|
1172
|
+
};
|
|
1173
|
+
push(tier.recommended, tier.reason, true);
|
|
1174
|
+
for (const alt of tier.alsoOffer) {
|
|
1175
|
+
const m = findModel(alt);
|
|
1176
|
+
push(alt, m?.notes ?? "Alternative for this tier", false);
|
|
1177
|
+
}
|
|
1178
|
+
const rest = catalog.filter((m) => !seen.has(m.tag));
|
|
1179
|
+
const fitting = rest.filter((m) => m.sizeGB <= mem.gb).sort((a, b) => b.sizeGB - a.sizeGB);
|
|
1180
|
+
for (const m of fitting) push(m.tag, m.notes, false);
|
|
1181
|
+
if (opts.includeAll) {
|
|
1182
|
+
const tooBig = rest.filter((m) => m.sizeGB > mem.gb).sort((a, b) => a.sizeGB - b.sizeGB);
|
|
1183
|
+
for (const m of tooBig) push(m.tag, `${m.notes} (does not fit in ${mem.gb} GB)`, false);
|
|
1184
|
+
}
|
|
1185
|
+
return { effectiveMemory: mem, tier, naturalTier, adjustments, list };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/steps/hardware.ts
|
|
1189
|
+
var gb = (n) => `${Math.round(n * 10) / 10} GB`;
|
|
1190
|
+
function hardwareRows(hw) {
|
|
1191
|
+
const gpu = hw.gpu.kind === "apple" ? "Apple Silicon (unified memory)" : hw.gpu.kind === "none" ? "none detected" : `${hw.gpu.model}${hw.gpu.vramGB !== null ? ` (${gb(hw.gpu.vramGB)} VRAM)` : ""}`;
|
|
1192
|
+
return [
|
|
1193
|
+
["OS", `${hw.platform} (${hw.arch})`],
|
|
1194
|
+
["CPU", `${hw.cpuModel}, ${hw.physicalCores} cores (${hw.logicalCores} threads)`],
|
|
1195
|
+
["RAM", `${gb(hw.totalRamGB)} total, ${gb(hw.freeRamGB)} free`],
|
|
1196
|
+
["GPU", gpu],
|
|
1197
|
+
["Free disk", `${gb(hw.freeDiskGB)} on the volume holding ${hw.modelsDir}`]
|
|
1198
|
+
];
|
|
1199
|
+
}
|
|
1200
|
+
async function stepHardware(ctx) {
|
|
1201
|
+
header(
|
|
1202
|
+
"2/6",
|
|
1203
|
+
"Hardware",
|
|
1204
|
+
"What this machine can run. Effective memory decides the model tier."
|
|
1205
|
+
);
|
|
1206
|
+
const sp = spinner2();
|
|
1207
|
+
sp.start("Detecting CPU, memory, GPU, and disk\u2026");
|
|
1208
|
+
const hw = await detectHardware();
|
|
1209
|
+
sp.stop("Hardware detected.");
|
|
1210
|
+
ctx.hw = hw;
|
|
1211
|
+
table(hardwareRows(hw));
|
|
1212
|
+
const report = buildRecommendation(hw);
|
|
1213
|
+
ctx.report = report;
|
|
1214
|
+
info(
|
|
1215
|
+
`Effective memory for models: ${pc.bold(gb(report.effectiveMemory.gb))} (${report.effectiveMemory.rule}) \u2192 tier ${report.tier.label}`
|
|
1216
|
+
);
|
|
1217
|
+
for (const a of report.adjustments) warn(a);
|
|
1218
|
+
const top = report.list[0];
|
|
1219
|
+
if (top) success(`Recommended: ${code(top.tag)} (~${top.sizeGB} GB) \u2014 ${top.reason}`);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/ollama/progress.ts
|
|
1223
|
+
var PullTracker = class {
|
|
1224
|
+
layers = /* @__PURE__ */ new Map();
|
|
1225
|
+
status = "starting";
|
|
1226
|
+
startedAt;
|
|
1227
|
+
now;
|
|
1228
|
+
constructor(now = () => Date.now()) {
|
|
1229
|
+
this.now = now;
|
|
1230
|
+
this.startedAt = now();
|
|
1231
|
+
}
|
|
1232
|
+
update(e) {
|
|
1233
|
+
if (e.status) this.status = e.status;
|
|
1234
|
+
if (e.digest && typeof e.total === "number") {
|
|
1235
|
+
this.layers.set(e.digest, { total: e.total, completed: e.completed ?? 0 });
|
|
1236
|
+
}
|
|
1237
|
+
return this.snapshot();
|
|
1238
|
+
}
|
|
1239
|
+
snapshot() {
|
|
1240
|
+
let total = 0;
|
|
1241
|
+
let completed = 0;
|
|
1242
|
+
for (const l of this.layers.values()) {
|
|
1243
|
+
total += l.total;
|
|
1244
|
+
completed += l.completed;
|
|
1245
|
+
}
|
|
1246
|
+
const elapsedSec = Math.max(1e-3, (this.now() - this.startedAt) / 1e3);
|
|
1247
|
+
return {
|
|
1248
|
+
status: this.status,
|
|
1249
|
+
completedBytes: completed,
|
|
1250
|
+
totalBytes: total,
|
|
1251
|
+
percent: total > 0 ? Math.min(100, Math.floor(completed / total * 100)) : null,
|
|
1252
|
+
bytesPerSec: completed > 0 ? completed / elapsedSec : null
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
function formatBytes(n) {
|
|
1257
|
+
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
|
|
1258
|
+
if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} MB`;
|
|
1259
|
+
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
|
1260
|
+
return `${n} B`;
|
|
1261
|
+
}
|
|
1262
|
+
function formatPullLine(tag, s) {
|
|
1263
|
+
if (s.percent === null) return `Pulling ${tag}: ${s.status}`;
|
|
1264
|
+
const speed = s.bytesPerSec ? ` ${formatBytes(s.bytesPerSec)}/s` : "";
|
|
1265
|
+
return `Pulling ${tag}: ${s.percent}% ${formatBytes(s.completedBytes)} / ${formatBytes(s.totalBytes)}${speed}`;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/steps/model.ts
|
|
1269
|
+
var CUSTOM = "__custom__";
|
|
1270
|
+
function isValidTag(tag) {
|
|
1271
|
+
return /^[a-zA-Z0-9._\-/]+(:[a-zA-Z0-9._-]+)?$/.test(tag);
|
|
1272
|
+
}
|
|
1273
|
+
function choiceFor(r) {
|
|
1274
|
+
const flags = [
|
|
1275
|
+
r.recommended ? "recommended" : null,
|
|
1276
|
+
!r.fitsDisk ? "needs more disk" : null,
|
|
1277
|
+
!r.fitsMemory ? "may swap" : null
|
|
1278
|
+
].filter(Boolean);
|
|
1279
|
+
const hint = `~${r.sizeGB} GB \xB7 ${r.reason}${flags.length ? ` \xB7 ${flags.join(", ")}` : ""}`;
|
|
1280
|
+
return { value: r.tag, label: r.recommended ? `${r.tag} \u2605` : r.tag, hint };
|
|
1281
|
+
}
|
|
1282
|
+
async function chooseModel(ctx) {
|
|
1283
|
+
if (ctx.opts.model) {
|
|
1284
|
+
if (!isValidTag(ctx.opts.model))
|
|
1285
|
+
throw new Error(`"${ctx.opts.model}" does not look like an Ollama tag.`);
|
|
1286
|
+
return ctx.opts.model;
|
|
1287
|
+
}
|
|
1288
|
+
const list = ctx.report?.list ?? [];
|
|
1289
|
+
const top = list.find((r) => r.recommended)?.tag ?? "qwen3.5:9b";
|
|
1290
|
+
if (nonInteractive()) return top;
|
|
1291
|
+
const picked = await select2(
|
|
1292
|
+
"Executor model",
|
|
1293
|
+
[
|
|
1294
|
+
...list.map(choiceFor),
|
|
1295
|
+
{ value: CUSTOM, label: "Enter a custom tag\u2026", hint: "any tag from ollama.com/library" }
|
|
1296
|
+
],
|
|
1297
|
+
top
|
|
1298
|
+
);
|
|
1299
|
+
if (picked !== CUSTOM) return picked;
|
|
1300
|
+
for (; ; ) {
|
|
1301
|
+
const tag = await text2("Ollama tag", "e.g. qwen3.5:9b-q8_0", top);
|
|
1302
|
+
if (isValidTag(tag)) return tag;
|
|
1303
|
+
warn(`"${tag}" does not look like a valid tag.`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
async function pullModel(ctx, tag) {
|
|
1307
|
+
const sp = spinner2();
|
|
1308
|
+
const tracker = new PullTracker();
|
|
1309
|
+
let lastPercent = -1;
|
|
1310
|
+
sp.start(`Pulling ${tag}\u2026`);
|
|
1311
|
+
try {
|
|
1312
|
+
await ctx.client.pull(tag, (e) => {
|
|
1313
|
+
const s = tracker.update(e);
|
|
1314
|
+
if (s.percent !== lastPercent || s.percent === null) {
|
|
1315
|
+
lastPercent = s.percent ?? -1;
|
|
1316
|
+
sp.message(formatPullLine(tag, s));
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
sp.stop(`Pulled ${tag}.`);
|
|
1320
|
+
return true;
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
sp.error(`Pull failed: ${err.message}`);
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
async function warmupModel(ctx, tag) {
|
|
1327
|
+
const sp = spinner2();
|
|
1328
|
+
sp.start(`Loading ${tag} and measuring speed (first load can take 10\u201330 s)\u2026`);
|
|
1329
|
+
try {
|
|
1330
|
+
const w = await ctx.client.warmup(tag);
|
|
1331
|
+
ctx.warmup = w;
|
|
1332
|
+
const speed = w.tokensPerSec !== null ? `${w.tokensPerSec} tok/s` : "speed unknown";
|
|
1333
|
+
sp.stop(
|
|
1334
|
+
`${tag} ready: ${pc.bold(speed)} (load ${Math.round(w.loadDurationMs / 100) / 10} s, ${w.evalCount} tokens generated)`
|
|
1335
|
+
);
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
ctx.warmup = null;
|
|
1338
|
+
sp.error(`Warm-up failed: ${err.message}`);
|
|
1339
|
+
ctx.warnings.push(`Warm-up request failed for ${tag}; the model may not have loaded.`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
async function stepModel(ctx) {
|
|
1343
|
+
header(
|
|
1344
|
+
"3/6",
|
|
1345
|
+
"Model",
|
|
1346
|
+
"Pick the executor model, pull it, and measure real tokens/sec on this machine."
|
|
1347
|
+
);
|
|
1348
|
+
const tag = await chooseModel(ctx);
|
|
1349
|
+
ctx.model = tag;
|
|
1350
|
+
const known = findModel(tag);
|
|
1351
|
+
const rec = ctx.report?.list.find((r) => r.tag === tag);
|
|
1352
|
+
if (known) ctx.modelSizeGB = known.sizeGB;
|
|
1353
|
+
for (const w of rec?.warnings ?? []) warn(w);
|
|
1354
|
+
const up = await ctx.client.isUp();
|
|
1355
|
+
if (!up) {
|
|
1356
|
+
warn(
|
|
1357
|
+
`Ollama is not reachable at ${ctx.client.baseUrl}; skipping pull and warm-up. Config will still point at ${tag}.`
|
|
1358
|
+
);
|
|
1359
|
+
ctx.warnings.push(
|
|
1360
|
+
`Model ${tag} was not pulled because Ollama was unreachable. Run: ollama pull ${tag}`
|
|
1361
|
+
);
|
|
1362
|
+
ctx.pulled = false;
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const have = await ctx.client.hasModel(tag).catch(() => false);
|
|
1366
|
+
if (have) {
|
|
1367
|
+
success(`${tag} is already pulled.`);
|
|
1368
|
+
ctx.pulled = true;
|
|
1369
|
+
} else if (ctx.opts.skipPull) {
|
|
1370
|
+
warn(`--skip-pull: not pulling ${tag}. Run: ollama pull ${tag}`);
|
|
1371
|
+
ctx.warnings.push(`Model ${tag} not pulled (--skip-pull).`);
|
|
1372
|
+
ctx.pulled = false;
|
|
1373
|
+
} else {
|
|
1374
|
+
const size = known ? `about ${known.sizeGB} GB` : "size unknown (not in the catalog)";
|
|
1375
|
+
info(`Download: ${size}. Downloads over 100 MB always ask first unless --yes was given.`);
|
|
1376
|
+
const ok = await confirm2(`Pull ${tag} now?`, true);
|
|
1377
|
+
if (!ok) {
|
|
1378
|
+
ctx.warnings.push(`Model ${tag} not pulled. Run: ollama pull ${tag}`);
|
|
1379
|
+
ctx.pulled = false;
|
|
1380
|
+
} else {
|
|
1381
|
+
ctx.pulled = await pullModel(ctx, tag);
|
|
1382
|
+
if (!ctx.pulled)
|
|
1383
|
+
ctx.warnings.push(
|
|
1384
|
+
`Pull of ${tag} failed. Check the tag on ollama.com/library and retry: ollama pull ${tag}`
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (ctx.pulled) await warmupModel(ctx, tag);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// src/steps/ollama.ts
|
|
1392
|
+
import { join as join9 } from "path";
|
|
1393
|
+
|
|
1394
|
+
// src/ollama/install.ts
|
|
1395
|
+
import { join as join8 } from "path";
|
|
1396
|
+
var OLLAMA_DOWNLOAD_URL = "https://ollama.com/download";
|
|
1397
|
+
var OLLAMA_LINUX_SCRIPT = "https://ollama.com/install.sh";
|
|
1398
|
+
function planOllamaInstall(env) {
|
|
1399
|
+
switch (env.platform) {
|
|
1400
|
+
case "darwin":
|
|
1401
|
+
if (env.hasBrew) {
|
|
1402
|
+
return {
|
|
1403
|
+
kind: "command",
|
|
1404
|
+
file: "brew",
|
|
1405
|
+
args: ["install", "ollama"],
|
|
1406
|
+
display: displayCommand("brew", ["install", "ollama"]),
|
|
1407
|
+
explanation: "Installs the Ollama CLI and server with Homebrew into your user prefix. No sudo. Later you can run `brew upgrade ollama`.",
|
|
1408
|
+
privileged: false
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
return {
|
|
1412
|
+
kind: "download",
|
|
1413
|
+
url: OLLAMA_DOWNLOAD_URL,
|
|
1414
|
+
explanation: "Homebrew is not installed, so download the macOS app from ollama.com, drag it to Applications, open it once, then re-run lex."
|
|
1415
|
+
};
|
|
1416
|
+
case "linux":
|
|
1417
|
+
return {
|
|
1418
|
+
kind: "command",
|
|
1419
|
+
file: "sh",
|
|
1420
|
+
args: ["-c", `curl -fsSL ${OLLAMA_LINUX_SCRIPT} | sh`],
|
|
1421
|
+
display: `curl -fsSL ${OLLAMA_LINUX_SCRIPT} | sh`,
|
|
1422
|
+
explanation: "Runs Ollama's official install script. It downloads the binary to /usr/local, creates an `ollama` system user and a systemd service, and installs GPU drivers hooks if it finds NVIDIA/AMD hardware. It will ask for sudo.",
|
|
1423
|
+
privileged: true
|
|
1424
|
+
};
|
|
1425
|
+
case "win32":
|
|
1426
|
+
if (env.hasWinget) {
|
|
1427
|
+
return {
|
|
1428
|
+
kind: "command",
|
|
1429
|
+
file: "winget",
|
|
1430
|
+
args: [
|
|
1431
|
+
"install",
|
|
1432
|
+
"--id",
|
|
1433
|
+
"Ollama.Ollama",
|
|
1434
|
+
"-e",
|
|
1435
|
+
"--accept-source-agreements",
|
|
1436
|
+
"--accept-package-agreements"
|
|
1437
|
+
],
|
|
1438
|
+
display: displayCommand("winget", ["install", "--id", "Ollama.Ollama", "-e"]),
|
|
1439
|
+
explanation: "Installs Ollama for Windows with winget (per-user install, no admin needed). It also installs the tray app that keeps the server running.",
|
|
1440
|
+
privileged: false
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
return {
|
|
1444
|
+
kind: "download",
|
|
1445
|
+
url: OLLAMA_DOWNLOAD_URL,
|
|
1446
|
+
explanation: "winget is not available, so download OllamaSetup.exe from ollama.com, run it, then re-run lex."
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
function planServerStart(platform, opts = {}) {
|
|
1451
|
+
if (platform === "darwin" && opts.macAppPresent) {
|
|
1452
|
+
return { file: "open", args: ["-a", "Ollama"], display: "open -a Ollama", usesApp: true };
|
|
1453
|
+
}
|
|
1454
|
+
if (platform === "win32" && opts.winAppPath) {
|
|
1455
|
+
return { file: opts.winAppPath, args: [], display: `"${opts.winAppPath}"`, usesApp: true };
|
|
1456
|
+
}
|
|
1457
|
+
return { file: "ollama", args: ["serve"], display: "ollama serve (detached)", usesApp: false };
|
|
1458
|
+
}
|
|
1459
|
+
function appCandidates(platform, env) {
|
|
1460
|
+
if (platform === "darwin")
|
|
1461
|
+
return ["/Applications/Ollama.app", join8(env.HOME ?? "", "Applications", "Ollama.app")];
|
|
1462
|
+
if (platform === "win32") {
|
|
1463
|
+
const local = env.LOCALAPPDATA ?? "";
|
|
1464
|
+
return [join8(local, "Programs", "Ollama", "ollama app.exe")];
|
|
1465
|
+
}
|
|
1466
|
+
return [];
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/steps/ollama.ts
|
|
1470
|
+
init_fs();
|
|
1471
|
+
init_state();
|
|
1472
|
+
function platformOf() {
|
|
1473
|
+
return process.platform === "darwin" || process.platform === "win32" ? process.platform : "linux";
|
|
1474
|
+
}
|
|
1475
|
+
async function fetchLatest() {
|
|
1476
|
+
const cachePath = releaseCachePath(cacheDir());
|
|
1477
|
+
return latestOllamaVersion({
|
|
1478
|
+
fetchImpl: (i, o) => fetch(i, o),
|
|
1479
|
+
readCache: () => readJsonOr(cachePath, null),
|
|
1480
|
+
writeCache: (c) => writeJson(cachePath, c),
|
|
1481
|
+
now: () => Date.now()
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
function upgradePlan(plan) {
|
|
1485
|
+
if (plan.kind !== "command") return plan;
|
|
1486
|
+
if (plan.file === "brew") {
|
|
1487
|
+
return {
|
|
1488
|
+
...plan,
|
|
1489
|
+
args: ["upgrade", "ollama"],
|
|
1490
|
+
display: displayCommand("brew", ["upgrade", "ollama"])
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
if (plan.file === "winget") {
|
|
1494
|
+
const args = [
|
|
1495
|
+
"upgrade",
|
|
1496
|
+
"--id",
|
|
1497
|
+
"Ollama.Ollama",
|
|
1498
|
+
"-e",
|
|
1499
|
+
"--accept-source-agreements",
|
|
1500
|
+
"--accept-package-agreements"
|
|
1501
|
+
];
|
|
1502
|
+
return {
|
|
1503
|
+
...plan,
|
|
1504
|
+
args,
|
|
1505
|
+
display: displayCommand("winget", ["upgrade", "--id", "Ollama.Ollama", "-e"])
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
return plan;
|
|
1509
|
+
}
|
|
1510
|
+
async function runPlan(ctx, plan, verb) {
|
|
1511
|
+
if (plan.kind === "download") {
|
|
1512
|
+
warn(plan.explanation);
|
|
1513
|
+
info(`Opening ${plan.url}`);
|
|
1514
|
+
await openUrl(plan.url);
|
|
1515
|
+
ctx.warnings.push(`Ollama must be ${verb}ed manually from ${plan.url}; re-run lex afterwards.`);
|
|
1516
|
+
return false;
|
|
1517
|
+
}
|
|
1518
|
+
note2(`${code(plan.display)}
|
|
1519
|
+
|
|
1520
|
+
${plan.explanation}`, `Command to ${verb} Ollama`);
|
|
1521
|
+
const nonInteractive2 = nonInteractive();
|
|
1522
|
+
if (nonInteractive2 && !ctx.opts.allowInstall) {
|
|
1523
|
+
warn(
|
|
1524
|
+
`Not running ${verb} without confirmation (non-interactive). Pass --allow-install to permit it.`
|
|
1525
|
+
);
|
|
1526
|
+
ctx.warnings.push(`Ollama ${verb} skipped in non-interactive mode. Run: ${plan.display}`);
|
|
1527
|
+
return false;
|
|
1528
|
+
}
|
|
1529
|
+
const ok = await confirm2(
|
|
1530
|
+
`Run this now?${plan.privileged ? " (it will ask for sudo)" : ""}`,
|
|
1531
|
+
true
|
|
1532
|
+
);
|
|
1533
|
+
if (!ok) {
|
|
1534
|
+
ctx.warnings.push(`Ollama ${verb} declined. Run it yourself: ${plan.display}`);
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
const sp = spinner2();
|
|
1538
|
+
if (!ui.tty) sp.start(`Running: ${plan.display}`);
|
|
1539
|
+
const result = await run(plan.file, plan.args, { inherit: true });
|
|
1540
|
+
if (!result.ok) {
|
|
1541
|
+
error(`${plan.display} exited with code ${result.exitCode ?? "?"}.`);
|
|
1542
|
+
ctx.warnings.push(`Ollama ${verb} command failed (exit ${result.exitCode ?? "?"}).`);
|
|
1543
|
+
return false;
|
|
1544
|
+
}
|
|
1545
|
+
success(`Ollama ${verb} command finished.`);
|
|
1546
|
+
return true;
|
|
1547
|
+
}
|
|
1548
|
+
async function startServer(ctx) {
|
|
1549
|
+
const platform = platformOf();
|
|
1550
|
+
const env = process.env;
|
|
1551
|
+
const candidates = appCandidates(platform, env);
|
|
1552
|
+
let appPath = null;
|
|
1553
|
+
for (const c of candidates) if (await exists(c)) appPath = c;
|
|
1554
|
+
const plan = planServerStart(platform, {
|
|
1555
|
+
macAppPresent: platform === "darwin" && appPath !== null,
|
|
1556
|
+
winAppPath: platform === "win32" ? appPath : null
|
|
1557
|
+
});
|
|
1558
|
+
info(`Starting the server: ${code(plan.display)}`);
|
|
1559
|
+
if (plan.usesApp && platform === "darwin") await run(plan.file, plan.args);
|
|
1560
|
+
else spawnDetached(plan.file, plan.args);
|
|
1561
|
+
const sp = spinner2();
|
|
1562
|
+
sp.start("Waiting for Ollama to answer on the health endpoint\u2026");
|
|
1563
|
+
const up = await ctx.client.waitUntilUp(3e4);
|
|
1564
|
+
if (up) sp.stop("Ollama is running.");
|
|
1565
|
+
else sp.error("Ollama did not respond within 30 s.");
|
|
1566
|
+
return up;
|
|
1567
|
+
}
|
|
1568
|
+
async function stepOllama(ctx) {
|
|
1569
|
+
header(
|
|
1570
|
+
"1/6",
|
|
1571
|
+
"Ollama",
|
|
1572
|
+
"The local server that runs the executor model. Detect, install, start, and check the version."
|
|
1573
|
+
);
|
|
1574
|
+
if (ctx.opts.skipOllama) {
|
|
1575
|
+
ctx.ollama.skipped = true;
|
|
1576
|
+
warn(
|
|
1577
|
+
"Skipping Ollama setup (--skip-ollama). Later steps will re-check the server and warn if it is down."
|
|
1578
|
+
);
|
|
1579
|
+
} else if (!nonInteractive()) {
|
|
1580
|
+
const choice = await select2(
|
|
1581
|
+
"Ollama setup",
|
|
1582
|
+
[
|
|
1583
|
+
{ value: "run", label: "Detect and set up Ollama for me", hint: "recommended" },
|
|
1584
|
+
{ value: "skip", label: "Skip \u2014 I manage Ollama myself" }
|
|
1585
|
+
],
|
|
1586
|
+
"run"
|
|
1587
|
+
);
|
|
1588
|
+
ctx.ollama.skipped = choice === "skip";
|
|
1589
|
+
}
|
|
1590
|
+
ctx.ollama.binary = await which("ollama");
|
|
1591
|
+
ctx.ollama.version = await ctx.client.version();
|
|
1592
|
+
if (ctx.ollama.skipped) {
|
|
1593
|
+
if (ctx.ollama.version)
|
|
1594
|
+
success(`Ollama ${ctx.ollama.version} is reachable at ${ctx.client.baseUrl}.`);
|
|
1595
|
+
else
|
|
1596
|
+
warn(
|
|
1597
|
+
`Ollama is not reachable at ${ctx.client.baseUrl}. Model pull and verification will be skipped.`
|
|
1598
|
+
);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const platform = platformOf();
|
|
1602
|
+
const env = {
|
|
1603
|
+
platform,
|
|
1604
|
+
hasBrew: await which("brew") !== null,
|
|
1605
|
+
hasWinget: await which("winget") !== null
|
|
1606
|
+
};
|
|
1607
|
+
if (!ctx.ollama.binary && !ctx.ollama.version) {
|
|
1608
|
+
warn("Ollama is not installed (not on PATH, server not responding).");
|
|
1609
|
+
const installed = await runPlan(ctx, planOllamaInstall(env), "install");
|
|
1610
|
+
if (!installed) return;
|
|
1611
|
+
ctx.ollama.binary = await which("ollama");
|
|
1612
|
+
ctx.ollama.version = await ctx.client.version();
|
|
1613
|
+
} else if (ctx.ollama.binary && !ctx.ollama.version) {
|
|
1614
|
+
info(
|
|
1615
|
+
`Ollama binary found at ${ctx.ollama.binary}, but the server is not responding at ${ctx.client.baseUrl}.`
|
|
1616
|
+
);
|
|
1617
|
+
} else {
|
|
1618
|
+
success(`Ollama ${ctx.ollama.version} is running at ${ctx.client.baseUrl}.`);
|
|
1619
|
+
}
|
|
1620
|
+
if (!ctx.ollama.version) {
|
|
1621
|
+
const up = await startServer(ctx);
|
|
1622
|
+
if (!up) {
|
|
1623
|
+
ctx.warnings.push(
|
|
1624
|
+
"Ollama server could not be started. Start it manually (open the Ollama app or run `ollama serve`)."
|
|
1625
|
+
);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
ctx.ollama.version = await ctx.client.version();
|
|
1629
|
+
}
|
|
1630
|
+
ctx.ollama.latest = await fetchLatest();
|
|
1631
|
+
if (ctx.ollama.latest && ctx.ollama.version) {
|
|
1632
|
+
if (isOutdated(ctx.ollama.version, ctx.ollama.latest)) {
|
|
1633
|
+
warn(`Ollama ${ctx.ollama.version} is installed; latest is ${ctx.ollama.latest}.`);
|
|
1634
|
+
if (nonInteractive()) {
|
|
1635
|
+
ctx.warnings.push(
|
|
1636
|
+
`Ollama is outdated (${ctx.ollama.version} < ${ctx.ollama.latest}). Upgrade when convenient.`
|
|
1637
|
+
);
|
|
1638
|
+
} else {
|
|
1639
|
+
const want = await confirm2("Upgrade Ollama now?", false);
|
|
1640
|
+
if (want) {
|
|
1641
|
+
const done = await runPlan(ctx, upgradePlan(planOllamaInstall(env)), "upgrade");
|
|
1642
|
+
if (done) info("Restart the Ollama app/server to pick up the new version.");
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
} else {
|
|
1646
|
+
info(`Ollama is up to date (latest release: ${ctx.ollama.latest}).`);
|
|
1647
|
+
}
|
|
1648
|
+
} else if (!ctx.ollama.latest) {
|
|
1649
|
+
info(
|
|
1650
|
+
"Could not check the latest Ollama release (offline or rate-limited); skipping the version check."
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
if (!process.env.OLLAMA_MODELS) {
|
|
1654
|
+
const home2 = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
1655
|
+
const dir = join9(home2, ".ollama", "models");
|
|
1656
|
+
if (!await exists(dir)) info(`Model store will be created at ${dir} on first pull.`);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// src/agents/install.ts
|
|
1661
|
+
init_fs();
|
|
1662
|
+
import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
|
|
1663
|
+
import { basename, dirname as dirname2, join as join11 } from "path";
|
|
1664
|
+
|
|
1665
|
+
// src/util/markers.ts
|
|
1666
|
+
var START_MARKER = "<!-- lex:start -->";
|
|
1667
|
+
var END_MARKER = "<!-- lex:end -->";
|
|
1668
|
+
var defaultMarkers = { start: START_MARKER, end: END_MARKER };
|
|
1669
|
+
function escapeRegExp(s) {
|
|
1670
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1671
|
+
}
|
|
1672
|
+
function blockRegex(m) {
|
|
1673
|
+
return new RegExp(`${escapeRegExp(m.start)}[\\s\\S]*?${escapeRegExp(m.end)}\\n?`, "g");
|
|
1674
|
+
}
|
|
1675
|
+
function hasBlock(content, m = defaultMarkers) {
|
|
1676
|
+
return content.includes(m.start) && content.includes(m.end);
|
|
1677
|
+
}
|
|
1678
|
+
function wrapBlock(body, m = defaultMarkers) {
|
|
1679
|
+
const trimmed = body.replace(/\s+$/, "");
|
|
1680
|
+
return `${m.start}
|
|
1681
|
+
${trimmed}
|
|
1682
|
+
${m.end}
|
|
1683
|
+
`;
|
|
1684
|
+
}
|
|
1685
|
+
function upsertBlock(content, body, m = defaultMarkers) {
|
|
1686
|
+
const block = wrapBlock(body, m);
|
|
1687
|
+
if (hasBlock(content, m)) {
|
|
1688
|
+
return content.replace(blockRegex(m), block);
|
|
1689
|
+
}
|
|
1690
|
+
if (content.length === 0) return block;
|
|
1691
|
+
const sep3 = content.endsWith("\n\n") ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
1692
|
+
return `${content}${sep3}${block}`;
|
|
1693
|
+
}
|
|
1694
|
+
function removeBlock(content, m = defaultMarkers) {
|
|
1695
|
+
if (!hasBlock(content, m)) return content;
|
|
1696
|
+
const re = new RegExp(`\\n*${escapeRegExp(m.start)}[\\s\\S]*?${escapeRegExp(m.end)}\\n?`, "g");
|
|
1697
|
+
let sawContentBefore = false;
|
|
1698
|
+
const out = content.replace(re, (_whole, offset) => {
|
|
1699
|
+
sawContentBefore = content.slice(0, offset).trim().length > 0;
|
|
1700
|
+
return sawContentBefore ? "\n" : "";
|
|
1701
|
+
});
|
|
1702
|
+
return out;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// src/agents/paths.ts
|
|
1706
|
+
import { join as join10 } from "path";
|
|
1707
|
+
var SKILL_DIR_NAME = "local-executor-pipeline";
|
|
1708
|
+
function userTarget(agent, ctx) {
|
|
1709
|
+
const h = ctx.home;
|
|
1710
|
+
switch (agent) {
|
|
1711
|
+
case "claude": {
|
|
1712
|
+
const root = join10(h, ".claude", "skills", SKILL_DIR_NAME);
|
|
1713
|
+
return {
|
|
1714
|
+
agent,
|
|
1715
|
+
scope: "user",
|
|
1716
|
+
root,
|
|
1717
|
+
adapters: [{ source: "claude-code/SKILL.md", dest: join10(root, "SKILL.md") }],
|
|
1718
|
+
marked: [],
|
|
1719
|
+
optional: [],
|
|
1720
|
+
summary: "Claude Code user-level skill"
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
case "codex": {
|
|
1724
|
+
const root = join10(h, ".codex", "local-executor");
|
|
1725
|
+
return {
|
|
1726
|
+
agent,
|
|
1727
|
+
scope: "user",
|
|
1728
|
+
root,
|
|
1729
|
+
adapters: [],
|
|
1730
|
+
marked: [{ source: "codex/AGENTS.block.md", dest: join10(h, ".codex", "AGENTS.md") }],
|
|
1731
|
+
optional: [
|
|
1732
|
+
{
|
|
1733
|
+
ifDirExists: join10(h, ".codex", "skills"),
|
|
1734
|
+
adapter: {
|
|
1735
|
+
source: "codex/SKILL.md",
|
|
1736
|
+
dest: join10(h, ".codex", "skills", SKILL_DIR_NAME, "SKILL.md")
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
],
|
|
1740
|
+
summary: "Codex CLI global instructions block + scripts"
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
case "cursor": {
|
|
1744
|
+
const root = join10(h, ".cursor", "local-executor");
|
|
1745
|
+
return {
|
|
1746
|
+
agent,
|
|
1747
|
+
scope: "user",
|
|
1748
|
+
root,
|
|
1749
|
+
adapters: [],
|
|
1750
|
+
marked: [],
|
|
1751
|
+
optional: [],
|
|
1752
|
+
summary: "Cursor global scripts copy (rules are per project)"
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
case "windsurf": {
|
|
1756
|
+
const root = join10(h, ".codeium", "windsurf", "local-executor");
|
|
1757
|
+
return {
|
|
1758
|
+
agent,
|
|
1759
|
+
scope: "user",
|
|
1760
|
+
root,
|
|
1761
|
+
adapters: [],
|
|
1762
|
+
marked: [],
|
|
1763
|
+
optional: [],
|
|
1764
|
+
summary: "Windsurf global scripts copy (rules are per project)"
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
function projectTarget(agent, ctx) {
|
|
1770
|
+
if (!ctx.projectRoot) return null;
|
|
1771
|
+
const p2 = ctx.projectRoot;
|
|
1772
|
+
const user = userTarget(agent, ctx);
|
|
1773
|
+
switch (agent) {
|
|
1774
|
+
case "claude": {
|
|
1775
|
+
const root = join10(p2, ".claude", "skills", SKILL_DIR_NAME);
|
|
1776
|
+
return {
|
|
1777
|
+
agent,
|
|
1778
|
+
scope: "project",
|
|
1779
|
+
root,
|
|
1780
|
+
adapters: [{ source: "claude-code/SKILL.md", dest: join10(root, "SKILL.md") }],
|
|
1781
|
+
marked: [],
|
|
1782
|
+
optional: [],
|
|
1783
|
+
summary: "Claude Code project-level skill"
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
case "codex":
|
|
1787
|
+
return {
|
|
1788
|
+
agent,
|
|
1789
|
+
scope: "project",
|
|
1790
|
+
root: user.root,
|
|
1791
|
+
adapters: [],
|
|
1792
|
+
marked: [{ source: "codex/AGENTS.block.md", dest: join10(p2, "AGENTS.md") }],
|
|
1793
|
+
optional: [],
|
|
1794
|
+
summary: "Codex block in the project's AGENTS.md"
|
|
1795
|
+
};
|
|
1796
|
+
case "cursor":
|
|
1797
|
+
return {
|
|
1798
|
+
agent,
|
|
1799
|
+
scope: "project",
|
|
1800
|
+
root: user.root,
|
|
1801
|
+
adapters: [
|
|
1802
|
+
{
|
|
1803
|
+
source: "cursor/local-executor.mdc",
|
|
1804
|
+
dest: join10(p2, ".cursor", "rules", "local-executor.mdc")
|
|
1805
|
+
}
|
|
1806
|
+
],
|
|
1807
|
+
marked: [],
|
|
1808
|
+
optional: [],
|
|
1809
|
+
summary: "Cursor project rule"
|
|
1810
|
+
};
|
|
1811
|
+
case "windsurf":
|
|
1812
|
+
return {
|
|
1813
|
+
agent,
|
|
1814
|
+
scope: "project",
|
|
1815
|
+
root: user.root,
|
|
1816
|
+
adapters: [
|
|
1817
|
+
{
|
|
1818
|
+
source: "windsurf/local-executor.md",
|
|
1819
|
+
dest: join10(p2, ".windsurf", "rules", "local-executor.md")
|
|
1820
|
+
}
|
|
1821
|
+
],
|
|
1822
|
+
marked: [],
|
|
1823
|
+
optional: [],
|
|
1824
|
+
summary: "Windsurf project rule"
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
function resolveTargets(agents, ctx, opts) {
|
|
1829
|
+
const out = [];
|
|
1830
|
+
for (const a of agents) {
|
|
1831
|
+
out.push(userTarget(a, ctx));
|
|
1832
|
+
if (opts.project) {
|
|
1833
|
+
const pt = projectTarget(a, ctx);
|
|
1834
|
+
if (pt) out.push(pt);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return out;
|
|
1838
|
+
}
|
|
1839
|
+
function configPathFor(root) {
|
|
1840
|
+
return join10(root, "runtime", "config.json");
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// src/agents/install.ts
|
|
1844
|
+
function templateVars(root, model, version) {
|
|
1845
|
+
const r = toPosix(root);
|
|
1846
|
+
return {
|
|
1847
|
+
LEX_ROOT: r,
|
|
1848
|
+
LEX_CORE: `${r}/core`,
|
|
1849
|
+
LEX_RUNTIME: `${r}/runtime`,
|
|
1850
|
+
LEX_CONFIG: `${r}/runtime/config.json`,
|
|
1851
|
+
LEX_MODEL: model,
|
|
1852
|
+
LEX_VERSION: version
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
function renderTemplate(text3, vars) {
|
|
1856
|
+
return text3.replace(
|
|
1857
|
+
/\{\{(LEX_[A-Z_]+)\}\}/g,
|
|
1858
|
+
(whole, key) => key in vars ? vars[key] : whole
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
var defaultRuntimeConfig = {
|
|
1862
|
+
ollama_url: "http://localhost:11434",
|
|
1863
|
+
model: "qwen3.5:9b",
|
|
1864
|
+
fallback_model: "qwen3.5:4b",
|
|
1865
|
+
num_ctx: 16384,
|
|
1866
|
+
temperature: 0.1,
|
|
1867
|
+
keep_alive: "30m",
|
|
1868
|
+
timeout_seconds: 600,
|
|
1869
|
+
think: false
|
|
1870
|
+
};
|
|
1871
|
+
async function copyCore(src, dest, vars) {
|
|
1872
|
+
await ensureDir(dest);
|
|
1873
|
+
for (const name of await readdir2(src)) {
|
|
1874
|
+
const text3 = await readFile2(join11(src, name), "utf8");
|
|
1875
|
+
await writeText(join11(dest, name), name.endsWith(".md") ? renderTemplate(text3, vars) : text3);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
async function installTarget(input) {
|
|
1879
|
+
const { target, skillSource, model, version } = input;
|
|
1880
|
+
const actions = [];
|
|
1881
|
+
const vars = templateVars(target.root, model, version);
|
|
1882
|
+
const ownsRoot = target.scope === "user" || target.agent === "claude";
|
|
1883
|
+
const owned = [];
|
|
1884
|
+
const marked = [];
|
|
1885
|
+
if (ownsRoot) {
|
|
1886
|
+
const existed = await exists(target.root);
|
|
1887
|
+
await ensureDir(target.root);
|
|
1888
|
+
await copyCore(join11(skillSource, "core"), join11(target.root, "core"), vars);
|
|
1889
|
+
await writeText(join11(target.root, "core", "models.md"), input.modelsDoc);
|
|
1890
|
+
const runtimeDest = join11(target.root, "runtime");
|
|
1891
|
+
const configPath = configPathFor(target.root);
|
|
1892
|
+
const existing = await readJsonOr(configPath, {});
|
|
1893
|
+
await ensureDir(runtimeDest);
|
|
1894
|
+
await copyDir(join11(skillSource, "runtime"), runtimeDest);
|
|
1895
|
+
const shipped = await readJsonOr(
|
|
1896
|
+
join11(skillSource, "runtime", "config.json"),
|
|
1897
|
+
{}
|
|
1898
|
+
);
|
|
1899
|
+
const merged = {
|
|
1900
|
+
...defaultRuntimeConfig,
|
|
1901
|
+
...shipped,
|
|
1902
|
+
...existing,
|
|
1903
|
+
model,
|
|
1904
|
+
ollama_url: input.ollamaUrl
|
|
1905
|
+
};
|
|
1906
|
+
await writeJson(configPath, merged);
|
|
1907
|
+
owned.push(target.root);
|
|
1908
|
+
actions.push(`${existed ? "Updated" : "Created"} ${target.root}`);
|
|
1909
|
+
}
|
|
1910
|
+
for (const a of target.adapters) {
|
|
1911
|
+
const text3 = await readFile2(join11(skillSource, "adapters", a.source), "utf8");
|
|
1912
|
+
await writeText(a.dest, renderTemplate(text3, vars));
|
|
1913
|
+
if (!ownsRoot || !a.dest.startsWith(target.root)) owned.push(a.dest);
|
|
1914
|
+
actions.push(`Wrote ${a.dest}`);
|
|
1915
|
+
}
|
|
1916
|
+
for (const o of target.optional) {
|
|
1917
|
+
if (!await isDirectory(o.ifDirExists)) continue;
|
|
1918
|
+
const text3 = await readFile2(join11(skillSource, "adapters", o.adapter.source), "utf8");
|
|
1919
|
+
await writeText(o.adapter.dest, renderTemplate(text3, vars));
|
|
1920
|
+
owned.push(
|
|
1921
|
+
dirname2(o.adapter.dest) === o.ifDirExists ? o.adapter.dest : dirname2(o.adapter.dest)
|
|
1922
|
+
);
|
|
1923
|
+
actions.push(`Wrote ${o.adapter.dest}`);
|
|
1924
|
+
}
|
|
1925
|
+
for (const m of target.marked) {
|
|
1926
|
+
const body = renderTemplate(
|
|
1927
|
+
await readFile2(join11(skillSource, "adapters", m.source), "utf8"),
|
|
1928
|
+
vars
|
|
1929
|
+
);
|
|
1930
|
+
const current = await readTextOr(m.dest, "");
|
|
1931
|
+
const next = upsertBlock(current, body);
|
|
1932
|
+
if (next !== current) await writeText(m.dest, next);
|
|
1933
|
+
marked.push(m.dest);
|
|
1934
|
+
actions.push(
|
|
1935
|
+
`${current.includes("<!-- lex:start -->") ? "Refreshed" : "Appended"} lex block in ${m.dest}`
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
const record = {
|
|
1939
|
+
agent: target.agent,
|
|
1940
|
+
scope: target.scope,
|
|
1941
|
+
root: target.root,
|
|
1942
|
+
configPath: configPathFor(target.root),
|
|
1943
|
+
owned,
|
|
1944
|
+
marked,
|
|
1945
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1946
|
+
version
|
|
1947
|
+
};
|
|
1948
|
+
return { record, actions };
|
|
1949
|
+
}
|
|
1950
|
+
async function uninstallRecord(rec) {
|
|
1951
|
+
const actions = [];
|
|
1952
|
+
for (const p2 of rec.owned) {
|
|
1953
|
+
if (await exists(p2)) {
|
|
1954
|
+
await removePath(p2);
|
|
1955
|
+
actions.push(`Removed ${p2}`);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
for (const f of rec.marked) {
|
|
1959
|
+
const current = await readTextOr(f, "");
|
|
1960
|
+
if (!current) continue;
|
|
1961
|
+
const next = removeBlock(current);
|
|
1962
|
+
if (next === current) continue;
|
|
1963
|
+
if (next.trim().length === 0 && basename(f) === "AGENTS.md") {
|
|
1964
|
+
await writeText(f, "");
|
|
1965
|
+
} else {
|
|
1966
|
+
await writeText(f, next);
|
|
1967
|
+
}
|
|
1968
|
+
actions.push(`Removed lex block from ${f}`);
|
|
1969
|
+
}
|
|
1970
|
+
return actions;
|
|
1971
|
+
}
|
|
1972
|
+
async function updateConfigModel(configPath, model) {
|
|
1973
|
+
if (!await exists(configPath)) return false;
|
|
1974
|
+
const cfg = await readJsonOr(configPath, {});
|
|
1975
|
+
await writeJson(configPath, { ...defaultRuntimeConfig, ...cfg, model });
|
|
1976
|
+
return true;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// src/models/doc.ts
|
|
1980
|
+
function gb2(n) {
|
|
1981
|
+
return `${Math.round(n * 10) / 10} GB`;
|
|
1982
|
+
}
|
|
1983
|
+
function renderModelsDoc(hw, report, chosen) {
|
|
1984
|
+
const top = report.list[0];
|
|
1985
|
+
const tierRows = tiers.map((t) => {
|
|
1986
|
+
const mark = t === report.tier ? " \u2190 this machine" : "";
|
|
1987
|
+
const alts = t.alsoOffer.map((a) => `\`${a}\``).join(", ");
|
|
1988
|
+
return `| ${t.label}${mark} | \`${t.recommended}\` | ${alts} | ${t.reason} |`;
|
|
1989
|
+
}).join("\n");
|
|
1990
|
+
const catalogRows = catalog.map(
|
|
1991
|
+
(m) => `| \`${m.tag}\` | ${m.params} | ~${m.sizeGB} GB | ${m.contextK}K | ${m.notes}${m.appleOnly ? " (Apple Silicon only)" : ""} |`
|
|
1992
|
+
).join("\n");
|
|
1993
|
+
const adjustments = report.adjustments.length > 0 ? `
|
|
1994
|
+
**Adjustments applied on this machine:**
|
|
1995
|
+
${report.adjustments.map((a) => `- ${a}`).join("\n")}
|
|
1996
|
+
` : "";
|
|
1997
|
+
const gpu = hw.gpu.kind === "apple" ? "Apple Silicon (unified memory)" : hw.gpu.kind === "none" ? "none detected" : `${hw.gpu.model}${hw.gpu.vramGB ? ` (${gb2(hw.gpu.vramGB)} VRAM)` : ""}`;
|
|
1998
|
+
return `# Choosing the executor model
|
|
1999
|
+
|
|
2000
|
+
Generated by \`local-executor\` for this machine on ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}. Catalog last verified against ollama.com on ${lastVerified}.
|
|
2001
|
+
|
|
2002
|
+
## This machine
|
|
2003
|
+
|
|
2004
|
+
| | |
|
|
2005
|
+
|---|---|
|
|
2006
|
+
| OS / arch | ${hw.platform} / ${hw.arch} |
|
|
2007
|
+
| CPU | ${hw.cpuModel} (${hw.physicalCores} physical / ${hw.logicalCores} logical cores) |
|
|
2008
|
+
| RAM | ${gb2(hw.totalRamGB)} total, ${gb2(hw.freeRamGB)} free at install time |
|
|
2009
|
+
| GPU | ${gpu} |
|
|
2010
|
+
| Effective memory for models | **${gb2(report.effectiveMemory.gb)}** (${report.effectiveMemory.rule}) |
|
|
2011
|
+
| Free disk near the model store | ${gb2(hw.freeDiskGB)} (${hw.modelsDir}) |
|
|
2012
|
+
| Configured executor | **\`${chosen}\`** |
|
|
2013
|
+
| Top recommendation | \`${top?.tag ?? "n/a"}\` \u2014 ${top?.reason ?? ""} |
|
|
2014
|
+
${adjustments}
|
|
2015
|
+
## The rule
|
|
2016
|
+
|
|
2017
|
+
The model file plus its context cache must fit in memory alongside the OS, the agent, and an editor. On Apple Silicon roughly 70% of unified RAM is usable for the model; on a PC with a discrete NVIDIA/AMD GPU, count VRAM; on CPU-only x64, count about half of RAM. A model that doesn't fit swaps to disk and drops to 1\u20132 tokens/sec \u2014 worse than useless.
|
|
2018
|
+
|
|
2019
|
+
Mixture-of-Experts models (e.g. Gemma 4 26B, "4B active") are fast to compute but still need the **whole** model in memory. Active-parameter count is not memory footprint. That is why a 26B MoE does not fit on a 16 GB Mac even though only 4B parameters run per token.
|
|
2020
|
+
|
|
2021
|
+
## Tiers
|
|
2022
|
+
|
|
2023
|
+
| Effective memory | Recommended | Alternatives | Notes |
|
|
2024
|
+
|---|---|---|---|
|
|
2025
|
+
${tierRows}
|
|
2026
|
+
|
|
2027
|
+
Below 4 physical cores on a CPU-only machine, step down until the recommended model gets smaller. If the recommended model needs more than the free disk \xD7 1.5, step down and free space.
|
|
2028
|
+
|
|
2029
|
+
## Catalog (sizes approximate)
|
|
2030
|
+
|
|
2031
|
+
| Tag | Parameters | Download | Context | Notes |
|
|
2032
|
+
|---|---|---|---|---|
|
|
2033
|
+
${catalogRows}
|
|
2034
|
+
|
|
2035
|
+
Verify tag names on ollama.com before pulling something not in this list \u2014 tags change between releases.
|
|
2036
|
+
|
|
2037
|
+
## Cold start
|
|
2038
|
+
|
|
2039
|
+
The first call after the model unloads takes 10\u201330 s while it loads. The runtime passes \`keep_alive\` (default 30m, in \`config.json\`) so the model stays resident between packets. Set \`OLLAMA_KEEP_ALIVE=-1\` in the environment to keep it loaded permanently while working.
|
|
2040
|
+
|
|
2041
|
+
## Context window
|
|
2042
|
+
|
|
2043
|
+
\`num_ctx\` in \`config.json\` is 16384. Larger contexts cost memory (the KV cache grows linearly). If the model starts swapping, drop \`num_ctx\` to 8192 before stepping down a model size \u2014 most packets fit in 8k.
|
|
2044
|
+
|
|
2045
|
+
## Changing the model
|
|
2046
|
+
|
|
2047
|
+
Run \`lex switch <tag>\`. It pulls the tag if needed and updates every installed \`config.json\`. Editing \`config.json\` by hand and running \`ollama pull <tag>\` also works.
|
|
2048
|
+
`;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// src/steps/skills.ts
|
|
2052
|
+
init_fs();
|
|
2053
|
+
|
|
2054
|
+
// src/util/pkg.ts
|
|
2055
|
+
import { readFileSync } from "fs";
|
|
2056
|
+
import { dirname as dirname3, join as join12 } from "path";
|
|
2057
|
+
import { fileURLToPath } from "url";
|
|
2058
|
+
var cached = null;
|
|
2059
|
+
function packageRoot() {
|
|
2060
|
+
return locate().root;
|
|
2061
|
+
}
|
|
2062
|
+
function packageVersion() {
|
|
2063
|
+
return locate().pkg.version;
|
|
2064
|
+
}
|
|
2065
|
+
function skillSourceDir() {
|
|
2066
|
+
return join12(packageRoot(), "skill");
|
|
2067
|
+
}
|
|
2068
|
+
function locate() {
|
|
2069
|
+
if (cached) return cached;
|
|
2070
|
+
let dir = dirname3(fileURLToPath(import.meta.url));
|
|
2071
|
+
for (let i = 0; i < 6; i++) {
|
|
2072
|
+
try {
|
|
2073
|
+
const pkg = JSON.parse(readFileSync(join12(dir, "package.json"), "utf8"));
|
|
2074
|
+
if (pkg.name === "local-executor") {
|
|
2075
|
+
cached = { root: dir, pkg };
|
|
2076
|
+
return cached;
|
|
2077
|
+
}
|
|
2078
|
+
} catch {
|
|
2079
|
+
}
|
|
2080
|
+
const parent = dirname3(dir);
|
|
2081
|
+
if (parent === dir) break;
|
|
2082
|
+
dir = parent;
|
|
2083
|
+
}
|
|
2084
|
+
throw new Error("local-executor: cannot locate package root (package.json not found)");
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// src/steps/skills.ts
|
|
2088
|
+
init_state();
|
|
2089
|
+
async function stepSkills(ctx) {
|
|
2090
|
+
header(
|
|
2091
|
+
"5/6",
|
|
2092
|
+
"Skill install",
|
|
2093
|
+
"One canonical pipeline body plus a thin adapter per agent, with paths rewritten for each location."
|
|
2094
|
+
);
|
|
2095
|
+
if (ctx.agents.length === 0) {
|
|
2096
|
+
info("Nothing to install.");
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (!ctx.hw || !ctx.report || !ctx.model)
|
|
2100
|
+
throw new Error("internal: hardware and model steps must run first");
|
|
2101
|
+
const targets = resolveTargets(
|
|
2102
|
+
ctx.agents,
|
|
2103
|
+
{ home: home(), projectRoot: ctx.projectRoot },
|
|
2104
|
+
{ project: ctx.installProject }
|
|
2105
|
+
);
|
|
2106
|
+
const modelsDoc = renderModelsDoc(ctx.hw, ctx.report, ctx.model);
|
|
2107
|
+
const version = packageVersion();
|
|
2108
|
+
let manifest = await readManifest();
|
|
2109
|
+
for (const target of targets) {
|
|
2110
|
+
const outcome = await installTarget({
|
|
2111
|
+
target,
|
|
2112
|
+
skillSource: skillSourceDir(),
|
|
2113
|
+
model: ctx.model,
|
|
2114
|
+
ollamaUrl: ctx.client.baseUrl,
|
|
2115
|
+
version,
|
|
2116
|
+
modelsDoc
|
|
2117
|
+
});
|
|
2118
|
+
ctx.installs.push(outcome);
|
|
2119
|
+
manifest = upsertInstall(manifest, outcome.record);
|
|
2120
|
+
success(`${target.summary}`);
|
|
2121
|
+
for (const a of outcome.actions) message(pc.dim(` ${a.replace(home(), "~")}`));
|
|
2122
|
+
}
|
|
2123
|
+
manifest.model = ctx.model;
|
|
2124
|
+
await writeManifest(manifest);
|
|
2125
|
+
info(`Install manifest: ${contractTilde((await Promise.resolve().then(() => (init_state(), state_exports))).manifestPath())}`);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// src/steps/verify.ts
|
|
2129
|
+
import { mkdtemp, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
2130
|
+
import { tmpdir } from "os";
|
|
2131
|
+
import { join as join13 } from "path";
|
|
2132
|
+
init_fs();
|
|
2133
|
+
var VERIFY_TEST = `import { test } from "node:test";
|
|
2134
|
+
import assert from "node:assert/strict";
|
|
2135
|
+
import { fortyTwo } from "./answer.mjs";
|
|
2136
|
+
|
|
2137
|
+
test("fortyTwo returns 42", () => {
|
|
2138
|
+
assert.equal(fortyTwo(), 42);
|
|
2139
|
+
});
|
|
2140
|
+
`;
|
|
2141
|
+
var VERIFY_PACKET = `# Task: Return 42
|
|
2142
|
+
|
|
2143
|
+
## Goal (required)
|
|
2144
|
+
Create \`answer.mjs\` exporting a function \`fortyTwo\` that returns the number 42.
|
|
2145
|
+
|
|
2146
|
+
## Files you may change (required)
|
|
2147
|
+
- answer.mjs \u2014 create this file
|
|
2148
|
+
|
|
2149
|
+
You may NOT change any other file.
|
|
2150
|
+
|
|
2151
|
+
## Conventions (required)
|
|
2152
|
+
- Language/version: JavaScript, Node.js 20, ESM
|
|
2153
|
+
- Style: named export, no console output, no comments needed
|
|
2154
|
+
- Allowed dependencies: none
|
|
2155
|
+
- Modern practices:
|
|
2156
|
+
- ESM \`export\`, never CommonJS
|
|
2157
|
+
- \`const\` and arrow functions or plain \`function\`; no \`var\`
|
|
2158
|
+
|
|
2159
|
+
## Existing code
|
|
2160
|
+
answer.mjs does not exist yet.
|
|
2161
|
+
|
|
2162
|
+
## Tests that must pass (required)
|
|
2163
|
+
Command: \`node --test answer.test.mjs\`
|
|
2164
|
+
|
|
2165
|
+
### answer.test.mjs
|
|
2166
|
+
\`\`\`js
|
|
2167
|
+
${VERIFY_TEST}\`\`\`
|
|
2168
|
+
|
|
2169
|
+
## Do NOT
|
|
2170
|
+
- Do not modify answer.test.mjs
|
|
2171
|
+
- Do not create any file other than answer.mjs
|
|
2172
|
+
`;
|
|
2173
|
+
async function checkLocal(root) {
|
|
2174
|
+
const script = join13(root, "runtime", "check_local.mjs");
|
|
2175
|
+
if (!await exists(script)) return { check: "ERROR", checkMessage: `missing ${script}` };
|
|
2176
|
+
try {
|
|
2177
|
+
const r = await run(process.execPath, [script, "--json"], { timeoutMs: 15e3 });
|
|
2178
|
+
const parsed = JSON.parse(r.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
2179
|
+
const status = parsed.status;
|
|
2180
|
+
return { check: status ?? "ERROR", checkMessage: parsed.message ?? r.stderr };
|
|
2181
|
+
} catch (err) {
|
|
2182
|
+
return { check: "ERROR", checkMessage: err.message };
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
async function runVerifyPacket(root) {
|
|
2186
|
+
const work = await mkdtemp(join13(tmpdir(), "lex-verify-"));
|
|
2187
|
+
try {
|
|
2188
|
+
await writeFile2(join13(work, "answer.test.mjs"), VERIFY_TEST);
|
|
2189
|
+
await writeFile2(join13(work, "packet.md"), VERIFY_PACKET);
|
|
2190
|
+
const exec = await run(
|
|
2191
|
+
process.execPath,
|
|
2192
|
+
[
|
|
2193
|
+
join13(root, "runtime", "run_executor.mjs"),
|
|
2194
|
+
"--packet",
|
|
2195
|
+
join13(work, "packet.md"),
|
|
2196
|
+
"--out",
|
|
2197
|
+
join13(work, "response.md"),
|
|
2198
|
+
"--apply",
|
|
2199
|
+
"--root",
|
|
2200
|
+
work,
|
|
2201
|
+
"--json"
|
|
2202
|
+
],
|
|
2203
|
+
{ timeoutMs: 6e5 }
|
|
2204
|
+
);
|
|
2205
|
+
let tps = null;
|
|
2206
|
+
try {
|
|
2207
|
+
const j = JSON.parse(exec.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
2208
|
+
tps = j.tokensPerSec ?? null;
|
|
2209
|
+
} catch {
|
|
2210
|
+
}
|
|
2211
|
+
if (!exec.ok) {
|
|
2212
|
+
const raw = (await readTextOr(join13(work, "response.md"), "")).slice(0, 400);
|
|
2213
|
+
return {
|
|
2214
|
+
packet: "fail",
|
|
2215
|
+
packetDetail: `run_executor exited ${exec.exitCode ?? "?"}: ${exec.stderr.trim().split("\n")[0] ?? ""}${raw ? `
|
|
2216
|
+
Executor said: ${raw}` : ""}`,
|
|
2217
|
+
tokensPerSec: tps
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
const test = await run(process.execPath, ["--test", "answer.test.mjs"], {
|
|
2221
|
+
cwd: work,
|
|
2222
|
+
timeoutMs: 6e4
|
|
2223
|
+
});
|
|
2224
|
+
if (test.ok)
|
|
2225
|
+
return {
|
|
2226
|
+
packet: "pass",
|
|
2227
|
+
packetDetail: "executor wrote answer.mjs and node --test passed",
|
|
2228
|
+
tokensPerSec: tps
|
|
2229
|
+
};
|
|
2230
|
+
const answer = await readTextOr(join13(work, "answer.mjs"), "(answer.mjs not written)");
|
|
2231
|
+
return {
|
|
2232
|
+
packet: "fail",
|
|
2233
|
+
packetDetail: `tests failed. answer.mjs was:
|
|
2234
|
+
${answer.slice(0, 400)}`,
|
|
2235
|
+
tokensPerSec: tps
|
|
2236
|
+
};
|
|
2237
|
+
} finally {
|
|
2238
|
+
await rm2(work, { recursive: true, force: true }).catch(() => void 0);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
async function stepVerify(ctx) {
|
|
2242
|
+
header(
|
|
2243
|
+
"6/6",
|
|
2244
|
+
"Verify",
|
|
2245
|
+
"Run the installed check script and push one tiny real packet through the executor."
|
|
2246
|
+
);
|
|
2247
|
+
const roots = [
|
|
2248
|
+
...new Set(
|
|
2249
|
+
ctx.installs.filter((i) => i.record.owned.includes(i.record.root)).map((i) => i.record.root)
|
|
2250
|
+
)
|
|
2251
|
+
];
|
|
2252
|
+
if (roots.length === 0) {
|
|
2253
|
+
info("No installs to verify.");
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (ctx.opts.skipVerify) {
|
|
2257
|
+
warn("--skip-verify: not running the end-to-end check.");
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
let packetDone = null;
|
|
2261
|
+
for (const root of roots) {
|
|
2262
|
+
const sp = spinner2();
|
|
2263
|
+
sp.start(`Checking ${root}\u2026`);
|
|
2264
|
+
const check = await checkLocal(root);
|
|
2265
|
+
let packet = {
|
|
2266
|
+
packet: "skipped",
|
|
2267
|
+
packetDetail: check.checkMessage,
|
|
2268
|
+
tokensPerSec: null
|
|
2269
|
+
};
|
|
2270
|
+
if (check.check === "READY") {
|
|
2271
|
+
if (packetDone) {
|
|
2272
|
+
packet = { ...packetDone, packetDetail: "same executor as above" };
|
|
2273
|
+
} else {
|
|
2274
|
+
sp.message(`Sending the "return 42" packet through ${root}\u2026`);
|
|
2275
|
+
packet = await runVerifyPacket(root);
|
|
2276
|
+
packetDone = packet;
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
const result = { root, ...check, ...packet };
|
|
2280
|
+
ctx.verify.push(result);
|
|
2281
|
+
const label = `${check.check}${packet.packet !== "skipped" ? ` \xB7 packet ${packet.packet}` : ""}`;
|
|
2282
|
+
if (check.check === "READY" && packet.packet === "pass")
|
|
2283
|
+
sp.stop(`${root}: ${pc.green(label)}`);
|
|
2284
|
+
else if (check.check === "READY")
|
|
2285
|
+
sp.error(`${root}: ${pc.red(label)} \u2014 ${packet.packetDetail.split("\n")[0]}`);
|
|
2286
|
+
else sp.error(`${root}: ${pc.yellow(label)} \u2014 ${check.checkMessage}`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// src/commands/init.ts
|
|
2291
|
+
init_fs();
|
|
2292
|
+
var startHints = {
|
|
2293
|
+
claude: 'In Claude Code, say: "use the local executor to implement X"',
|
|
2294
|
+
codex: 'In Codex CLI, say: "use the local executor pipeline to implement X"',
|
|
2295
|
+
cursor: 'In Cursor chat, say: "use the local executor pipeline to implement X" (rule: .cursor/rules/local-executor.mdc)',
|
|
2296
|
+
windsurf: 'In Windsurf Cascade, say: "use the local executor pipeline to implement X" (rule: .windsurf/rules/local-executor.md)'
|
|
2297
|
+
};
|
|
2298
|
+
function summaryLines(ctx) {
|
|
2299
|
+
const lines = [];
|
|
2300
|
+
lines.push(
|
|
2301
|
+
`Ollama ${ctx.ollama.version ? `v${ctx.ollama.version}` : "not reachable"}${ctx.ollama.skipped ? " (setup skipped)" : ""} at ${ctx.client.baseUrl}`
|
|
2302
|
+
);
|
|
2303
|
+
const speed = ctx.warmup?.tokensPerSec ? `${ctx.warmup.tokensPerSec} tok/s` : ctx.pulled ? "speed not measured" : "not pulled";
|
|
2304
|
+
lines.push(`Model ${ctx.model ?? "none"} \u2014 ${speed}`);
|
|
2305
|
+
if (ctx.installs.length === 0) lines.push("Agents none configured");
|
|
2306
|
+
for (const i of ctx.installs) {
|
|
2307
|
+
const adapter = i.record.marked[0] ?? i.record.owned.find((p2) => p2 !== i.record.root) ?? i.record.root;
|
|
2308
|
+
lines.push(
|
|
2309
|
+
`${agentNames[i.record.agent].padEnd(9)} ${i.record.scope} \u2192 ${contractTilde(adapter)}`
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
const v = ctx.verify;
|
|
2313
|
+
if (v.length > 0) {
|
|
2314
|
+
const pass = v.filter((r) => r.packet === "pass").length;
|
|
2315
|
+
const skipped = v.filter((r) => r.packet === "skipped").length;
|
|
2316
|
+
const ran = v.length - skipped;
|
|
2317
|
+
const why = skipped > 0 ? ` (${skipped} skipped: ${v.find((r) => r.packet === "skipped")?.check.toLowerCase()})` : "";
|
|
2318
|
+
lines.push(
|
|
2319
|
+
`Verify ${ran === 0 ? "not run" : `${pass}/${ran} install(s) passed the end-to-end packet`}${why}`
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
return lines;
|
|
2323
|
+
}
|
|
2324
|
+
async function runInit(opts) {
|
|
2325
|
+
configureUi({ yes: opts.yes, json: opts.json });
|
|
2326
|
+
if (!ui.tty && !opts.yes && !opts.json) {
|
|
2327
|
+
process.stderr.write("lex: stdout is not a terminal; running as if --yes was given.\n");
|
|
2328
|
+
}
|
|
2329
|
+
const ctx = {
|
|
2330
|
+
opts,
|
|
2331
|
+
client: new OllamaClient(opts.ollamaUrl),
|
|
2332
|
+
cwd: process.cwd(),
|
|
2333
|
+
ollama: { binary: null, version: null, latest: null, skipped: false },
|
|
2334
|
+
detections: [],
|
|
2335
|
+
agents: [],
|
|
2336
|
+
projectRoot: null,
|
|
2337
|
+
installProject: false,
|
|
2338
|
+
installs: [],
|
|
2339
|
+
verify: [],
|
|
2340
|
+
warnings: []
|
|
2341
|
+
};
|
|
2342
|
+
intro2(`local-executor v${packageVersion()}`);
|
|
2343
|
+
message(
|
|
2344
|
+
"Planner (your cloud agent) \u2192 Executor (local model via Ollama) \u2192 Auditor (strong model, fresh context)."
|
|
2345
|
+
);
|
|
2346
|
+
await stepOllama(ctx);
|
|
2347
|
+
await stepHardware(ctx);
|
|
2348
|
+
await stepModel(ctx);
|
|
2349
|
+
await stepAgents(ctx);
|
|
2350
|
+
await stepSkills(ctx);
|
|
2351
|
+
await stepVerify(ctx);
|
|
2352
|
+
const failedVerify = ctx.verify.some((r) => r.packet === "fail");
|
|
2353
|
+
if (opts.json) {
|
|
2354
|
+
process.stdout.write(
|
|
2355
|
+
`${JSON.stringify(
|
|
2356
|
+
{
|
|
2357
|
+
ok: !failedVerify,
|
|
2358
|
+
ollama: ctx.ollama,
|
|
2359
|
+
hardware: ctx.hw,
|
|
2360
|
+
effectiveMemoryGB: ctx.report?.effectiveMemory.gb,
|
|
2361
|
+
tier: ctx.report?.tier.label,
|
|
2362
|
+
model: ctx.model,
|
|
2363
|
+
pulled: ctx.pulled ?? false,
|
|
2364
|
+
tokensPerSec: ctx.warmup?.tokensPerSec ?? null,
|
|
2365
|
+
agents: ctx.agents,
|
|
2366
|
+
installs: ctx.installs.map((i) => i.record),
|
|
2367
|
+
verify: ctx.verify,
|
|
2368
|
+
warnings: ctx.warnings
|
|
2369
|
+
},
|
|
2370
|
+
null,
|
|
2371
|
+
2
|
|
2372
|
+
)}
|
|
2373
|
+
`
|
|
2374
|
+
);
|
|
2375
|
+
} else {
|
|
2376
|
+
box2(summaryLines(ctx).join("\n"), "Summary");
|
|
2377
|
+
for (const w of ctx.warnings) warn(w);
|
|
2378
|
+
const hints = ctx.agents.map((a) => `\u2022 ${startHints[a]}`);
|
|
2379
|
+
if (hints.length > 0) note2(hints.join("\n"), "How to start");
|
|
2380
|
+
outro2(
|
|
2381
|
+
failedVerify ? pc.red("Setup finished with a failing verification. See above.") : "Done."
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
return failedVerify ? 3 : 0;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// src/commands/models.ts
|
|
2388
|
+
async function runModels(opts) {
|
|
2389
|
+
configureUi({ json: opts.json });
|
|
2390
|
+
const hw = await detectHardware();
|
|
2391
|
+
const report = buildRecommendation(hw, { includeAll: opts.all });
|
|
2392
|
+
if (opts.json) {
|
|
2393
|
+
process.stdout.write(
|
|
2394
|
+
`${JSON.stringify({ hardware: hw, effectiveMemory: report.effectiveMemory, tier: report.tier.label, adjustments: report.adjustments, models: report.list.map(({ model: _m, ...r }) => r), lastVerified }, null, 2)}
|
|
2395
|
+
`
|
|
2396
|
+
);
|
|
2397
|
+
return 0;
|
|
2398
|
+
}
|
|
2399
|
+
intro2("lex models");
|
|
2400
|
+
table(hardwareRows(hw));
|
|
2401
|
+
info(
|
|
2402
|
+
`Effective memory: ${pc.bold(`${report.effectiveMemory.gb} GB`)} (${report.effectiveMemory.rule}) \u2192 tier ${report.tier.label}`
|
|
2403
|
+
);
|
|
2404
|
+
for (const a of report.adjustments) warn(a);
|
|
2405
|
+
const rows = report.list.map((r) => {
|
|
2406
|
+
const flags = [
|
|
2407
|
+
!r.fitsDisk ? pc.yellow("needs disk") : "",
|
|
2408
|
+
!r.fitsMemory ? pc.yellow("may swap") : ""
|
|
2409
|
+
].filter(Boolean).join(" ");
|
|
2410
|
+
return [
|
|
2411
|
+
`${r.recommended ? "\u2605 " : " "}${r.tag}`,
|
|
2412
|
+
`~${String(r.sizeGB).padStart(4)} GB ${r.reason}${flags ? ` [${flags}]` : ""}`
|
|
2413
|
+
];
|
|
2414
|
+
});
|
|
2415
|
+
table(rows);
|
|
2416
|
+
outro2(
|
|
2417
|
+
`Catalog last verified against ollama.com on ${lastVerified}. Nothing was installed.${opts.all ? "" : " Use --all to include models that do not fit."}`
|
|
2418
|
+
);
|
|
2419
|
+
return 0;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// src/commands/switch.ts
|
|
2423
|
+
init_fs();
|
|
2424
|
+
init_state();
|
|
2425
|
+
async function runSwitch(tag, opts) {
|
|
2426
|
+
configureUi({ yes: opts.yes, json: opts.json });
|
|
2427
|
+
if (!isValidTag(tag)) {
|
|
2428
|
+
error(`"${tag}" does not look like an Ollama tag.`);
|
|
2429
|
+
return 1;
|
|
2430
|
+
}
|
|
2431
|
+
const manifest = await readManifest();
|
|
2432
|
+
if (manifest.installs.length === 0) {
|
|
2433
|
+
error("No installs recorded. Run `lex init` first.");
|
|
2434
|
+
return 1;
|
|
2435
|
+
}
|
|
2436
|
+
intro2(`lex switch ${tag}`);
|
|
2437
|
+
const client = new OllamaClient(opts.ollamaUrl);
|
|
2438
|
+
const ctx = {
|
|
2439
|
+
client,
|
|
2440
|
+
opts: { ...opts, skipPull: false },
|
|
2441
|
+
warnings: []
|
|
2442
|
+
};
|
|
2443
|
+
let pulled = false;
|
|
2444
|
+
if (await client.isUp()) {
|
|
2445
|
+
if (await client.hasModel(tag).catch(() => false)) {
|
|
2446
|
+
success(`${tag} is already pulled.`);
|
|
2447
|
+
pulled = true;
|
|
2448
|
+
} else {
|
|
2449
|
+
const known = findModel(tag);
|
|
2450
|
+
info(
|
|
2451
|
+
`Download: ${known ? `about ${known.sizeGB} GB` : "size unknown (not in the catalog)"}.`
|
|
2452
|
+
);
|
|
2453
|
+
if (await confirm2(`Pull ${tag} now?`, true)) pulled = await pullModel(ctx, tag);
|
|
2454
|
+
if (!pulled) {
|
|
2455
|
+
error(`Could not pull ${tag}; configs were not changed.`);
|
|
2456
|
+
return 2;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
await warmupModel(ctx, tag);
|
|
2460
|
+
} else {
|
|
2461
|
+
warn(
|
|
2462
|
+
`Ollama is not reachable at ${opts.ollamaUrl}; updating configs without pulling. Run: ollama pull ${tag}`
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
const updated = [];
|
|
2466
|
+
const missing = [];
|
|
2467
|
+
for (const rec of manifest.installs) {
|
|
2468
|
+
if (!rec.owned.includes(rec.root)) continue;
|
|
2469
|
+
if (await updateConfigModel(rec.configPath, tag)) updated.push(rec.configPath);
|
|
2470
|
+
else missing.push(rec.configPath);
|
|
2471
|
+
}
|
|
2472
|
+
manifest.model = tag;
|
|
2473
|
+
await writeManifest(manifest);
|
|
2474
|
+
for (const u of updated) success(`Updated ${contractTilde(u)}`);
|
|
2475
|
+
for (const m of missing) warn(`Missing ${contractTilde(m)} (re-run lex init)`);
|
|
2476
|
+
if (opts.json)
|
|
2477
|
+
process.stdout.write(
|
|
2478
|
+
`${JSON.stringify({ ok: true, model: tag, pulled, tokensPerSec: ctx.warmup?.tokensPerSec ?? null, updated, missing })}
|
|
2479
|
+
`
|
|
2480
|
+
);
|
|
2481
|
+
else outro2(`Executor model is now ${tag} in ${updated.length} config file(s).`);
|
|
2482
|
+
return 0;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
// src/commands/uninstall.ts
|
|
2486
|
+
init_fs();
|
|
2487
|
+
init_state();
|
|
2488
|
+
async function runUninstall(opts) {
|
|
2489
|
+
configureUi({ yes: opts.yes, json: opts.json });
|
|
2490
|
+
const manifest = await readManifest();
|
|
2491
|
+
const targets = manifest.installs.filter((r) => !opts.agents || opts.agents.includes(r.agent));
|
|
2492
|
+
intro2("lex uninstall");
|
|
2493
|
+
if (targets.length === 0) {
|
|
2494
|
+
info("Nothing recorded to uninstall.");
|
|
2495
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({ ok: true, removed: [] })}
|
|
2496
|
+
`);
|
|
2497
|
+
return 0;
|
|
2498
|
+
}
|
|
2499
|
+
const preview = targets.map((r) => {
|
|
2500
|
+
const items = [
|
|
2501
|
+
...r.owned.map((p2) => `delete ${contractTilde(p2)}`),
|
|
2502
|
+
...r.marked.map((p2) => `unmark ${contractTilde(p2)} (block only)`)
|
|
2503
|
+
];
|
|
2504
|
+
return `${agentNames[r.agent]} (${r.scope})
|
|
2505
|
+
${items.join("\n ")}`;
|
|
2506
|
+
}).join("\n");
|
|
2507
|
+
note2(preview, "This will");
|
|
2508
|
+
if (!await confirm2("Proceed?", true)) return 130;
|
|
2509
|
+
const removed = [];
|
|
2510
|
+
for (const rec of targets) {
|
|
2511
|
+
const actions = await uninstallRecord(rec);
|
|
2512
|
+
removed.push(...actions);
|
|
2513
|
+
for (const a of actions) success(a.replace(process.env.HOME ?? "", "~"));
|
|
2514
|
+
}
|
|
2515
|
+
manifest.installs = manifest.installs.filter((r) => !targets.includes(r));
|
|
2516
|
+
await writeManifest(manifest);
|
|
2517
|
+
if (opts.json) process.stdout.write(`${JSON.stringify({ ok: true, removed })}
|
|
2518
|
+
`);
|
|
2519
|
+
else outro2(`Removed ${targets.length} install(s). Ollama and pulled models were left alone.`);
|
|
2520
|
+
return 0;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
// src/util/url.ts
|
|
2524
|
+
function normalizeUrl(raw) {
|
|
2525
|
+
let s = raw.trim();
|
|
2526
|
+
if (!/^https?:\/\//.test(s)) s = `http://${s}`;
|
|
2527
|
+
s = s.replace(/\/+$/, "");
|
|
2528
|
+
s = s.replace("://0.0.0.0", "://localhost");
|
|
2529
|
+
return s;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// src/cli.ts
|
|
2533
|
+
var program = new Command();
|
|
2534
|
+
program.name("lex").description(
|
|
2535
|
+
"Set up a planner \u2192 local executor \u2192 auditor coding pipeline.\nInstalls Ollama, picks a model for your hardware, and teaches your AI agents to use it."
|
|
2536
|
+
).version(packageVersion(), "-v, --version").showHelpAfterError().addHelpText(
|
|
2537
|
+
"after",
|
|
2538
|
+
`
|
|
2539
|
+
Examples:
|
|
2540
|
+
$ npx local-executor interactive setup (same as: lex init)
|
|
2541
|
+
$ lex --yes zero prompts, sane defaults
|
|
2542
|
+
$ lex init --model qwen3.5:4b --agents claude,codex --yes
|
|
2543
|
+
$ lex init --skip-ollama --agents cursor --project
|
|
2544
|
+
$ lex models ranked catalog for this machine
|
|
2545
|
+
$ lex doctor diagnostics, non-zero exit on failure
|
|
2546
|
+
$ lex switch gemma4:e4b pull + update every installed config
|
|
2547
|
+
$ lex uninstall --agents codex remove what lex installed for Codex
|
|
2548
|
+
`
|
|
2549
|
+
);
|
|
2550
|
+
var ollamaUrlOption = new Option("--ollama-url <url>", "Ollama server URL").default(DEFAULT_OLLAMA_URL).env("OLLAMA_HOST");
|
|
2551
|
+
function initCommand(cmd) {
|
|
2552
|
+
return cmd.option("-y, --yes", "accept defaults; never prompt", false).option("--json", "machine-readable output; implies --yes", false).option("-m, --model <tag>", "executor model tag (skips the selection prompt)").option(
|
|
2553
|
+
"-a, --agents <list>",
|
|
2554
|
+
"comma-separated: claude,codex,cursor,windsurf",
|
|
2555
|
+
(v) => parseAgentList(v)
|
|
2556
|
+
).option("--skip-ollama", "do not detect/install/start Ollama", false).option("--skip-pull", "do not pull the model", false).option("--skip-verify", "do not run the end-to-end check", false).option("--project", "also install project-level files (requires a git repo)").option("--no-project", "never install project-level files").option(
|
|
2557
|
+
"--allow-install",
|
|
2558
|
+
"in --yes mode, permit running the Ollama install/upgrade command",
|
|
2559
|
+
false
|
|
2560
|
+
).addOption(ollamaUrlOption).action(async (opts) => {
|
|
2561
|
+
const code2 = await runInit({
|
|
2562
|
+
yes: Boolean(opts.yes) || Boolean(opts.json),
|
|
2563
|
+
json: Boolean(opts.json),
|
|
2564
|
+
ollamaUrl: normalizeUrl(opts.ollamaUrl),
|
|
2565
|
+
model: opts.model,
|
|
2566
|
+
agents: opts.agents,
|
|
2567
|
+
skipOllama: Boolean(opts.skipOllama),
|
|
2568
|
+
skipPull: Boolean(opts.skipPull),
|
|
2569
|
+
skipVerify: Boolean(opts.skipVerify),
|
|
2570
|
+
project: typeof opts.project === "boolean" ? opts.project : void 0,
|
|
2571
|
+
allowInstall: Boolean(opts.allowInstall)
|
|
2572
|
+
});
|
|
2573
|
+
process.exitCode = code2;
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
initCommand(
|
|
2577
|
+
program.command("init", { isDefault: true }).description("interactive setup (default command)")
|
|
2578
|
+
);
|
|
2579
|
+
program.command("doctor").description("re-run all checks and print a diagnostic table; exits 1 on any failure").option("--json", "machine-readable output", false).addOption(ollamaUrlOption).action(async (opts) => {
|
|
2580
|
+
process.exitCode = await runDoctor({
|
|
2581
|
+
json: Boolean(opts.json),
|
|
2582
|
+
ollamaUrl: normalizeUrl(opts.ollamaUrl)
|
|
2583
|
+
});
|
|
2584
|
+
});
|
|
2585
|
+
program.command("models").description("show the model catalog ranked for this machine (installs nothing)").option("--json", "machine-readable output", false).option("--all", "include models that do not fit in memory", false).action(async (opts) => {
|
|
2586
|
+
process.exitCode = await runModels({ json: Boolean(opts.json), all: Boolean(opts.all) });
|
|
2587
|
+
});
|
|
2588
|
+
program.command("switch <tag>").description("pull <tag> if needed and point every installed config.json at it").option("-y, --yes", "never prompt", false).option("--json", "machine-readable output; implies --yes", false).addOption(ollamaUrlOption).action(async (tag, opts) => {
|
|
2589
|
+
process.exitCode = await runSwitch(tag, {
|
|
2590
|
+
yes: Boolean(opts.yes) || Boolean(opts.json),
|
|
2591
|
+
json: Boolean(opts.json),
|
|
2592
|
+
ollamaUrl: normalizeUrl(opts.ollamaUrl)
|
|
2593
|
+
});
|
|
2594
|
+
});
|
|
2595
|
+
program.command("uninstall").description("remove what lex installed (respects marker blocks in shared files)").option("-y, --yes", "never prompt", false).option("--json", "machine-readable output; implies --yes", false).option("-a, --agents <list>", "only these agents", (v) => parseAgentList(v)).action(async (opts) => {
|
|
2596
|
+
process.exitCode = await runUninstall({
|
|
2597
|
+
yes: Boolean(opts.yes) || Boolean(opts.json),
|
|
2598
|
+
json: Boolean(opts.json),
|
|
2599
|
+
agents: opts.agents
|
|
2600
|
+
});
|
|
2601
|
+
});
|
|
2602
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2603
|
+
process.stderr.write(`lex: ${err.message}
|
|
2604
|
+
`);
|
|
2605
|
+
process.exitCode = 1;
|
|
2606
|
+
});
|