pudu-ai 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,8 @@ npx pudu-ai hardware
43
43
  npx pudu-ai models
44
44
  npx pudu-ai models add-path ~/Models
45
45
  npx pudu-ai recommend
46
+ npx pudu-ai recommend --install --yes
47
+ npx pudu-ai recommend --link opencode --yes
46
48
  npx pudu-ai tasks
47
49
  npx pudu-ai tasks --for code,image,transcription --lang es
48
50
  npx pudu-ai benchmark
package/dist/cli/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- #!/usr/bin/env node
3
2
 
4
3
  // src/cli/parse-args.ts
5
4
  function parseArgs(argv) {
@@ -30,6 +29,7 @@ function parseArgs(argv) {
30
29
  const forIndex = args2.findIndex((a) => a === "--for");
31
30
  const scopeIndex = args2.findIndex((a) => a === "--scope");
32
31
  const priorityIndex = args2.findIndex((a) => a === "--priority");
32
+ const linkIndex = args2.findIndex((a) => a === "--link");
33
33
  return {
34
34
  command: isKnown ? command : "dashboard",
35
35
  positional: isKnown ? rest.slice(1) : rest,
@@ -46,7 +46,9 @@ function parseArgs(argv) {
46
46
  forKinds: forIndex >= 0 ? args2[forIndex + 1] : void 0,
47
47
  scope: scopeIndex >= 0 ? args2[scopeIndex + 1] : void 0,
48
48
  priority: priorityIndex >= 0 ? args2[priorityIndex + 1] : void 0,
49
- yes: flags.has("--yes")
49
+ yes: flags.has("--yes"),
50
+ install: flags.has("--install"),
51
+ link: linkIndex >= 0 ? args2[linkIndex + 1] : void 0
50
52
  };
51
53
  }
52
54
 
@@ -844,6 +846,14 @@ var en = {
844
846
  compatible: "COMPATIBLE (catalog, estimated)",
845
847
  recommended: "RECOMMENDED FOR THIS MACHINE",
846
848
  recommendedHint: "Estimated values come from CanIRun.ai by midudev (canirun.ai \xB7 midu.dev) or a local fallback \u2014 not measured",
849
+ recommendKeys: "[\u2191\u2193] model [I] ollama pull if grade S\u2013B [O] OpenCode [E] Hermes [W] OpenClaw [Esc] back",
850
+ recommendCliHint: "Install: npx pudu-ai recommend --install --yes Link: npx pudu-ai recommend --link opencode --yes",
851
+ agentsTitle: "CODING AGENTS",
852
+ agentMissing: "not installed \u2014 select to install via ollama launch if the model is eligible",
853
+ installGradeBlock: "Grade {grade} is too low to pull. Need S, A, or B.",
854
+ linkingTool: "Linking {tool} with {model}\u2026",
855
+ modelPulled: "Pulled {model} with Ollama.",
856
+ installNone: "No recommended model has grade S\u2013B to install.",
847
857
  credits: "Measured: llama-bench + OS telemetry. Estimated: CanIRun.ai by midudev (https://canirun.ai, https://github.com/midudev/canirun.ai, https://midu.dev). Labelled separately; never mixed.",
848
858
  noModels: "No local models detected",
849
859
  notTested: "Not tested",
@@ -951,6 +961,8 @@ Usage:
951
961
  npx pudu-ai models
952
962
  npx pudu-ai models add-path ~/Models
953
963
  npx pudu-ai recommend
964
+ npx pudu-ai recommend --install --yes
965
+ npx pudu-ai recommend --link opencode --yes
954
966
  npx pudu-ai tasks
955
967
  npx pudu-ai tasks --for code,image --scope all --priority speed
956
968
  npx pudu-ai benchmark [model]
@@ -1006,6 +1018,14 @@ var es = {
1006
1018
  compatible: "COMPATIBLES (cat\xE1logo, estimado)",
1007
1019
  recommended: "RECOMENDADOS PARA ESTE EQUIPO",
1008
1020
  recommendedHint: "Los valores estimados vienen de CanIRun.ai de midudev (canirun.ai \xB7 midu.dev) o de un fallback local \u2014 no son mediciones",
1021
+ recommendKeys: "[\u2191\u2193] modelo [I] ollama pull si nota S\u2013B [O] OpenCode [E] Hermes [W] OpenClaw [Esc] atr\xE1s",
1022
+ recommendCliHint: "Instalar: npx pudu-ai recommend --install --yes Vincular: npx pudu-ai recommend --link opencode --yes",
1023
+ agentsTitle: "AGENTES DE C\xD3DIGO",
1024
+ agentMissing: "no instalado \u2014 selecci\xF3nalo para instalar con ollama launch si el modelo es elegible",
1025
+ installGradeBlock: "La nota {grade} es baja para hacer pull. Hace falta S, A o B.",
1026
+ linkingTool: "Vinculando {tool} con {model}\u2026",
1027
+ modelPulled: "Descargado {model} con Ollama.",
1028
+ installNone: "Ning\xFAn modelo recomendado tiene nota S\u2013B para instalar.",
1009
1029
  credits: "Medido: llama-bench + telemetr\xEDa del SO. Estimado: CanIRun.ai de midudev (https://canirun.ai, https://github.com/midudev/canirun.ai, https://midu.dev). Se etiquetan por separado; nunca se mezclan.",
1010
1030
  noModels: "No se detectaron modelos locales",
1011
1031
  notTested: "Sin probar",
@@ -1113,6 +1133,8 @@ Uso:
1113
1133
  npx pudu-ai models
1114
1134
  npx pudu-ai models add-path ~/Models
1115
1135
  npx pudu-ai recommend
1136
+ npx pudu-ai recommend --install --yes
1137
+ npx pudu-ai recommend --link opencode --yes
1116
1138
  npx pudu-ai tasks
1117
1139
  npx pudu-ai tasks --for code,image --scope all --priority speed
1118
1140
  npx pudu-ai benchmark [model]
@@ -1359,15 +1381,33 @@ async function listBenchmarks() {
1359
1381
  return records;
1360
1382
  }
1361
1383
 
1384
+ // src/integrations/agents.ts
1385
+ var AGENTS = [
1386
+ { id: "opencode", label: "OpenCode", bin: "opencode" },
1387
+ { id: "hermes", label: "Hermes", bin: "hermes" },
1388
+ { id: "openclaw", label: "OpenClaw", bin: "openclaw" }
1389
+ ];
1390
+ async function detectAgents() {
1391
+ const out = [];
1392
+ for (const agent of AGENTS) {
1393
+ out.push({
1394
+ ...agent,
1395
+ detected: Boolean(await commandExists(agent.bin))
1396
+ });
1397
+ }
1398
+ return out;
1399
+ }
1400
+
1362
1401
  // src/session/load.ts
1363
1402
  async function loadSession(options) {
1364
1403
  const hardware = await detectHardware();
1365
- const [runtimes2, models, catalogState, history, llamaBench] = await Promise.all([
1404
+ const [runtimes2, models, catalogState, history, llamaBench, agents] = await Promise.all([
1366
1405
  detectRuntimes(),
1367
1406
  discoverLocalModels(),
1368
1407
  loadCatalog(options.network),
1369
1408
  listBenchmarks(),
1370
- commandExists("llama-bench").then(Boolean)
1409
+ commandExists("llama-bench").then(Boolean),
1410
+ detectAgents()
1371
1411
  ]);
1372
1412
  const rows = [];
1373
1413
  for (const local of models) {
@@ -1386,7 +1426,8 @@ async function loadSession(options) {
1386
1426
  recommendations,
1387
1427
  history,
1388
1428
  llamaBench,
1389
- networkUsed: catalogState.networkUsed
1429
+ networkUsed: catalogState.networkUsed,
1430
+ agents
1390
1431
  };
1391
1432
  }
1392
1433
 
@@ -1886,8 +1927,8 @@ function setLogLevel(next) {
1886
1927
  import { render } from "ink";
1887
1928
 
1888
1929
  // src/tui/App.tsx
1889
- import { Box as Box9, Text as Text9, useApp, useInput as useInput3 } from "ink";
1890
- import { useMemo as useMemo2, useState as useState3 } from "react";
1930
+ import { Box as Box9, Text as Text9, useApp as useApp2, useInput as useInput4 } from "ink";
1931
+ import { useMemo as useMemo2, useState as useState4 } from "react";
1891
1932
 
1892
1933
  // src/tui/views/Dashboard.tsx
1893
1934
  import { Box, Text } from "ink";
@@ -1925,6 +1966,27 @@ var GRADE_MEANING = {
1925
1966
  F: "Not recommended"
1926
1967
  };
1927
1968
 
1969
+ // src/tui/theme.ts
1970
+ function gradeColor(grade) {
1971
+ if (grade === "S" || grade === "A") return "green";
1972
+ if (grade === "B") return "yellow";
1973
+ if (grade === "C") return "magenta";
1974
+ if (grade === "D" || grade === "F") return "red";
1975
+ return "gray";
1976
+ }
1977
+ function bar(percent, width = 10) {
1978
+ if (percent === void 0 || Number.isNaN(percent)) return `${"\u2591".repeat(width)} N/A`;
1979
+ const filled = Math.max(0, Math.min(width, Math.round(percent / 100 * width)));
1980
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
1981
+ }
1982
+ function sparkline(values, width = 24) {
1983
+ const glyphs = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
1984
+ if (!values.length) return glyphs[0].repeat(width);
1985
+ const slice = values.slice(-width);
1986
+ const max = Math.max(...slice, 1);
1987
+ return slice.map((v) => glyphs[Math.min(glyphs.length - 1, Math.floor(v / max * (glyphs.length - 1)))]).join("").padStart(width, glyphs[0]);
1988
+ }
1989
+
1928
1990
  // src/tui/views/Dashboard.tsx
1929
1991
  import { jsx, jsxs } from "react/jsx-runtime";
1930
1992
  function Dashboard({ session }) {
@@ -1964,7 +2026,7 @@ function Dashboard({ session }) {
1964
2026
  ] }),
1965
2027
  /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1966
2028
  /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("runtimes") }),
1967
- session.runtimes.map((r) => /* @__PURE__ */ jsxs(Text, { children: [
2029
+ session.runtimes.map((r) => /* @__PURE__ */ jsxs(Text, { color: r.detected ? "green" : "gray", children: [
1968
2030
  r.detected ? "\u2713" : "\u25CB",
1969
2031
  " ",
1970
2032
  r.label.padEnd(14),
@@ -1976,12 +2038,12 @@ function Dashboard({ session }) {
1976
2038
  /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("installedModels") }),
1977
2039
  session.rows.length === 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: t("noModels") }),
1978
2040
  session.rows.map((row) => /* @__PURE__ */ jsxs(Text, { children: [
1979
- row.local.name.padEnd(20),
2041
+ /* @__PURE__ */ jsx(Text, { color: "green", children: row.local.name.padEnd(20) }),
1980
2042
  " ",
1981
2043
  row.local.sizeBytes ? formatBytes(row.local.sizeBytes) : "N/A",
1982
- " ",
1983
- row.compatibility?.grade ?? "\u2014",
1984
- " ",
2044
+ " ",
2045
+ /* @__PURE__ */ jsx(Text, { color: gradeColor(row.compatibility?.grade), bold: true, children: row.compatibility?.grade ?? "\u2014" }),
2046
+ " ",
1985
2047
  row.lastBenchmark?.benchmark.generationTokensPerSecond ? formatTokensPerSec(row.lastBenchmark.benchmark.generationTokensPerSecond) : t("notTested")
1986
2048
  ] }, row.local.id))
1987
2049
  ] }),
@@ -1989,11 +2051,11 @@ function Dashboard({ session }) {
1989
2051
  /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("recommended") }),
1990
2052
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: t("recommendedHint") }),
1991
2053
  session.recommendations.map((rec) => /* @__PURE__ */ jsxs(Text, { children: [
1992
- rec.useCase.padEnd(12),
2054
+ /* @__PURE__ */ jsx(Text, { color: "magenta", children: rec.useCase.padEnd(12) }),
1993
2055
  " ",
1994
2056
  rec.model.name.padEnd(22),
1995
2057
  " ",
1996
- rec.grade,
2058
+ /* @__PURE__ */ jsx(Text, { color: gradeColor(rec.grade), bold: true, children: rec.grade }),
1997
2059
  " ",
1998
2060
  GRADE_MEANING[rec.grade]
1999
2061
  ] }, `${rec.useCase}-${rec.model.id}`)),
@@ -2005,13 +2067,31 @@ function Dashboard({ session }) {
2005
2067
  // src/tui/views/Models.tsx
2006
2068
  import { Box as Box2, Text as Text2 } from "ink";
2007
2069
 
2070
+ // src/cli/color.ts
2071
+ var enabled = () => !process.env.NO_COLOR && process.stdout.isTTY;
2072
+ var wrap = (open, close = 39) => (text) => enabled() ? `\x1B[${open}m${text}\x1B[${close}m` : text;
2073
+ var cyan = wrap(36);
2074
+ var green = wrap(32);
2075
+ var yellow = wrap(33);
2076
+ var red = wrap(31);
2077
+ var magenta = wrap(35);
2078
+ var bold = wrap(1, 22);
2079
+ var dim = wrap(2, 22);
2080
+ function gradeAnsi(grade) {
2081
+ if (grade === "S" || grade === "A") return green(grade);
2082
+ if (grade === "B") return yellow(grade);
2083
+ if (grade === "C") return magenta(grade);
2084
+ if (grade === "D" || grade === "F") return red(grade);
2085
+ return grade;
2086
+ }
2087
+
2008
2088
  // src/cli/text.ts
2009
2089
  function hardwareText(session) {
2010
2090
  const h = session.hardware;
2011
2091
  const ram = formatBytes(h.memory.totalBytes, 0);
2012
2092
  const avail = h.memory.availableBytes ? formatBytes(h.memory.availableBytes) : "N/A";
2013
2093
  return [
2014
- t("machine"),
2094
+ bold(cyan(t("machine"))),
2015
2095
  ` ${h.machineModel ?? t("unknownMachine")}`,
2016
2096
  ` ${h.cpu.name ?? t("cpuNa")}`,
2017
2097
  ` CPU ${h.cpu.physicalCores ?? "N/A"} cores (${h.cpu.performanceCores ?? "?"}P / ${h.cpu.efficiencyCores ?? "?"}E)`,
@@ -2023,18 +2103,22 @@ function hardwareText(session) {
2023
2103
  }
2024
2104
  function runtimesText(session) {
2025
2105
  return [
2026
- t("runtimes"),
2027
- ...session.runtimes.map((r) => ` ${r.detected ? "\u2713" : "\u25CB"} ${r.label.padEnd(14)} ${r.detected ? r.version ?? t("detected") : t("notDetected")}`)
2106
+ bold(cyan(t("runtimes"))),
2107
+ ...session.runtimes.map((r) => {
2108
+ const mark = r.detected ? green("\u2713") : dim("\u25CB");
2109
+ const state = r.detected ? green(r.version ?? t("detected")) : dim(t("notDetected"));
2110
+ return ` ${mark} ${r.label.padEnd(14)} ${state}`;
2111
+ })
2028
2112
  ].join("\n");
2029
2113
  }
2030
2114
  function modelsText(session) {
2031
- const lines = [t("installedModels"), "MODEL INSTALLED FIT EST. SPEED MEASURED"];
2115
+ const lines = [bold(cyan(t("installedModels"))), dim("MODEL INSTALLED FIT EST. SPEED MEASURED")];
2032
2116
  for (const row of session.rows) {
2033
2117
  const fit = row.compatibility?.grade ?? "\u2014";
2034
2118
  const est = row.compatibility?.estimatedTokensPerSecond ? `~${formatNumber(row.compatibility.estimatedTokensPerSecond)} t/s` : "\u2014";
2035
2119
  const measured = row.lastBenchmark?.benchmark.generationTokensPerSecond ? formatTokensPerSec(row.lastBenchmark.benchmark.generationTokensPerSecond) : t("notTested");
2036
2120
  lines.push(
2037
- `${row.local.name.padEnd(22)} \u2713 ${fit.padEnd(9)} ${est.padEnd(14)} ${measured}`
2121
+ `${green(row.local.name.padEnd(22))} \u2713 ${gradeAnsi(fit).padEnd(9)} ${est.padEnd(14)} ${measured}`
2038
2122
  );
2039
2123
  }
2040
2124
  lines.push("", t("compatible"));
@@ -2046,12 +2130,19 @@ function modelsText(session) {
2046
2130
  return lines.join("\n");
2047
2131
  }
2048
2132
  function recommendText(session) {
2049
- const lines = [t("recommended"), t("recommendedHint"), "", t("credits"), ""];
2133
+ const lines = [bold(cyan(t("recommended"))), dim(t("recommendedHint")), "", dim(t("credits")), ""];
2050
2134
  for (const rec of session.recommendations) {
2051
2135
  lines.push(
2052
- `${rec.useCase.padEnd(12)} ${rec.model.name.padEnd(22)} ${rec.grade} ${GRADE_MEANING[rec.grade]} ~${na(rec.estimatedTokensPerSecond)} t/s est.`
2136
+ `${magenta(rec.useCase.padEnd(12))} ${rec.model.name.padEnd(22)} ${gradeAnsi(rec.grade)} ${GRADE_MEANING[rec.grade]} ~${na(rec.estimatedTokensPerSecond)} t/s est.`
2137
+ );
2138
+ }
2139
+ lines.push("", bold(yellow(t("agentsTitle"))));
2140
+ for (const agent of session.agents) {
2141
+ lines.push(
2142
+ ` ${agent.detected ? green("\u2713") : dim("\u25CB")} ${agent.label.padEnd(12)} ${agent.detected ? green(t("detected")) : dim(t("agentMissing"))}`
2053
2143
  );
2054
2144
  }
2145
+ lines.push("", dim(t("recommendCliHint")));
2055
2146
  return lines.join("\n");
2056
2147
  }
2057
2148
  function historyText(records) {
@@ -2087,6 +2178,7 @@ function doctorText(session) {
2087
2178
  `${session.hardware.gpu.metal ? "\u2713" : "\u25CB"} Metal`,
2088
2179
  ...session.runtimes.map((r) => `${r.detected ? "\u2713" : "\u25CB"} ${r.label.padEnd(14)} ${r.version ?? ""}`.trimEnd()),
2089
2180
  `${session.networkUsed ? "\u2713" : "\u25CB"} CanIRun.ai (midudev)`,
2181
+ ...session.agents.map((a) => `${a.detected ? "\u2713" : "\u25CB"} ${a.label.padEnd(14)} ${a.detected ? t("detected") : t("agentMissing")}`),
2090
2182
  "",
2091
2183
  session.llamaBench ? t("doctorReady", { count: session.models.filter((m) => m.artifactPath).length }) : t("doctorNoBench")
2092
2184
  ];
@@ -2187,10 +2279,264 @@ function HardwareView({ session }) {
2187
2279
  }
2188
2280
 
2189
2281
  // src/tui/views/Recommend.tsx
2190
- import { Box as Box4, Text as Text4 } from "ink";
2191
- import { jsx as jsx4 } from "react/jsx-runtime";
2282
+ import { Box as Box4, Text as Text4, useApp, useInput } from "ink";
2283
+ import { useState } from "react";
2284
+
2285
+ // src/integrations/catalog.ts
2286
+ var INTEGRATIONS = {
2287
+ opencode: {
2288
+ id: "opencode",
2289
+ docsUrl: "https://docs.ollama.com/integrations/opencode",
2290
+ ollamaLaunch: "opencode",
2291
+ minGenerationTps: 12,
2292
+ allowedGrades: ["S", "A", "B"],
2293
+ useCases: ["code", "chat", "reasoning"]
2294
+ },
2295
+ openclaw: {
2296
+ id: "openclaw",
2297
+ docsUrl: "https://docs.ollama.com/integrations/openclaw",
2298
+ ollamaLaunch: "openclaw",
2299
+ minGenerationTps: 12,
2300
+ allowedGrades: ["S", "A", "B"],
2301
+ useCases: ["code", "chat", "reasoning"]
2302
+ },
2303
+ hermes: {
2304
+ id: "hermes",
2305
+ docsUrl: "https://docs.ollama.com/integrations/hermes",
2306
+ ollamaLaunch: "hermes",
2307
+ minGenerationTps: 12,
2308
+ allowedGrades: ["S", "A"],
2309
+ useCases: ["code", "chat", "reasoning"]
2310
+ },
2311
+ claude: {
2312
+ id: "claude",
2313
+ docsUrl: "https://docs.ollama.com/integrations/claude-code",
2314
+ ollamaLaunch: "claude",
2315
+ minGenerationTps: 12,
2316
+ allowedGrades: ["S", "A", "B"],
2317
+ useCases: ["code", "chat", "reasoning"]
2318
+ }
2319
+ };
2320
+
2321
+ // src/integrations/types.ts
2322
+ var INTEGRATION_IDS = ["opencode", "openclaw", "hermes", "claude"];
2323
+
2324
+ // src/integrations/decide.ts
2325
+ var GRADE_RANK = { S: 5, A: 4, B: 3, C: 2, D: 1, F: 0 };
2326
+ function toOllamaTag(id) {
2327
+ const compact = id.toLowerCase().replace(/_/g, "-");
2328
+ const match = compact.match(/^([a-z0-9.]+(?:-[a-z0-9.]+)*)-(\d+(?:\.\d+)?b)$/i);
2329
+ if (match) return `${match[1]}:${match[2]}`;
2330
+ if (compact.includes(":")) return compact;
2331
+ return compact;
2332
+ }
2333
+ function useCaseOk(useCases, needed) {
2334
+ if (!useCases?.length) return needed.includes("chat");
2335
+ return useCases.some((u) => needed.some((n) => u.includes(n)));
2336
+ }
2337
+ function decideLaunch(session, id) {
2338
+ const def = INTEGRATIONS[id];
2339
+ const reasons = [];
2340
+ const ollama = session.runtimes.find((r) => r.id === "ollama")?.detected;
2341
+ if (!ollama) reasons.push(t("launchNeedOllama"));
2342
+ const candidates = [];
2343
+ for (const row of session.rows) {
2344
+ const useCases = row.catalog?.useCase ?? ["chat", "code"];
2345
+ if (!useCaseOk(useCases, def.useCases)) continue;
2346
+ const tps = row.lastBenchmark?.benchmark.generationTokensPerSecond;
2347
+ candidates.push({
2348
+ modelId: row.local.id,
2349
+ ollamaTag: row.local.id.includes(":") ? row.local.id : toOllamaTag(row.local.id),
2350
+ installed: true,
2351
+ origin: row.lastBenchmark ? "measured" : "estimated",
2352
+ grade: row.compatibility?.grade,
2353
+ tps
2354
+ });
2355
+ }
2356
+ if (!candidates.length) {
2357
+ for (const model of session.catalog) {
2358
+ if (!useCaseOk(model.useCase, def.useCases)) continue;
2359
+ const fit = localCompatibility(session.hardware, model);
2360
+ if (!def.allowedGrades.includes(fit.grade)) continue;
2361
+ candidates.push({
2362
+ modelId: model.id,
2363
+ ollamaTag: toOllamaTag(model.id),
2364
+ installed: false,
2365
+ origin: "estimated",
2366
+ grade: fit.grade
2367
+ });
2368
+ }
2369
+ }
2370
+ candidates.sort((a, b) => {
2371
+ if (a.installed !== b.installed) return a.installed ? -1 : 1;
2372
+ return (GRADE_RANK[b.grade ?? "F"] ?? 0) - (GRADE_RANK[a.grade ?? "F"] ?? 0);
2373
+ });
2374
+ const pick = candidates[0];
2375
+ if (!pick) reasons.push(t("launchNoModel"));
2376
+ if (pick?.grade && !def.allowedGrades.includes(pick.grade)) {
2377
+ reasons.push(t("launchGradeFail", { grade: pick.grade, allowed: def.allowedGrades.join(",") }));
2378
+ }
2379
+ if (pick?.origin === "measured" && pick.tps !== void 0 && pick.tps < def.minGenerationTps) {
2380
+ reasons.push(t("launchSlowFail", { tps: pick.tps.toFixed(1), min: def.minGenerationTps }));
2381
+ }
2382
+ if (pick && pick.origin === "estimated" && (!pick.grade || GRADE_RANK[pick.grade] < GRADE_RANK.B)) {
2383
+ reasons.push(t("launchEstimateWeak"));
2384
+ }
2385
+ const eligible = Boolean(ollama && pick && reasons.length === 0);
2386
+ const command = pick ? `ollama launch ${def.ollamaLaunch} --model ${pick.ollamaTag}` : `ollama launch ${def.ollamaLaunch}`;
2387
+ return {
2388
+ integration: id,
2389
+ docsUrl: def.docsUrl,
2390
+ eligible,
2391
+ reasons: eligible ? [t("launchOk")] : reasons,
2392
+ modelId: pick?.modelId,
2393
+ ollamaTag: pick?.ollamaTag,
2394
+ installed: pick?.installed ?? false,
2395
+ origin: pick?.origin,
2396
+ grade: pick?.grade,
2397
+ command
2398
+ };
2399
+ }
2400
+ function decideAll(session) {
2401
+ return INTEGRATION_IDS.map((id) => decideLaunch(session, id));
2402
+ }
2403
+ function launchText(decisions) {
2404
+ const lines = [t("launchTitle"), t("launchHint"), ""];
2405
+ for (const d of decisions) {
2406
+ lines.push(`${d.integration} ${d.docsUrl}`);
2407
+ lines.push(` ${d.eligible ? "\u2713" : "\u25CB"} ${d.reasons.join(" ")}`);
2408
+ if (d.modelId) {
2409
+ lines.push(
2410
+ ` ${t("launchModel")}: ${d.modelId} ${d.grade ?? "\u2014"} ${d.origin === "measured" ? t("measured") : t("estimated")} ${d.installed ? t("tasksInstalled") : t("tasksNotInstalled")}`
2411
+ );
2412
+ }
2413
+ lines.push(` ${d.command}`);
2414
+ if (d.eligible) lines.push(` ${t("launchRunHint", { tool: d.integration })}`);
2415
+ else lines.push(` ${t("launchBlocked")}`);
2416
+ lines.push("");
2417
+ }
2418
+ return lines.join("\n");
2419
+ }
2420
+ function parseIntegration(raw) {
2421
+ if (!raw) return void 0;
2422
+ const id = raw.toLowerCase();
2423
+ if (id === "claude-code") return "claude";
2424
+ return INTEGRATION_IDS.find((item) => item === id);
2425
+ }
2426
+
2427
+ // src/integrations/execute.ts
2428
+ async function executeLaunch(decision) {
2429
+ if (!decision.eligible || !decision.ollamaTag) {
2430
+ return { ok: false, log: t("launchBlocked") };
2431
+ }
2432
+ const def = INTEGRATIONS[decision.integration];
2433
+ if (!def) return { ok: false, log: t("launchUnknown") };
2434
+ const lines = [];
2435
+ if (!decision.installed) {
2436
+ lines.push(t("launchPulling", { model: decision.ollamaTag }));
2437
+ const pull = await runCommand("ollama", ["pull", decision.ollamaTag], { timeout: 30 * 6e4 });
2438
+ if (pull.exitCode !== 0) {
2439
+ return { ok: false, log: `${lines.join("\n")}
2440
+ ${pull.stderr || t("launchPullFail")}` };
2441
+ }
2442
+ }
2443
+ const args2 = ["launch", def.ollamaLaunch, "--model", decision.ollamaTag];
2444
+ if (def.id === "openclaw" || def.id === "claude") args2.push("--yes");
2445
+ lines.push(`ollama ${args2.join(" ")}`);
2446
+ const launched = await runCommand("ollama", args2, { timeout: 12e4 });
2447
+ if (launched.exitCode !== 0) {
2448
+ return {
2449
+ ok: false,
2450
+ log: `${lines.join("\n")}
2451
+ ${launched.stderr || launched.stdout || t("launchExecFail")}`
2452
+ };
2453
+ }
2454
+ return { ok: true, log: `${lines.join("\n")}
2455
+ ${launched.stdout}`.trim() };
2456
+ }
2457
+
2458
+ // src/integrations/pull.ts
2459
+ var INSTALLABLE = ["S", "A", "B"];
2460
+ function canInstallGrade(grade) {
2461
+ return Boolean(grade && INSTALLABLE.includes(grade));
2462
+ }
2463
+ async function pullOllamaModel(modelId) {
2464
+ const tag = toOllamaTag(modelId);
2465
+ const result = await runCommand("ollama", ["pull", tag], { timeout: 30 * 6e4 });
2466
+ if (result.exitCode !== 0) {
2467
+ return { ok: false, log: result.stderr || t("launchPullFail"), tag };
2468
+ }
2469
+ return { ok: true, log: t("modelPulled", { model: tag }), tag };
2470
+ }
2471
+
2472
+ // src/tui/views/Recommend.tsx
2473
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2192
2474
  function RecommendView({ session }) {
2193
- return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: /* @__PURE__ */ jsx4(Text4, { children: recommendText(session) }) });
2475
+ const { exit } = useApp();
2476
+ const [cursor, setCursor] = useState(0);
2477
+ const [log, setLog] = useState("");
2478
+ const [busy, setBusy] = useState(false);
2479
+ const recs = session.recommendations;
2480
+ useInput((input, key) => {
2481
+ if (busy) return;
2482
+ if (key.escape) return;
2483
+ if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
2484
+ if (key.downArrow) setCursor((c) => Math.min(Math.max(recs.length - 1, 0), c + 1));
2485
+ const rec = recs[cursor];
2486
+ if (input.toLowerCase() === "i" && rec) {
2487
+ if (!canInstallGrade(rec.grade)) {
2488
+ setLog(t("installGradeBlock", { grade: rec.grade }));
2489
+ return;
2490
+ }
2491
+ setBusy(true);
2492
+ setLog(t("launchPulling", { model: rec.model.id }));
2493
+ void pullOllamaModel(rec.model.id).then((result) => {
2494
+ setLog(result.log);
2495
+ setBusy(false);
2496
+ });
2497
+ }
2498
+ const map = { o: "opencode", e: "hermes", w: "openclaw" };
2499
+ const tool = map[input.toLowerCase()];
2500
+ if (tool && rec) {
2501
+ const decision = decideLaunch(session, tool);
2502
+ if (!decision.eligible) {
2503
+ setLog(decision.reasons.join(" "));
2504
+ return;
2505
+ }
2506
+ setBusy(true);
2507
+ setLog(t("linkingTool", { tool, model: rec.model.id }));
2508
+ void executeLaunch({ ...decision, ollamaTag: decision.ollamaTag, modelId: rec.model.id }).then((result) => {
2509
+ setLog(result.log);
2510
+ setBusy(false);
2511
+ if (result.ok) exit();
2512
+ });
2513
+ }
2514
+ });
2515
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
2516
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: t("recommended") }),
2517
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: t("aboutRecommend") }),
2518
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: t("recommendKeys") }),
2519
+ recs.map((rec, i) => /* @__PURE__ */ jsxs4(Text4, { color: i === cursor ? "cyan" : void 0, children: [
2520
+ i === cursor ? "\u276F " : " ",
2521
+ /* @__PURE__ */ jsx4(Text4, { color: "magenta", children: rec.useCase.padEnd(12) }),
2522
+ rec.model.name.padEnd(22),
2523
+ " ",
2524
+ /* @__PURE__ */ jsx4(Text4, { color: gradeColor(rec.grade), bold: true, children: rec.grade }),
2525
+ " ",
2526
+ GRADE_MEANING[rec.grade]
2527
+ ] }, `${rec.useCase}-${rec.model.id}`)),
2528
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexDirection: "column", children: [
2529
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: "yellow", children: t("agentsTitle") }),
2530
+ session.agents.map((agent) => /* @__PURE__ */ jsxs4(Text4, { color: agent.detected ? "green" : "gray", children: [
2531
+ agent.detected ? "\u2713" : "\u25CB",
2532
+ " ",
2533
+ agent.label.padEnd(12),
2534
+ " ",
2535
+ agent.detected ? t("detected") : t("agentMissing")
2536
+ ] }, agent.id))
2537
+ ] }),
2538
+ log ? /* @__PURE__ */ jsx4(Text4, { color: "green", children: log }) : /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: t("credits") })
2539
+ ] });
2194
2540
  }
