llm-tuner 0.3.0 → 0.4.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/README.es.md CHANGED
@@ -41,6 +41,7 @@ Ningún LLM decide la configuración. LLM Tuner lee la topología de tu hardware
41
41
  - **Soporte de motores locales**: Compatibilidad directa con LM Studio (a través de `llama-server`) y Ollama (instancias privadas aisladas).
42
42
  - **Presets persistentes**: Mide una única vez por combinación de hardware, modelo, contexto y perfil. Invalida presets automáticamente si el hardware o el archivo del modelo cambian.
43
43
  - **Doble interfaz**: Asistente en consola (`llm-tuner`) y aplicación gráfica de escritorio (`llm-tuner-desktop`).
44
+ - **Integración MCP para agentes de IA**: Conecta tus motores locales (LM Studio, Ollama) como servidores Model Context Protocol (MCP) en Claude Desktop, Claude Code, OpenCode, Antigravity, VS Code y ChatGPT/Codex con nombres personalizados y seguridad multiplataforma.
44
45
  - **Bilingüe**: Localización completa en español e inglés tanto en terminal como en entorno gráfico.
45
46
 
46
47
  ---
@@ -93,15 +94,30 @@ Requisitos previos:
93
94
 
94
95
  ## Uso
95
96
 
96
- ### Asistente interactivo
97
+ ### CLI interactiva y comandos slash
97
98
 
98
- Ejecuta el asistente en la terminal:
99
+ Ejecuta la interfaz en la terminal:
99
100
 
100
101
  ```bash
101
102
  llm-tuner
102
103
  ```
103
104
 
104
- Te guiará paso a paso en la selección de motor, modelo, contexto, perfil recomendado y carga afinada.
105
+ Ejecutar `llm-tuner` sin argumentos abre un menú interactivo para seleccionar la acción deseada:
106
+ - `/load`: Ajustar y cargar un modelo con la configuración óptima para tu hardware
107
+ - `/mcp`: Conectar tu motor local como servidor MCP con agentes de IA (Claude, OpenCode, VS Code, Codex)
108
+ - `/presets`: Listar e inspeccionar los presets guardados
109
+ - `/settings`: Configurar idioma (`en` / `es`) y tema visual (`system` / `light` / `dark`)
110
+ - `/help`: Mostrar ayuda y opciones de uso
111
+ - `/exit`: Salir del menú
112
+
113
+ También puedes ejecutar cualquier comando slash directamente desde tu terminal:
114
+
115
+ ```bash
116
+ llm-tuner /load
117
+ llm-tuner /mcp
118
+ llm-tuner /presets
119
+ llm-tuner /settings
120
+ ```
105
121
 
106
122
  ### Aplicación de escritorio
107
123
 
package/README.md CHANGED
@@ -41,6 +41,7 @@ No LLM decides the configuration. LLM Tuner reads your hardware topology and mod
41
41
  - **Engine Support**: First-class support for both LM Studio (direct `llama-server` management) and Ollama (isolated private benchmarks).
42
42
  - **Persistent Presets**: Measures once per hardware, model, context, and profile. Automatically invalidates presets if hardware or model weights change.
43
43
  - **Dual Interfaces**: Interactive terminal wizard (`llm-tuner`) and cross-platform desktop UI (`llm-tuner-desktop`).
44
+ - **AI Agent MCP Integration**: Connect your local engines (LM Studio, Ollama) as Model Context Protocol (MCP) servers to Claude Desktop, Claude Code, OpenCode, Antigravity, VS Code, and ChatGPT/Codex with custom naming and cross-platform safety.
44
45
  - **Bilingual**: Full English and Spanish localization for both terminal and desktop interfaces.
45
46
 
46
47
  ---
@@ -93,15 +94,30 @@ Prerequisites:
93
94
 
94
95
  ## Usage
95
96
 
96
- ### Interactive Wizard
97
+ ### Interactive CLI & Slash Commands
97
98
 
98
- Launch the terminal wizard:
99
+ Launch the terminal interface:
99
100
 
100
101
  ```bash
101
102
  llm-tuner
102
103
  ```