2195
2541
 
2196
2542
  // src/tui/views/Compare.tsx
@@ -2201,35 +2547,19 @@ function CompareView({ session }) {
2201
2547
  }
2202
2548
 
2203
2549
  // src/tui/views/Benchmark.tsx
2204
- import { Box as Box6, Text as Text6, useInput } from "ink";
2205
- import { useEffect, useRef, useState } from "react";
2206
-
2207
- // src/tui/theme.ts
2208
- function bar(percent, width = 10) {
2209
- if (percent === void 0 || Number.isNaN(percent)) return `${"\u2591".repeat(width)} N/A`;
2210
- const filled = Math.max(0, Math.min(width, Math.round(percent / 100 * width)));
2211
- return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
2212
- }
2213
- function sparkline(values, width = 24) {
2214
- const glyphs = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
2215
- if (!values.length) return glyphs[0].repeat(width);
2216
- const slice = values.slice(-width);
2217
- const max = Math.max(...slice, 1);
2218
- return slice.map((v) => glyphs[Math.min(glyphs.length - 1, Math.floor(v / max * (glyphs.length - 1)))]).join("").padStart(width, glyphs[0]);
2219
- }
2220
-
2221
- // src/tui/views/Benchmark.tsx
2222
- import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
2550
+ import { Box as Box6, Text as Text6, useInput as useInput2 } from "ink";
2551
+ import { useEffect, useRef, useState as useState2 } from "react";
2552
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
2223
2553
  function BenchmarkView(props) {
2224
- const [phase, setPhase] = useState(props.models.length ? "select" : "error");
2225
- const [sample, setSample] = useState();
2226
- const [elapsed, setElapsed] = useState(0);
2227
- const [result, setResult] = useState();
2228
- const [error, setError] = useState(props.models.length ? "" : t("noGguf"));
2554
+ const [phase, setPhase] = useState2(props.models.length ? "select" : "error");
2555
+ const [sample, setSample] = useState2();
2556
+ const [elapsed, setElapsed] = useState2(0);
2557
+ const [result, setResult] = useState2();
2558
+ const [error, setError] = useState2(props.models.length ? "" : t("noGguf"));
2229
2559
  const cpuHist = useRef([]);
2230
2560
  const memHist = useRef([]);
2231
2561
  const abortRef = useRef(void 0);
2232
- useInput((input, key) => {
2562
+ useInput2((input, key) => {
2233
2563
  if (phase === "select") {
2234
2564
  if (key.upArrow) props.setSelected(Math.max(0, props.selected - 1));
2235
2565
  if (key.downArrow) props.setSelected(Math.min(props.models.length - 1, props.selected + 1));
@@ -2279,9 +2609,9 @@ function BenchmarkView(props) {
2279
2609
  const h = props.session.hardware;
2280
2610
  const model = props.models[props.selected];
2281
2611
  if (phase === "select") {
2282
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
2612
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
2283
2613
  /* @__PURE__ */ jsx6(Text6, { bold: true, children: t("selectBenchmark") }),
2284
- props.models.map((m, i) => /* @__PURE__ */ jsxs4(Text6, { color: i === props.selected ? "cyan" : void 0, children: [
2614
+ props.models.map((m, i) => /* @__PURE__ */ jsxs5(Text6, { color: i === props.selected ? "cyan" : void 0, children: [
2285
2615
  i === props.selected ? "\u276F " : " ",
2286
2616
  m.name
2287
2617
  ] }, m.id)),
@@ -2289,9 +2619,9 @@ function BenchmarkView(props) {
2289
2619
  ] });
2290
2620
  }
2291
2621
  if (phase === "done" && result) {
2292
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
2622
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
2293
2623
  /* @__PURE__ */ jsx6(Text6, { children: resultText(result.record, result.assessment) }),
2294
- /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
2624
+ /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
2295
2625
  t("saved"),
2296
2626
  " ",
2297
2627
  result.path
@@ -2305,11 +2635,11 @@ function BenchmarkView(props) {
2305
2635
  const gpu = sample?.gpu?.utilizationPercent;
2306
2636
  const memUsed = sample?.memory.usedBytes;
2307
2637
  const memTotal = h.memory.totalBytes;
2308
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
2309
- /* @__PURE__ */ jsxs4(Box6, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [
2638
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
2639
+ /* @__PURE__ */ jsxs5(Box6, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [
2310
2640
  /* @__PURE__ */ jsx6(Text6, { bold: true, children: t("benchmarkTitle") }),
2311
2641
  /* @__PURE__ */ jsx6(Text6, { children: model?.name }),
2312
- /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
2642
+ /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
2313
2643
  h.cpu.name,
2314
2644
  " \xB7 ",
2315
2645
  formatBytes(h.memory.totalBytes, 0),
@@ -2317,9 +2647,9 @@ function BenchmarkView(props) {
2317
2647
  memoryLabel(h)
2318
2648
  ] })
2319
2649
  ] }),
2320
- /* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
2650
+ /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
2321
2651
  /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("performance") }),
2322
- /* @__PURE__ */ jsxs4(Text6, { children: [
2652
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2323
2653
  t("elapsed"),
2324
2654
  " ",
2325
2655
  elapsed.toFixed(1),
@@ -2327,21 +2657,21 @@ function BenchmarkView(props) {
2327
2657
  ] }),
2328
2658
  /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("measuredAfter") })
2329
2659
  ] }),
2330
- /* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
2660
+ /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
2331
2661
  /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("system") }),
2332
- /* @__PURE__ */ jsxs4(Text6, { children: [
2662
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2333
2663
  "CPU ",
2334
2664
  bar(cpu),
2335
2665
  " ",
2336
2666
  formatPercent(cpu)
2337
2667
  ] }),
2338
- /* @__PURE__ */ jsxs4(Text6, { children: [
2668
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2339
2669
  "GPU ",
2340
2670
  bar(gpu),
2341
2671
  " ",
2342
2672
  formatPercent(gpu)
2343
2673
  ] }),
2344
- /* @__PURE__ */ jsxs4(Text6, { children: [
2674
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2345
2675
  "Memory ",
2346
2676
  bar(memUsed && memTotal ? memUsed / memTotal * 100 : void 0),
2347
2677
  " ",
@@ -2349,36 +2679,36 @@ function BenchmarkView(props) {
2349
2679
  " / ",
2350
2680
  formatBytes(memTotal, 0)
2351
2681
  ] }),
2352
- /* @__PURE__ */ jsxs4(Text6, { children: [
2682
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2353
2683
  "Swap ",
2354
2684
  bar(0),
2355
2685
  " ",
2356
2686
  sample ? formatBytes(sample.memory.swapUsedBytes) : "N/A"
2357
2687
  ] })
2358
2688
  ] }),
2359
- /* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
2689
+ /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
2360
2690
  /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("powerThermals") }),
2361
- /* @__PURE__ */ jsxs4(Text6, { children: [
2691
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2362
2692
  "Package ",
2363
2693
  sample?.packagePowerWatts ?? "N/A",
2364
2694
  " W"
2365
2695
  ] }),
2366
- /* @__PURE__ */ jsxs4(Text6, { children: [
2696
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2367
2697
  "Temperature ",
2368
2698
  sample?.thermal?.temperatureC ?? "N/A",
2369
2699
  " \xB0C"
2370
2700
  ] }),
2371
- /* @__PURE__ */ jsxs4(Text6, { children: [
2701
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2372
2702
  "Thermal Pressure ",
2373
2703
  sample?.thermal?.pressure ?? "N/A"
2374
2704
  ] }),
2375
2705
  /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("permissionsHint") })
2376
2706
  ] }),
2377
- /* @__PURE__ */ jsxs4(Text6, { children: [
2707
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2378
2708
  sparkline(cpuHist.current),
2379
2709
  " CPU"
2380
2710
  ] }),
2381
- /* @__PURE__ */ jsxs4(Text6, { children: [
2711
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2382
2712
  sparkline(memHist.current.map((v) => v / 1024 ** 3)),
2383
2713
  " Memory"
2384
2714
  ] }),
@@ -2388,17 +2718,17 @@ function BenchmarkView(props) {
2388
2718
 
2389
2719
  // src/tui/views/History.tsx
2390
2720
  import { Box as Box7, Text as Text7 } from "ink";
2391
- import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
2721
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2392
2722
  function HistoryView({ session }) {
2393
- return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
2723
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", children: [
2394
2724
  /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: t("measured") }),
2395
2725
  /* @__PURE__ */ jsx7(Text7, { children: historyText(session.history) })
2396
2726
  ] });
2397
2727
  }
2398
2728
 
2399
2729
  // src/tui/views/Tasks.tsx
2400
- import { Box as Box8, Text as Text8, useInput as useInput2 } from "ink";
2401
- import { useMemo, useState as useState2 } from "react";
2730
+ import { Box as Box8, Text as Text8, useInput as useInput3 } from "ink";
2731
+ import { useMemo, useState as useState3 } from "react";
2402
2732
 