103
104
 
104
- Guides you interactively through engine selection, model selection, context length, profile recommendation, and automatic tuning.
105
+ Running `llm-tuner` without arguments presents an interactive menu to choose a command:
106
+ - `/load`: Tune and load a model with optimal hardware settings
107
+ - `/mcp`: Connect your local engine as an MCP server to AI agents (Claude, OpenCode, VS Code, Codex)
108
+ - `/presets`: Inspect saved tuning presets
109
+ - `/settings`: Configure language (`en` / `es`) and visual theme (`system` / `light` / `dark`)
110
+ - `/help`: Display help and usage options
111
+ - `/exit`: Exit the interactive menu
112
+
113
+ You can also run any slash command directly from your terminal:
114
+
115
+ ```bash
116
+ llm-tuner /load
117
+ llm-tuner /mcp
118
+ llm-tuner /presets
119
+ llm-tuner /settings
120
+ ```
105
121
 
106
122
  ### Desktop App
107
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-tuner",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Detects your hardware and finds the fastest configuration to run local LLMs with LM Studio or Ollama.",
5
5
  "keywords": [
6
6
  "llm",
@@ -0,0 +1,85 @@
1
+ export class TerminalAnimation {
2
+ constructor(colors, isTTY = true) {
3
+ this.c = colors;
4
+ this.isTTY = Boolean(isTTY);
5
+ this.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
+ this.frameIdx = 0;
7
+ this.timer = null;
8
+ this.statusText = '';
9
+ this.pct = 0;
10
+ this.telemetry = null;
11
+ this.isActive = false;
12
+ }
13
+
14
+ start(initialStatus = '') {
15
+ if (!this.isTTY) return;
16
+ this.statusText = initialStatus;
17
+ this.isActive = true;
18
+ this.timer = setInterval(() => {
19
+ this.tick();
20
+ }, 80);
21
+ this.timer?.unref?.();
22
+ }
23
+
24
+ updateStatus(text) {
25
+ this.statusText = text || '';
26
+ this.tick();
27
+ }
28
+
29
+ updateProgress(pct) {
30
+ this.pct = Math.min(100, Math.max(0, pct ?? 0));
31
+ this.tick();
32
+ }
33
+
34
+ updateTelemetry(telem) {
35
+ this.telemetry = telem;
36
+ this.tick();
37
+ }
38
+
39
+ clear() {
40
+ if (this.isTTY && this.isActive) {
41
+ process.stdout.write('\r\x1b[K');
42
+ }
43
+ }
44
+
45
+ tick() {
46
+ if (!this.isTTY || !this.isActive) return;
47
+ const frame = this.frames[(this.frameIdx++) % this.frames.length];
48
+
49
+ const barLen = 16;
50
+ const filled = Math.round((this.pct / 100) * barLen);
51
+ const bar = this.c.cyan('━'.repeat(filled)) + this.c.dim('─'.repeat(barLen - filled));
52
+
53
+ let telemStr = '';
54
+ if (this.telemetry) {
55
+ if (this.telemetry.cpu) {
56
+ telemStr += ` · CPU ${this.telemetry.cpu.percent}%`;
57
+ }
58
+ if (this.telemetry.ram) {
59
+ const used = (this.telemetry.ram.usedBytes / 1024 ** 3).toFixed(1);
60
+ const total = (this.telemetry.ram.totalBytes / 1024 ** 3).toFixed(1);
61
+ telemStr += ` · RAM ${used}/${total} GB`;
62
+ }
63
+ if (this.telemetry.gpus?.length) {
64
+ telemStr += ' · ' + this.telemetry.gpus.map((g) => {
65
+ const used = (g.usedBytes / 1024 ** 3).toFixed(1);
66
+ const total = (g.totalBytes / 1024 ** 3).toFixed(1);
67
+ return `GPU${g.index} ${used}/${total} GB`;
68
+ }).join(' ');
69
+ }
70
+ }
71
+
72
+ const statusPart = this.statusText ? `${this.statusText} ` : '';
73
+ const line = ` ${this.c.cyan(frame)} ${statusPart}[${bar}] ${String(this.pct).padStart(3)}%${this.c.dim(telemStr)}`;
74
+ process.stdout.write(`\r\x1b[K${line}`);
75
+ }
76
+
77
+ stop() {
78
+ if (this.timer) {
79
+ clearInterval(this.timer);
80
+ this.timer = null;
81
+ }
82
+ this.clear();
83
+ this.isActive = false;
84
+ }
85
+ }
@@ -0,0 +1,12 @@
1
+ export function getColors(theme = 'dark') {
2
+ const isLight = theme === 'light';
3
+ return {
4
+ b: (s) => `\x1b[1m${s}\x1b[0m`,
5
+ dim: (s) => isLight ? `\x1b[90m${s}\x1b[0m` : `\x1b[2m${s}\x1b[0m`,
6
+ g: (s) => isLight ? `\x1b[38;2;40;138;0m${s}\x1b[0m` : `\x1b[38;2;101;255;0m${s}\x1b[0m`,
7
+ y: (s) => isLight ? `\x1b[33m${s}\x1b[0m` : `\x1b[93m${s}\x1b[0m`,
8
+ r: (s) => isLight ? `\x1b[38;2;214;21;0m${s}\x1b[0m` : `\x1b[38;2;255;26;0m${s}\x1b[0m`,
9
+ cyan: (s) => isLight ? `\x1b[38;2;0;124;153m${s}\x1b[0m` : `\x1b[38;2;0;229;255m${s}\x1b[0m`,
10
+ purple: (s) => isLight ? `\x1b[38;2;120;0;199m${s}\x1b[0m` : `\x1b[38;2;154;0;255m${s}\x1b[0m`,
11
+ };
12
+ }
@@ -0,0 +1,7 @@
1
+ import { presetsDir } from '../../core/index.js';
2
+ import { t as translate } from '../../i18n/index.js';
3
+
4
+ export async function helpCommand(args, { lang }) {
5
+ const t = (key, params) => translate(lang, key, params);
6
+ console.log(t('cli.help', { presetsDir: presetsDir() }));
7
+ }
@@ -0,0 +1,338 @@
1
+ import { select, input, confirm, search, Separator } from '@inquirer/prompts';
2
+ import {
3
+ detectHardware,
4
+ detectEngines,
5
+ Tuner,
6
+ installPlans,
7
+ runInstall,
8
+ listPresets,
9
+ fmtBytes,
10
+ readSettings,
11
+ writeSettings,
12
+ DEFAULT_PROFILE,
13
+ TunerError,
14
+ checkUpdate,
15
+ } from '../../core/index.js';
16
+ import { t as translate } from '../../i18n/index.js';
17
+ import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile, parseContext } from '../profile.js';
18
+ import { getColors } from '../colors.js';
19
+ import { withNav, NAV_BACK, backChoice } from '../nav.js';
20
+ import { TerminalAnimation } from '../animation.js';
21
+
22
+ export async function loadCommand(args, { lang, theme }) {
23
+ const c = getColors(theme);
24
+ const t = (key, params) => translate(lang, key, params);
25
+
26
+ const describe = (cand) => {
27
+ const where = cand.fullOffload ? t('candidate.allGpu') : t('candidate.layersGpu', { gpu: cand.gpuLayers, total: cand.totalLayers });
28
+ return `KV ${cand.kvType} · ${where}${cand.cpuMoeLayers ? t('candidate.expertsRam', { layers: cand.cpuMoeLayers }) : ''}${t('candidate.threads', { n: cand.threads })}${cand.probe ? t('candidate.probe') : ''}`;
29
+ };
30
+
31
+ const benchErrorText = (b) => {
32
+ return b.errorCode ? t(b.errorCode, b.errorParams) : b.error;
33
+ };
34
+
35
+ const vramStr = (peaks = {}) => {
36
+ const e = Object.entries(peaks || {});
37
+ return e.length ? e.map(([i, b]) => `GPU${i} ${(b / 1024 ** 3).toFixed(1)} GB`).join(' / ') : t('common.na');
38
+ };
39
+
40
+ const updatePromise = checkUpdate().catch(() => null);
41
+ const isTTY = Boolean(process.stdout.isTTY && !args.json);
42
+ const anim = new TerminalAnimation(c, isTTY);
43
+
44
+ const out = (...a) => {
45
+ if (args.json) return;
46
+ anim.clear();
47
+ console.log(...a);
48
+ };
49
+
50
+ out([
51
+ '',
52
+ ` ${c.cyan(' ╭───')}${c.dim('╷ ╷ ╷')}${c.cyan('───╮')}`,
53
+ ` ${c.cyan('╭─┤ ')}${c.cyan('┌───┐')}${c.cyan(' ├─╮ ')}${c.b('LLM Tuner')}`,
54
+ ` ${c.cyan('│ ')}${c.dim('╶┤')}${c.b(' │ █ │ ')}${c.dim('├╴')}${c.cyan('▲ │ ')}${c.dim(t('web.tagline'))}`,
55
+ ` ${c.cyan('╰─┤ ')}${c.cyan('└───┘')}${c.cyan(' ├──╯')}`,
56
+ ` ${c.cyan(' ╰───')}${c.dim('╵ ╵ ╵')}${c.cyan('───╯')}`,
57
+ '',
58
+ ].join('\n'));
59
+
60
+ const updateInfo = await updatePromise;
61
+ if (updateInfo?.updateAvailable) {
62
+ out(c.y(` ▲ ${t('cli.updateAvailable', { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`));
63
+ out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
64
+ }
65
+
66
+ const hw = await detectHardware();
67
+ out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
68
+ for (const g of hw.gpus) out(c.dim(` GPU${g.index} ${g.name} · ${fmtBytes(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}${g.displayAttached ? t('cli.display') : ''}`));
69
+ if (!hw.gpus.length) out(c.y(` ${t('cli.noGpu')}`));
70
+
71
+ let det = await detectEngines(hw);
72
+ let engineId = args.engine;
73
+ let tuner = null;
74
+ let models = [];
75
+ let modelKey = args.model;
76
+ let ctx = parseContext(args.ctx);
77
+ let profile = args.profile;
78
+
79
+ let currentStep = 1;
80
+
81
+ while (currentStep >= 1 && currentStep <= 4) {
82
+ if (currentStep === 1) {
83
+ if (args.engine) {
84
+ engineId = args.engine;
85
+ currentStep = 2;
86
+ continue;
87
+ }
88
+
89
+ const res = await withNav(() => select({
90
+ message: t('cli.whichEngine'),
91
+ default: det.recommended,
92
+ choices: [
93
+ ...det.engines.map((e) => ({
94
+ name: `${e.name} ${e.detection.installed ? c.g(t('cli.installed')) + (e.detection.version ? c.dim(' v' + e.detection.version) : '') : c.dim(t('cli.notInstalled'))}${e.id === det.recommended ? c.y(` ${t('cli.recommended')}`) : ''}`,
95
+ value: e.id,
96
+ })),
97
+ new Separator(),
98
+ backChoice(t, c),
99
+ ],
100
+ }));
101
+
102
+ if (res === NAV_BACK) return;
103
+ engineId = res;
104
+ currentStep = 2;
105
+ }
106
+ else if (currentStep === 2) {
107
+ if (!tuner || tuner.engine.id !== engineId) {
108
+ if (!det.engines.find((e) => e.id === engineId)?.detection.installed) {
109
+ await installFlow(engineId, { args, t, c });
110
+ det = await detectEngines(hw);
111
+ if (!det.engines.find((e) => e.id === engineId).detection.installed) throw new TunerError('errors.installNotFinished');
112
+ }
113
+ tuner = await Tuner.create(engineId);
114
+ models = await tuner.listModels();
115
+ }
116
+
117
+ if (args.model) {
118
+ modelKey = args.model;
119
+ currentStep = 3;
120
+ continue;
121
+ }
122
+
123
+ if (!models.length) {
124
+ out(c.y(`\n${t('cli.noModels', { engine: tuner.engine.name })}`));
125
+ const name = await input({ message: engineId === 'ollama' ? t('cli.downloadOllama') : t('cli.downloadLms') });
126
+ const bin = tuner.ctx.detection.bin;
127
+ const r = await runInstall(engineId === 'ollama' ? { cmd: bin, args: ['pull', name] } : { cmd: bin, args: ['get', name, '-y'] }, (l) => out(c.dim(' ' + l)));
128
+ if (r.code !== 0) throw new TunerError('errors.modelDownloadFailed');
129
+ models = await tuner.listModels();
130
+ }
131
+
132
+ const res = await withNav(() => search({
133
+ message: t('cli.model'),
134
+ source: (term) => [
135
+ backChoice(t, c),
136
+ new Separator(),
137
+ ...models
138
+ .filter((m) => !term || `${m.key} ${m.name}`.toLowerCase().includes(term.toLowerCase()))
139
+ .map((m) => ({ name: `${m.name} ${c.dim(`${fmtBytes(m.sizeBytes)}${m.quant ? ' · ' + m.quant : ''}`)}`, value: m.key })),
140
+ ],
141
+ }));
142
+
143
+ if (res === NAV_BACK) {
144
+ if (args.engine) return;
145
+ currentStep = 1;
146
+ continue;
147
+ }
148
+
149
+ modelKey = res;
150
+ currentStep = 3;
151
+ }
152
+ else if (currentStep === 3) {
153
+ if (args.ctx) {
154
+ ctx = parseContext(args.ctx);
155
+ currentStep = 4;
156
+ continue;
157
+ }
158
+
159
+ const presets = await listPresets({ engine: engineId, modelKey });
160
+ const preview = await tuner.plan(modelKey, 8192);
161
+ const limit = preview.model.meta.trainContext || 131072;
162
+ const list = Object.entries(preview.maxContext).map(([k, v]) => `KV ${k} ${v ? Math.floor(v / 1024) + 'K' : '—'}`).join(' · ');
163
+ out(c.dim(` ${t('cli.maxFullGpu', { list, train: Math.floor(limit / 1024) })}`));
164
+ if (presets.length) out(c.dim(` ${t('cli.tunedContexts', { list: presets.map((p) => `${p.ctx / 1024}K (${p.shortTps} t/s)`).join(', ') })}`));
165
+
166
+ let inputBuffer = '';
167
+ const res = await withNav(() => input({
168
+ message: `${t('cli.context')} ${c.dim(`(Backspace: ${t('common.back')})`)}`,
169
+ default: String(presets.at(-1)?.ctx || Math.min(16384, limit)),
170
+ validate: (v) => {
171
+ inputBuffer = v;
172
+ if (v.toLowerCase() === 'b' || v.toLowerCase() === 'back') return true;
173
+ return parseContext(v, limit) !== null || t('cli.contextRange', { limit });
174
+ },
175
+ }), { isTextInput: true, getInputValue: () => inputBuffer });
176
+
177
+ if (res === NAV_BACK || res?.toLowerCase?.() === 'b' || res?.toLowerCase?.() === 'back') {
178
+ if (args.model) {
179
+ if (args.engine) return;
180
+ currentStep = 1;
181
+ } else {
182
+ currentStep = 2;
183
+ }
184
+ continue;
185
+ }
186
+
187
+ ctx = parseContext(res, limit);
188
+ currentStep = 4;
189
+ }
190
+ else if (currentStep === 4) {
191
+ if (args.profile || !shouldAskProfile(args)) {
192
+ profile = args.profile || DEFAULT_PROFILE;
193
+ break;
194
+ }
195
+
196
+ const plan = await tuner.profilePlan(modelKey, ctx);
197
+ const { profile: stored } = await readSettings();
198
+ for (const line of profileNotes(plan, t)) {
199
+ out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
200
+ }
201
+
202
+ const choicesObj = profileChoices(plan, t, { stored, mark: c.y, dim: c.dim });
203
+ const res = await withNav(() => select({
204
+ message: t('cli.profile'),
205
+ ...choicesObj,
206
+ choices: [
207
+ ...choicesObj.choices,
208
+ new Separator(),
209
+ backChoice(t, c),
210
+ ],
211
+ }));
212
+
213
+ if (res === NAV_BACK) {
214
+ if (args.ctx) {
215
+ if (args.model) {
216
+ if (args.engine) return;
217
+ currentStep = 1;
218
+ } else {
219
+ currentStep = 2;
220
+ }
221
+ } else {
222
+ currentStep = 3;
223
+ }
224
+ continue;
225
+ }
226
+
227
+ profile = res;
228
+ try {
229
+ await writeSettings({ profile });
230
+ } catch (e) {
231
+ console.error(c.y(t('cli.profileNotSaved', { error: e.message })));
232
+ }
233
+ const switchNote = profileSwitchNote(plan, profile, t, tuner.engine.name);
234
+ if (switchNote) out(c.dim(` ${switchNote}`));
235
+ break;
236
+ }
237
+ }
238
+
239
+ // 5. Load & Benchmark Execution with Desktop-style live animation
240
+ if (!tuner) {
241
+ tuner = await Tuner.create(engineId);
242
+ }
243
+
244
+ const advisedByWizard = !args.profile;
245
+ let previous = null;
246
+
247
+ anim.start(t('status.loading'));
248
+
249
+ tuner.on('progress', (e) => {
250
+ if (args.json) return;
251
+
252
+ if (e.type === 'telemetry') {
253
+ anim.updateTelemetry(e);
254
+ return;
255
+ }
256
+ if (e.progress) {
257
+ anim.updateProgress(e.progress.pct);
258
+ }
259
+ if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
260
+ for (const line of profileEventLines(e, previous, t, advisedByWizard ? null : tuner.engine.name)) {
261
+ out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
262
+ }
263
+ }
264
+ previous = e;
265
+ if (e.type === 'status') {
266
+ anim.updateStatus(t(e.code, e.params));
267
+ out(c.dim(`\n${t(e.code, e.params)}`));
268
+ }
269
+ if (e.type === 'preset-hit') {
270
+ out(c.g(`\n✓ ${t('cli.presetHit', { date: e.preset.createdAt.slice(0, 10), config: describe(e.preset.candidate), tps: e.preset.bench.short.genTps })}`));
271
+ }
272
+ if (e.type === 'candidate-start') {
273
+ anim.updateStatus(`[${e.index + 1}/${e.total}] ${describe(e.candidate)}`);
274
+ out(` [${e.index + 1}/${e.total}] ${describe(e.candidate)}`);
275
+ }
276
+ if (e.type === 'bench-progress' && e.phase === 'deep') {
277
+ out(c.dim(` ${t('common.longPrompt', { tokens: e.promptTokens })}`));
278
+ }
279
+ if (e.type === 'candidate-done') {
280
+ const b = e.bench;
281
+ out(b.ok
282
+ ? ` ${c.g('✓')} ${t('cli.benchShort', { tps: c.b(b.short.genTps + ' t/s') })}${b.deep ? t('cli.benchDeep', { tps: c.b(b.deep.genTps + ' t/s'), tokens: b.deep.promptTokens }) : ''} · CPU ${b.cpu.avg}% · VRAM ${vramStr(b.vramPeakBytes)}`
283
+ : c.r(` ✗ ${benchErrorText(b)}`));
284
+ }
285
+ if (e.type === 'preset-saved') {
286
+ out(c.g(`\n✓ ${t('common.presetSaved', { file: e.file })}`));
287
+ }
288
+ });
289
+
290
+ let result;
291
+ try {
292
+ result = await tuner.load(modelKey, ctx, {
293
+ profile,
294
+ force: !!args.force,
295
+ dryRun: !!args['dry-run'],
296
+ maxCandidates: Number(args.candidates) || 3,
297
+ confirmApply: async (preview) => {
298
+ anim.stop();
299
+ out(c.b(`\n${t('cli.changes')}`));
300
+ if (engineId === 'lmstudio') {
301
+ preview.files.forEach((f) => out(` ${f}`));
302
+ if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
303
+ } else {
304
+ out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
305
+ out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
306
+ }
307
+ const confirmed = args.yes || await confirm({ message: t('cli.confirmApply'), default: true });
308
+ if (confirmed) anim.start(t('status.applying'));
309
+ return confirmed;
310
+ },
311
+ });
312
+ } finally {
313
+ anim.stop();
314
+ }
315
+
316
+ if (args['dry-run']) out(c.y(`\n${t('cli.dryRun')}`));
317
+ if (result.applied?.backups?.length) out(c.dim(` ${t('common.backups', { list: result.applied.backups.join(', ') })}`));
318
+ if (result.applied?.pendingCommands?.length) {
319
+ out(c.y(`\n${t('cli.pending')}`));
320
+ result.applied.pendingCommands.forEach((cmd) => out(` ${cmd}`));
321
+ }
322
+ const l = result.loaded;
323
+ if (l) out(l.ok
324
+ ? c.g(`\n✓ ${t('cli.loaded', { engine: tuner.engine.name, tps: c.b(l.short.genTps + ' t/s') })}${l.model ? t('cli.usesModel', { model: l.model }) : ''}`)
325
+ : c.r(`\n✗ ${t('cli.loadFailed', { error: benchErrorText(l) })}`));
326
+ if (args.json) console.log(JSON.stringify(result, null, 2));
327
+ }
328
+
329
+ async function installFlow(engineId, { args, t, c }) {
330
+ const plans = await installPlans(engineId);
331
+ if (!plans.length) throw new TunerError('errors.noInstaller');
332
+ const plan = plans.length === 1 ? plans[0] : await select({ message: t('cli.installMethod'), choices: plans.map((p) => ({ name: t(p.labelCode), value: p })) });
333
+ console.log(c.dim(` ${plan.shell || (plan.cmd ? [plan.cmd, ...plan.args].join(' ') : plan.url)}`));
334
+ if (!args.yes && !(await confirm({ message: t('cli.installNow'), default: true }))) process.exit(0);
335
+ const r = await runInstall(plan, (l) => console.log(c.dim(' ' + l)));
336
+ if (r.manual) await input({ message: t('cli.installManual') });
337
+ else if (r.code !== 0) throw new TunerError('errors.installerExit', { code: r.code });
338
+ }
@@ -0,0 +1,118 @@
1
+ import { select, checkbox, input, Separator } from '@inquirer/prompts';
2
+ import { detectHardware, detectEngines, detectAgents, installMcpServer } from '../../core/index.js';
3
+ import { t as translate } from '../../i18n/index.js';
4
+ import { getColors } from '../colors.js';
5
+ import { withNav, NAV_BACK, backChoice } from '../nav.js';
6
+
7
+ export async function mcpCommand(args, { lang, theme }) {
8
+ const c = getColors(theme);
9
+ const t = (key, params) => translate(lang, key, params);
10
+
11
+ let step = 1;
12
+ let engineId = args.engine;
13
+ let selectedAgents = [];
14
+ let customName = args.name;
15
+ let agents = null;
16
+
17
+ while (step >= 1 && step <= 3) {
18
+ if (step === 1) {
19
+ if (args.engine) {
20
+ step = 2;
21
+ continue;
22
+ }
23
+ const hw = await detectHardware();
24
+ const det = await detectEngines(hw);
25
+ const res = await withNav(() => select({
26
+ message: t('cli.mcpSelectEngine'),
27
+ default: det.recommended || 'lmstudio',
28
+ choices: [
29
+ { name: 'LM Studio', value: 'lmstudio' },
30
+ { name: 'Ollama', value: 'ollama' },
31
+ new Separator(),
32
+ backChoice(t, c),
33
+ ],
34
+ }));
35
+ if (res === NAV_BACK) return;
36
+ engineId = res;
37
+ step = 2;
38
+ }
39
+ else if (step === 2) {
40
+ if (!agents) {
41
+ agents = await detectAgents();
42
+ }
43
+ const res = await withNav(() => checkbox({
44
+ message: `${t('cli.mcpSelectAgents')} ${c.dim(`(Backspace: ${t('common.back')})`)}`,
45
+ choices: agents.map((agent) => {
46
+ const isConfigured = agent.configuredServers?.includes(engineId);
47
+ let statusStr = agent.detected ? c.g(`[${t('cli.mcpStatusDetected')}]`) : c.dim(`[${t('cli.mcpStatusAvailable')}]`);
48
+ if (isConfigured) {
49
+ statusStr += ` ${c.cyan(`(${t('web.mcpAlreadyConfigured')})`)}`;
50
+ }
51
+ return {
52
+ name: `${agent.name.padEnd(18)} ${statusStr}`,
53
+ value: agent.id,
54
+ checked: agent.detected,
55
+ };
56
+ }),
57
+ }));
58
+ if (res === NAV_BACK) {
59
+ if (args.engine) return;
60
+ step = 1;
61
+ continue;
62
+ }
63
+ if (!res.length) {
64
+ console.log(c.y(t('cli.mcpNoAgentsSelected')));
65
+ return;
66
+ }
67
+ selectedAgents = res;
68
+ step = 3;
69
+ }
70
+ else if (step === 3) {
71
+ if (args.name) {
72
+ customName = args.name;
73
+ break;
74
+ }
75
+ let buffer = '';
76
+ const res = await withNav(() => input({
77
+ message: `${t('cli.mcpServerNamePrompt')} ${c.dim(`(Backspace: ${t('common.back')})`)}`,
78
+ default: engineId,
79
+ validate: (val) => {
80
+ buffer = val;
81
+ if (val.toLowerCase() === 'b' || val.toLowerCase() === 'back') return true;
82
+ return Boolean(val.trim()) || 'Server name cannot be empty';
83
+ },
84
+ }), { isTextInput: true, getInputValue: () => buffer });
85
+
86
+ if (res === NAV_BACK || res?.toLowerCase?.() === 'b' || res?.toLowerCase?.() === 'back') {
87
+ step = 2;
88
+ continue;
89
+ }
90
+ customName = res.trim();
91
+ break;
92
+ }
93
+ }
94
+
95
+ // Install MCP server configuration
96
+ const result = await installMcpServer({
97
+ engineId,
98
+ customName,
99
+ agentIds: selectedAgents,
100
+ host: args.host,
101
+ apiKey: args.apiKey,
102
+ });
103
+
104
+ // Report results
105
+ if (result.updated.length) {
106
+ const updatedNames = result.updated
107
+ .map((id) => agents?.find((a) => a.id === id)?.name || id)
108
+ .join(', ');
109
+ console.log(c.g(`\n✓ ${t('cli.mcpSuccess', { server: customName, agents: updatedNames })}`));
110
+ }
111
+
112
+ if (result.errors?.length) {
113
+ const errDetails = result.errors
114
+ .map((e) => `${agents?.find((a) => a.id === e.agentId)?.name || e.agentId}: ${e.error}`)
115
+ .join(', ');
116
+ console.error(c.r(`\n✗ ${t('cli.mcpFailed', { agents: errDetails })}`));
117
+ }
118
+ }
@@ -0,0 +1,21 @@
1
+ import { listPresets } from '../../core/index.js';
2
+ import { t as translate } from '../../i18n/index.js';
3
+ import { getColors } from '../colors.js';
4
+
5
+ export async function presetsCommand(args, { lang, theme }) {
6
+ const c = getColors(theme);
7
+ const t = (key, params) => translate(lang, key, params);
8
+
9
+ const list = await listPresets({ engine: args.engine, modelKey: args.model });
10
+ if (!list.length) {
11
+ console.log(t('cli.noPresets'));
12
+ return;
13
+ }
14
+
15
+ for (const p of list) {
16
+ const profileStr = p.profile ? ` ${p.profile}${p.variant && p.variant !== p.model ? ` (${p.variant})` : ''}` : '';
17
+ const offloadStr = p.fullOffload ? t('candidate.allGpu') : t('cli.partial');
18
+ const dateStr = c.dim(p.createdAt.slice(0, 16).replace('T', ' '));
19
+ console.log(`${p.engine.padEnd(9)} ${p.model} ${p.ctx / 1024}K${profileStr} KV ${p.kvType} ${offloadStr} ${p.shortTps} t/s ${dateStr}`);
20
+ }
21
+ }