2403
2733
  // src/tasks/catalog.ts
2404
2734
  var TASK_CATALOG = [
@@ -2593,7 +2923,7 @@ function tasksText(plans, answers) {
2593
2923
  }
2594
2924
 
2595
2925
  // src/tui/views/Tasks.tsx
2596
- import { Fragment, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
2926
+ import { Fragment, jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
2597
2927
  var KIND_LABEL = {
2598
2928
  code: "tasksOptCode",
2599
2929
  video: "tasksOptVideo",
@@ -2602,16 +2932,16 @@ var KIND_LABEL = {
2602
2932
  chat: "tasksOptChat"
2603
2933
  };
2604
2934
  function TasksView({ session }) {
2605
- const [step, setStep] = useState2(0);
2606
- const [cursor, setCursor] = useState2(0);
2607
- const [kinds, setKinds] = useState2(["code", "chat"]);
2608
- const [scope, setScope] = useState2("installed");
2609
- const [priority, setPriority] = useState2("balanced");
2935
+ const [step, setStep] = useState3(0);
2936
+ const [cursor, setCursor] = useState3(0);
2937
+ const [kinds, setKinds] = useState3(["code", "chat"]);
2938
+ const [scope, setScope] = useState3("installed");
2939
+ const [priority, setPriority] = useState3("balanced");
2610
2940
  const plans = useMemo(
2611
2941
  () => planTasks(session, { kinds, scope, priority }),
2612
2942
  [session, kinds, scope, priority]
2613
2943
  );
2614
- useInput2((input, key) => {
2944
+ useInput3((input, key) => {
2615
2945
  if (step === 0) {
2616
2946
  if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
2617
2947
  if (key.downArrow) setCursor((c) => Math.min(WORK_KINDS.length - 1, c + 1));
@@ -2636,11 +2966,11 @@ function TasksView({ session }) {
2636
2966
  if (step === 3) {
2637
2967
  return /* @__PURE__ */ jsx8(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsx8(Text8, { children: tasksText(plans, { kinds, scope, priority }) }) });
2638
2968
  }
2639
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
2969
+ return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", children: [
2640
2970
  /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: t("tasksTitle") }),
2641
- step === 0 && /* @__PURE__ */ jsxs6(Fragment, { children: [
2971
+ step === 0 && /* @__PURE__ */ jsxs7(Fragment, { children: [
2642
2972
  /* @__PURE__ */ jsx8(Text8, { children: t("tasksQ1") }),
2643
- WORK_KINDS.map((kind, i) => /* @__PURE__ */ jsxs6(Text8, { color: i === cursor ? "cyan" : void 0, children: [
2973
+ WORK_KINDS.map((kind, i) => /* @__PURE__ */ jsxs7(Text8, { color: i === cursor ? "cyan" : void 0, children: [
2644
2974
  i === cursor ? "\u276F " : " ",
2645
2975
  "[",
2646
2976
  kinds.includes(kind) ? "x" : " ",
@@ -2648,20 +2978,20 @@ function TasksView({ session }) {
2648
2978
  t(KIND_LABEL[kind])
2649
2979
  ] }, kind))
2650
2980
  ] }),
2651
- step === 1 && /* @__PURE__ */ jsxs6(Fragment, { children: [
2981
+ step === 1 && /* @__PURE__ */ jsxs7(Fragment, { children: [
2652
2982
  /* @__PURE__ */ jsx8(Text8, { children: t("tasksQ2") }),
2653
- /* @__PURE__ */ jsxs6(Text8, { color: scope === "installed" ? "cyan" : void 0, children: [
2983
+ /* @__PURE__ */ jsxs7(Text8, { color: scope === "installed" ? "cyan" : void 0, children: [
2654
2984
  scope === "installed" ? "\u276F " : " ",
2655
2985
  t("tasksOptInstalled")
2656
2986
  ] }),
2657
- /* @__PURE__ */ jsxs6(Text8, { color: scope === "all" ? "cyan" : void 0, children: [
2987
+ /* @__PURE__ */ jsxs7(Text8, { color: scope === "all" ? "cyan" : void 0, children: [
2658
2988
  scope === "all" ? "\u276F " : " ",
2659
2989
  t("tasksOptAll")
2660
2990
  ] })
2661
2991
  ] }),
2662
- step === 2 && /* @__PURE__ */ jsxs6(Fragment, { children: [
2992
+ step === 2 && /* @__PURE__ */ jsxs7(Fragment, { children: [
2663
2993
  /* @__PURE__ */ jsx8(Text8, { children: t("tasksQ3") }),
2664
- ["speed", "balanced", "quality"].map((item) => /* @__PURE__ */ jsxs6(Text8, { color: priority === item ? "cyan" : void 0, children: [
2994
+ ["speed", "balanced", "quality"].map((item) => /* @__PURE__ */ jsxs7(Text8, { color: priority === item ? "cyan" : void 0, children: [
2665
2995
  priority === item ? "\u276F " : " ",
2666
2996
  item === "speed" ? t("tasksOptSpeed") : item === "quality" ? t("tasksOptQuality") : t("tasksOptBalanced")
2667
2997
  ] }, item))
@@ -2670,17 +3000,22 @@ function TasksView({ session }) {
2670
3000
  }
2671
3001
 
2672
3002
  // src/tui/App.tsx
2673
- import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3003
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2674
3004
  function App(props) {
2675
- const { exit } = useApp();
2676
- const [screen, setScreen] = useState3("home");
2677
- const [selected, setSelected] = useState3(0);
3005
+ const { exit } = useApp2();
3006
+ const [screen, setScreen] = useState4("home");
3007
+ const [selected, setSelected] = useState4(0);
2678
3008
  const benchable = useMemo2(
2679
3009
  () => props.session.models.filter((m) => Boolean(m.artifactPath)),
2680
3010
  [props.session.models]
2681
3011
  );
2682
- useInput3((input, key) => {
3012
+ useInput4((input, key) => {
2683
3013
  if (screen === "benchmark") return;
3014
+ if (screen === "recommend" || screen === "tasks") {
3015
+ if (letter === "q") exit();
3016
+ if (key.escape) setScreen("home");
3017
+ return;
3018
+ }
2684
3019
  const letter = input.toLowerCase();
2685
3020
  if (letter === "q" || key.escape) exit();
2686
3021
  if (letter === "b") setScreen("benchmark");
@@ -2692,7 +3027,7 @@ function App(props) {
2692
3027
  if (letter === "t") setScreen("tasks");
2693
3028
  if (key.return && screen === "home") setScreen("benchmark");
2694
3029
  });
2695
- return /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", padding: 1, children: [
3030
+ return /* @__PURE__ */ jsxs8(Box9, { flexDirection: "column", padding: 1, children: [
2696
3031
  screen === "home" && /* @__PURE__ */ jsx9(Dashboard, { session: props.session }),
2697
3032
  screen === "models" && /* @__PURE__ */ jsx9(ModelsView, { session: props.session }),
2698
3033
  screen === "hardware" && /* @__PURE__ */ jsx9(HardwareView, { session: props.session }),
@@ -2722,179 +3057,6 @@ async function renderDashboard(session, preset) {
2722
3057
  await instance.waitUntilExit();
2723
3058
  }
2724
3059
 
2725
- // src/integrations/catalog.ts
2726
- var INTEGRATIONS = {
2727
- opencode: {
2728
- id: "opencode",
2729
- docsUrl: "https://docs.ollama.com/integrations/opencode",
2730
- ollamaLaunch: "opencode",
2731
- minGenerationTps: 12,
2732
- allowedGrades: ["S", "A", "B"],
2733
- useCases: ["code", "chat", "reasoning"]
2734
- },
2735
- openclaw: {
2736
- id: "openclaw",
2737
- docsUrl: "https://docs.ollama.com/integrations/openclaw",
2738
- ollamaLaunch: "openclaw",
2739
- minGenerationTps: 12,
2740
- allowedGrades: ["S", "A", "B"],
2741
- useCases: ["code", "chat", "reasoning"]
2742
- },
2743
- hermes: {
2744
- id: "hermes",
2745
- docsUrl: "https://docs.ollama.com/integrations/hermes",
2746
- ollamaLaunch: "hermes",
2747
- minGenerationTps: 12,
2748
- allowedGrades: ["S", "A"],
2749
- useCases: ["code", "chat", "reasoning"]
2750
- },
2751
- claude: {
2752
- id: "claude",
2753
- docsUrl: "https://docs.ollama.com/integrations/claude-code",
2754
- ollamaLaunch: "claude",
2755
- minGenerationTps: 12,
2756
- allowedGrades: ["S", "A", "B"],
2757
- useCases: ["code", "chat", "reasoning"]
2758
- }
2759
- };
2760
-
2761
- // src/integrations/types.ts
2762
- var INTEGRATION_IDS = ["opencode", "openclaw", "hermes", "claude"];
2763
-
2764
- // src/integrations/decide.ts
2765
- var GRADE_RANK = { S: 5, A: 4, B: 3, C: 2, D: 1, F: 0 };
2766
- function toOllamaTag(id) {
2767
- const compact = id.toLowerCase().replace(/_/g, "-");
2768
- const match = compact.match(/^([a-z0-9.]+(?:-[a-z0-9.]+)*)-(\d+(?:\.\d+)?b)$/i);
2769
- if (match) return `${match[1]}:${match[2]}`;
2770
- if (compact.includes(":")) return compact;
2771
- return compact;
2772
- }
2773
- function useCaseOk(useCases, needed) {
2774
- if (!useCases?.length) return needed.includes("chat");
2775
- return useCases.some((u) => needed.some((n) => u.includes(n)));
2776
- }
2777
- function decideLaunch(session, id) {
2778
- const def = INTEGRATIONS[id];
2779
- const reasons = [];
2780
- const ollama = session.runtimes.find((r) => r.id === "ollama")?.detected;
2781
- if (!ollama) reasons.push(t("launchNeedOllama"));
2782
- const candidates = [];
2783
- for (const row of session.rows) {
2784
- const useCases = row.catalog?.useCase ?? ["chat", "code"];
2785
- if (!useCaseOk(useCases, def.useCases)) continue;
2786
- const tps = row.lastBenchmark?.benchmark.generationTokensPerSecond;
2787
- candidates.push({
2788
- modelId: row.local.id,
2789
- ollamaTag: row.local.id.includes(":") ? row.local.id : toOllamaTag(row.local.id),
2790
- installed: true,
2791
- origin: row.lastBenchmark ? "measured" : "estimated",
2792
- grade: row.compatibility?.grade,
2793
- tps
2794
- });
2795
- }
2796
- if (!candidates.length) {
2797
- for (const model of session.catalog) {
2798
- if (!useCaseOk(model.useCase, def.useCases)) continue;
2799
- const fit = localCompatibility(session.hardware, model);
2800
- if (!def.allowedGrades.includes(fit.grade)) continue;
2801
- candidates.push({
2802
- modelId: model.id,
2803
- ollamaTag: toOllamaTag(model.id),
2804
- installed: false,
2805
- origin: "estimated",
2806
- grade: fit.grade
2807
- });
2808
- }
2809
- }
2810
- candidates.sort((a, b) => {
2811
- if (a.installed !== b.installed) return a.installed ? -1 : 1;
2812
- return (GRADE_RANK[b.grade ?? "F"] ?? 0) - (GRADE_RANK[a.grade ?? "F"] ?? 0);
2813
- });
2814
- const pick = candidates[0];
2815
- if (!pick) reasons.push(t("launchNoModel"));
2816
- if (pick?.grade && !def.allowedGrades.includes(pick.grade)) {
2817
- reasons.push(t("launchGradeFail", { grade: pick.grade, allowed: def.allowedGrades.join(",") }));
2818
- }
2819
- if (pick?.origin === "measured" && pick.tps !== void 0 && pick.tps < def.minGenerationTps) {
2820
- reasons.push(t("launchSlowFail", { tps: pick.tps.toFixed(1), min: def.minGenerationTps }));
2821
- }
2822
- if (pick && pick.origin === "estimated" && (!pick.grade || GRADE_RANK[pick.grade] < GRADE_RANK.B)) {
2823
- reasons.push(t("launchEstimateWeak"));
2824
- }
2825
- const eligible = Boolean(ollama && pick && reasons.length === 0);
2826
- const command = pick ? `ollama launch ${def.ollamaLaunch} --model ${pick.ollamaTag}` : `ollama launch ${def.ollamaLaunch}`;
2827
- return {
2828
- integration: id,
2829
- docsUrl: def.docsUrl,
2830
- eligible,
2831
- reasons: eligible ? [t("launchOk")] : reasons,
2832
- modelId: pick?.modelId,
2833
- ollamaTag: pick?.ollamaTag,
2834
- installed: pick?.installed ?? false,
2835
- origin: pick?.origin,
2836
- grade: pick?.grade,
2837
- command
2838
- };
2839
- }
2840
- function decideAll(session) {
2841
- return INTEGRATION_IDS.map((id) => decideLaunch(session, id));
2842
- }
2843
- function launchText(decisions) {
2844
- const lines = [t("launchTitle"), t("launchHint"), ""];
2845
- for (const d of decisions) {
2846
- lines.push(`${d.integration} ${d.docsUrl}`);
2847
- lines.push(` ${d.eligible ? "\u2713" : "\u25CB"} ${d.reasons.join(" ")}`);
2848
- if (d.modelId) {
2849
- lines.push(
2850
- ` ${t("launchModel")}: ${d.modelId} ${d.grade ?? "\u2014"} ${d.origin === "measured" ? t("measured") : t("estimated")} ${d.installed ? t("tasksInstalled") : t("tasksNotInstalled")}`
2851
- );
2852
- }
2853
- lines.push(` ${d.command}`);
2854
- if (d.eligible) lines.push(` ${t("launchRunHint", { tool: d.integration })}`);
2855
- else lines.push(` ${t("launchBlocked")}`);
2856
- lines.push("");
2857
- }
2858
- return lines.join("\n");
2859
- }
2860
- function parseIntegration(raw) {
2861
- if (!raw) return void 0;
2862
- const id = raw.toLowerCase();
2863
- if (id === "claude-code") return "claude";
2864
- return INTEGRATION_IDS.find((item) => item === id);
2865
- }
2866
-
2867
- // src/integrations/execute.ts
2868
- async function executeLaunch(decision) {
2869
- if (!decision.eligible || !decision.ollamaTag) {
2870
- return { ok: false, log: t("launchBlocked") };
2871
- }
2872
- const def = INTEGRATIONS[decision.integration];
2873
- if (!def) return { ok: false, log: t("launchUnknown") };
2874
- const lines = [];
2875
- if (!decision.installed) {
2876
- lines.push(t("launchPulling", { model: decision.ollamaTag }));
2877
- const pull = await runCommand("ollama", ["pull", decision.ollamaTag], { timeout: 30 * 6e4 });
2878
- if (pull.exitCode !== 0) {
2879
- return { ok: false, log: `${lines.join("\n")}
2880
- ${pull.stderr || t("launchPullFail")}` };
2881
- }
2882
- }
2883
- const args2 = ["launch", def.ollamaLaunch, "--model", decision.ollamaTag];
2884
- if (def.id === "openclaw" || def.id === "claude") args2.push("--yes");
2885
- lines.push(`ollama ${args2.join(" ")}`);
2886
- const launched = await runCommand("ollama", args2, { timeout: 12e4 });
2887
- if (launched.exitCode !== 0) {
2888
- return {
2889
- ok: false,
2890
- log: `${lines.join("\n")}
2891
- ${launched.stderr || launched.stdout || t("launchExecFail")}`
2892
- };
2893
- }
2894
- return { ok: true, log: `${lines.join("\n")}
2895
- ${launched.stdout}`.trim() };
2896
- }
2897
-
2898
3060
  // src/cli/help.ts
2899
3061
  function helpText() {
2900
3062
  return t("help");
@@ -2953,9 +3115,36 @@ ${runtimesText(session)}`),
2953
3115
  case "models":
2954
3116
  print(args2.json ? session.rows : explained("aboutModels", modelsText(session)), args2.json);
2955
3117
  return 0;
2956
- case "recommend":
2957
- print(args2.json ? session.recommendations : explained("aboutRecommend", recommendText(session)), args2.json);
3118
+ case "recommend": {
3119
+ if (args2.install && args2.yes) {
3120
+ const rec = session.recommendations.find((r) => canInstallGrade(r.grade));
3121
+ if (!rec) {
3122
+ print(t("installNone"), args2.json);
3123
+ return 1;
3124
+ }
3125
+ const pulled = await pullOllamaModel(rec.model.id);
3126
+ print(args2.json ? pulled : explained("aboutRecommend", pulled.log), args2.json);
3127
+ return pulled.ok ? 0 : 1;
3128
+ }
3129
+ if (args2.link && args2.yes) {
3130
+ const tool = parseIntegration(args2.link);
3131
+ if (!tool || tool === "claude") {
3132
+ process.stderr.write(`${t("launchNeedTool")}
3133
+ `);
3134
+ return 1;
3135
+ }
3136
+ const decision = decideLaunch(session, tool);
3137
+ if (!decision.eligible) {
3138
+ print(explained("aboutLaunch", launchText([decision])), args2.json);
3139
+ return 1;
3140
+ }
3141
+ const result = await executeLaunch(decision);
3142
+ print(args2.json ? result : explained("aboutLaunch", result.log), args2.json);
3143
+ return result.ok ? 0 : 1;
3144
+ }
3145
+ print(args2.json ? { recommendations: session.recommendations, agents: session.agents } : explained("aboutRecommend", recommendText(session)), args2.json);
2958
3146
  return 0;
3147
+ }
2959
3148
  case "launch": {
2960
3149
  const tool = parseIntegration(args2.positional[0]);
2961
3150
  const decisions = tool ? [decideLaunch(session, tool)] : decideAll(session);
@@ -3059,10 +3248,10 @@ ${runtimesText(session)}`),
3059
3248
  // src/cli/index.ts
3060
3249
  var args = parseArgs(process.argv);
3061
3250
  run(args).then((code) => {
3062
- process.exitCode = code;
3251
+ process.exit(code);
3063
3252
  }).catch((error) => {
3064
3253
  const message = error instanceof Error ? error.message : String(error);
3065
3254
  process.stderr.write(`${message}
3066
3255
  `);
3067
- process.exitCode = 1;
3256
+ process.exit(1);
3068
3257
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pudu-ai",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Pudu-AI — local AI hardware & benchmark lab for the terminal",
5
5
  "keywords": [
6
6
  "ai",
@@ -24,7 +24,7 @@
24
24
  "author": "devjaime",
25
25
  "type": "module",
26
26
  "bin": {
27
- "pudu-ai": "./bin/pudu-ai.mjs"
27
+ "pudu-ai": "dist/cli/index.js"
28
28
  },
29
29
  "exports": {
30
30
  ".": "./dist/cli/index.js"
@@ -39,7 +39,7 @@
39
39
  "node": ">=20"
40
40
  },
41
41
  "scripts": {
42
- "build": "tsc -p tsconfig.build.json",
42
+ "build": "npx --yes esbuild src/cli/index.ts --bundle --platform=node --format=esm --outfile=dist/cli/index.js --packages=external --jsx=automatic && node scripts/add-shebang.mjs",
43
43
  "dev": "tsx src/cli/index.ts",
44
44
  "pudu-ai": "tsx src/cli/index.ts",
45
45
  "start": "node dist/cli/index.js",
@@ -47,7 +47,7 @@
47
47
  "test:watch": "vitest",
48
48
  "typecheck": "tsc --noEmit -p tsconfig.json",
49
49
  "lint": "tsc --noEmit -p tsconfig.json",
50
- "prepublishOnly": "npm run typecheck && npm test && npm run build"
50
+ "prepublishOnly": "npm run build"
51
51
  },
52
52
  "dependencies": {
53
53
  "execa": "^9.6.0",