llm-tuner 0.2.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.
@@ -0,0 +1,243 @@
1
+ import { select } from '@inquirer/prompts';
2
+ import { readSettings, writeSettings, THEMES, PROFILES, CURRENT_VERSION } from '../core/index.js';
3
+ import { t as translate, LOCALES, errorText } from '../i18n/index.js';
4
+ import { loadCommand } from './commands/load.js';
5
+ import { mcpCommand } from './commands/mcp.js';
6
+ import { presetsCommand } from './commands/presets.js';
7
+ import { settingsCommand } from './commands/settings.js';
8
+ import { helpCommand } from './commands/help.js';
9
+ import { withNav } from './nav.js';
10
+
11
+ export function parseCliArgs(argv) {
12
+ const flags = new Set(['help', 'version', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
13
+ const tuningKeys = new Set(['engine', 'model', 'ctx', 'profile', 'candidates', 'force', 'dry-run', 'yes', 'json']);
14
+ const args = {};
15
+ let command = null;
16
+
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+
20
+ // Slash commands or positional aliases
21
+ if (a === '/load' || a === 'load') {
22
+ command = 'load';
23
+ continue;
24
+ }
25
+ if (a === '/mcp' || a === 'mcp') {
26
+ command = 'mcp';
27
+ continue;
28
+ }
29
+ if (a === '/presets' || a === 'presets') {
30
+ command = 'presets';
31
+ continue;
32
+ }
33
+ if (a === '/settings' || a === 'settings' || a === '/setting' || a === 'setting') {
34
+ command = 'settings';
35
+ args.settings = true;
36
+ continue;
37
+ }
38
+ if (a === '/help' || a === 'help') {
39
+ command = 'help';
40
+ args.help = true;
41
+ continue;
42
+ }
43
+ if (a === '/exit' || a === 'exit') {
44
+ command = 'exit';
45
+ continue;
46
+ }
47
+
48
+ // Short flags
49
+ if (a === '-h') { args.help = true; command = 'help'; continue; }
50
+ if (a === '-v') { args.version = true; command = 'version'; continue; }
51
+
52
+ // Double-dash flags & options
53
+ if (a.startsWith('--')) {
54
+ const k = a.slice(2);
55
+ if (k === 'setting' || k === 'settings') {
56
+ args.settings = true;
57
+ command = 'settings';
58
+ continue;
59
+ }
60
+ if (k === 'presets') {
61
+ args.presets = true;
62
+ command = 'presets';
63
+ continue;
64
+ }
65
+ if (k === 'help') {
66
+ args.help = true;
67
+ command = 'help';
68
+ continue;
69
+ }
70
+ if (k === 'version') {
71
+ args.version = true;
72
+ command = 'version';
73
+ continue;
74
+ }
75
+
76
+ if (flags.has(k)) {
77
+ args[k] = true;
78
+ } else {
79
+ args[k] = argv[++i];
80
+ }
81
+ continue;
82
+ }
83
+ }
84
+
85
+ // Normalize dry-run / dryRun
86
+ if (args['dry-run']) args.dryRun = true;
87
+ else if (args.dryRun) args['dry-run'] = true;
88
+
89
+ // Route fallback: if command not explicitly set
90
+ if (!command) {
91
+ const hasTuning = Object.keys(args).some((k) => tuningKeys.has(k));
92
+ if (hasTuning) {
93
+ command = 'load';
94
+ } else {
95
+ command = 'menu';
96
+ }
97
+ }
98
+
99
+ return { command, ...args };
100
+ }
101
+
102
+ export async function routeCli(argv, env = {}) {
103
+ const parsed = parseCliArgs(argv);
104
+ let { command, ...args } = parsed;
105
+
106
+ let { lang, theme } = await readSettings();
107
+ const t = (key, params) => translate(lang, key, params);
108
+
109
+ if ('lang' in args) {
110
+ if (!LOCALES.includes(args.lang)) {
111
+ console.error(translate('en', 'errors.unknownLocale', { lang: args.lang ?? '', list: LOCALES.join(', ') }));
112
+ return 1;
113
+ }
114
+ lang = args.lang;
115
+ try { await writeSettings({ lang }); } catch (e) { console.error(`Warning: ${e.message}`); }
116
+ }
117
+
118
+ if ('theme' in args) {
119
+ if (!THEMES.includes(args.theme)) {
120
+ console.error(translate(lang, 'errors.unknownTheme', { theme: args.theme ?? '', list: THEMES.join(', ') }));
121
+ return 1;
122
+ }
123
+ theme = args.theme;
124
+ try { await writeSettings({ theme }); } catch (e) { console.error(`Warning: ${e.message}`); }
125
+ }
126
+
127
+ if ('profile' in args) {
128
+ if (!PROFILES.includes(args.profile)) {
129
+ console.error(translate(lang, 'errors.unknownProfile', { profile: args.profile ?? '', list: PROFILES.join(', ') }));
130
+ return 1;
131
+ }
132
+ }
133
+
134
+ if (command === 'version' || args.version) {
135
+ console.log(CURRENT_VERSION);
136
+ return 0;
137
+ }
138
+
139
+ if (command === 'help' || args.help) {
140
+ await helpCommand(args, { lang, theme });
141
+ return 0;
142
+ }
143
+
144
+ // Interactive menu mode when run bare (persistent loop until /exit)
145
+ if (command === 'menu') {
146
+ const isInteractive = process.stdin.isTTY || env.forceInteractive;
147
+ if (!isInteractive) {
148
+ await loadCommand(args, { lang, theme });
149
+ return 0;
150
+ }
151
+
152
+ let currentLang = lang;
153
+ let currentTheme = theme;
154
+
155
+ while (true) {
156
+ const activeT = (k, p) => translate(currentLang, k, p);
157
+ let selectedCommand;
158
+
159
+ try {
160
+ selectedCommand = await withNav(() => select({
161
+ message: activeT('cli.menuTitle'),
162
+ choices: [
163
+ { name: `/load ${activeT('cli.cmdLoadDesc')}`, value: 'load' },
164
+ { name: `/mcp ${activeT('cli.cmdMcpDesc')}`, value: 'mcp' },
165
+ { name: `/presets ${activeT('cli.cmdPresetsDesc')}`, value: 'presets' },
166
+ { name: `/settings ${activeT('cli.cmdSettingsDesc')}`, value: 'settings' },
167
+ { name: `/help ${activeT('cli.cmdHelpDesc')}`, value: 'help' },
168
+ { name: `/exit ${activeT('cli.cmdExitDesc')}`, value: 'exit' },
169
+ ],
170
+ }), { allowBack: false });
171
+ } catch (e) {
172
+ if (e?.name === 'ExitPromptError') {
173
+ return 0;
174
+ }
175
+ throw e;
176
+ }
177
+
178
+ if (selectedCommand === 'exit') {
179
+ return 0;
180
+ }
181
+
182
+ try {
183
+ switch (selectedCommand) {
184
+ case 'load':
185
+ await loadCommand(args, { lang: currentLang, theme: currentTheme });
186
+ break;
187
+ case 'mcp':
188
+ await mcpCommand(args, { lang: currentLang, theme: currentTheme });
189
+ break;
190
+ case 'presets':
191
+ await presetsCommand(args, { lang: currentLang, theme: currentTheme });
192
+ break;
193
+ case 'settings':
194
+ await settingsCommand(args, { lang: currentLang, theme: currentTheme });
195
+ {
196
+ const s = await readSettings();
197
+ currentLang = s.lang;
198
+ currentTheme = s.theme;
199
+ }
200
+ break;
201
+ case 'help':
202
+ await helpCommand(args, { lang: currentLang, theme: currentTheme });
203
+ break;
204
+ }
205
+ } catch (e) {
206
+ if (e?.name === 'ExitPromptError') {
207
+ // Sub-command was cancelled, return to main menu
208
+ } else {
209
+ console.error(`\n${activeT('common.error', { message: errorText(currentLang, e) })}`);
210
+ }
211
+ }
212
+
213
+ console.log('');
214
+ }
215
+ }
216
+
217
+ try {
218
+ switch (command) {
219
+ case 'load':
220
+ await loadCommand(args, { lang, theme });
221
+ return 0;
222
+ case 'mcp':
223
+ await mcpCommand(args, { lang, theme });
224
+ return 0;
225
+ case 'presets':
226
+ await presetsCommand(args, { lang, theme });
227
+ return 0;
228
+ case 'settings':
229
+ await settingsCommand(args, { lang, theme });
230
+ return 0;
231
+ case 'help':
232
+ await helpCommand(args, { lang, theme });
233
+ return 0;
234
+ default:
235
+ console.error(`Unknown command: ${command}`);
236
+ return 1;
237
+ }
238
+ } catch (e) {
239
+ if (e?.name === 'ExitPromptError') return 130;
240
+ console.error(`\n${t('common.error', { message: errorText(lang, e) })}`);
241
+ return 1;
242
+ }
243
+ }
package/src/core/index.js CHANGED
@@ -9,4 +9,5 @@ export { findPreset, savePreset, listPresets, presetsDir, presetPath, variantsSi
9
9
  export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme } from './settings.js';
10
10
  export { TunerError, benchError } from './errors.js';
11
11
  export { sampleRam, sampleGpus, startTelemetrySampler } from './telemetry.js';
12
-
12
+ export { checkUpdate, isNewerVersion, CURRENT_VERSION } from './updater.js';
13
+ export { detectAgents, installMcpServer, SUPPORTED_AGENTS } from './mcp/index.js';
@@ -0,0 +1,37 @@
1
+ import { readJson, writeJsonWithBackup } from '../../util.js';
2
+
3
+ export async function readJsonMcpServers(filePath, rootKey) {
4
+ const data = (await readJson(filePath, {})) || {};
5
+ return Object.keys(data[rootKey] || {});
6
+ }
7
+
8
+ export async function upsertJsonMcpServer(
9
+ filePath,
10
+ rootKey,
11
+ serverName,
12
+ serverConfig,
13
+ { isOpenCode = false } = {}
14
+ ) {
15
+ const data = (await readJson(filePath, {})) || {};
16
+
17
+ if (!data[rootKey] || typeof data[rootKey] !== 'object') {
18
+ data[rootKey] = {};
19
+ }
20
+
21
+ if (isOpenCode) {
22
+ data[rootKey][serverName] = {
23
+ type: 'local',
24
+ command: [serverConfig.command, ...(serverConfig.args || [])],
25
+ environment: serverConfig.env || {}
26
+ };
27
+ } else {
28
+ data[rootKey][serverName] = {
29
+ command: serverConfig.command,
30
+ args: serverConfig.args || [],
31
+ ...(serverConfig.env ? { env: serverConfig.env } : {})
32
+ };
33
+ }
34
+
35
+ const backup = await writeJsonWithBackup(filePath, data, { indent: 2 });
36
+ return { backup };
37
+ }
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { exists, timestamp } from '../../util.js';
4
+
5
+ export async function readTomlMcpServers(filePath) {
6
+ if (!(await exists(filePath))) return [];
7
+
8
+ const content = await fs.readFile(filePath, 'utf8');
9
+ const lines = content.split(/\r?\n/);
10
+ const serverNames = new Set();
11
+
12
+ for (const raw of lines) {
13
+ const line = raw.trim();
14
+ const match = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)\]\s*$/);
15
+ if (match) {
16
+ serverNames.add(match[1]);
17
+ }
18
+ }
19
+
20
+ return [...serverNames];
21
+ }
22
+
23
+ export async function upsertTomlMcpServer(filePath, serverName, serverConfig) {
24
+ let content = '';
25
+ let backup = null;
26
+
27
+ if (await exists(filePath)) {
28
+ const backupPath = `${filePath}.bak-llm-tuner-${timestamp()}`;
29
+ await fs.copyFile(filePath, backupPath);
30
+ backup = backupPath;
31
+ content = await fs.readFile(filePath, 'utf8');
32
+ } else {
33
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
34
+ content = '';
35
+ }
36
+
37
+ const lines = content.split(/\r?\n/);
38
+ const newLines = [];
39
+ let skipping = false;
40
+
41
+ for (const line of lines) {
42
+ const trimmed = line.trim();
43
+
44
+ if (trimmed.startsWith('[')) {
45
+ if (
46
+ trimmed === `[mcp_servers.${serverName}]` ||
47
+ trimmed.startsWith(`[mcp_servers.${serverName}.`)
48
+ ) {
49
+ skipping = true;
50
+ continue;
51
+ }
52
+ skipping = false;
53
+ }
54
+
55
+ if (!skipping) {
56
+ newLines.push(line);
57
+ }
58
+ }
59
+
60
+ let baseContent = newLines.join('\n').trim();
61
+ if (baseContent.length > 0) {
62
+ baseContent += '\n\n';
63
+ }
64
+
65
+ let tomlBlock = `[mcp_servers.${serverName}]\n`;
66
+ tomlBlock += `command = ${JSON.stringify(serverConfig.command)}\n`;
67
+ tomlBlock += `args = ${JSON.stringify(serverConfig.args || [])}\n`;
68
+
69
+ if (serverConfig.env && typeof serverConfig.env === 'object' && Object.keys(serverConfig.env).length > 0) {
70
+ tomlBlock += `\n[mcp_servers.${serverName}.env]\n`;
71
+ for (const [key, value] of Object.entries(serverConfig.env)) {
72
+ tomlBlock += `${key} = ${JSON.stringify(String(value))}\n`;
73
+ }
74
+ }
75
+
76
+ const finalContent = baseContent + tomlBlock;
77
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}`);
78
+ await fs.writeFile(tempPath, finalContent, 'utf8');
79
+ await fs.rename(tempPath, filePath);
80
+
81
+ return { backup };
82
+ }
@@ -0,0 +1,82 @@
1
+ import { exists } from '../util.js';
2
+ import { SUPPORTED_AGENTS, resolveConfigPath, buildMcpPayload } from './registry.js';
3
+ import { readJsonMcpServers, upsertJsonMcpServer } from './adapters/json-adapter.js';
4
+ import { readTomlMcpServers, upsertTomlMcpServer } from './adapters/toml-adapter.js';
5
+
6
+ export { SUPPORTED_AGENTS, resolveConfigPath, buildMcpPayload };
7
+
8
+ export async function detectAgents({ platform, env, homedir } = {}) {
9
+ const results = [];
10
+
11
+ for (const agent of SUPPORTED_AGENTS) {
12
+ const configPath = resolveConfigPath(agent.id, { platform, env, homedir });
13
+ const fileExists = await exists(configPath);
14
+
15
+ let configuredServers = [];
16
+ if (fileExists) {
17
+ try {
18
+ if (agent.format === 'toml') {
19
+ configuredServers = await readTomlMcpServers(configPath);
20
+ } else {
21
+ configuredServers = await readJsonMcpServers(configPath, agent.rootKey);
22
+ }
23
+ } catch {
24
+ configuredServers = [];
25
+ }
26
+ }
27
+
28
+ results.push({
29
+ id: agent.id,
30
+ name: agent.name,
31
+ format: agent.format,
32
+ configPath,
33
+ detected: fileExists,
34
+ configuredServers
35
+ });
36
+ }
37
+
38
+ return results;
39
+ }
40
+
41
+ export async function installMcpServer(
42
+ { engineId, customName, agentIds = [], host, apiKey },
43
+ { platform, env, homedir } = {}
44
+ ) {
45
+ if (!engineId || !customName) {
46
+ throw new Error('engineId and customName are required');
47
+ }
48
+
49
+ const payload = buildMcpPayload(engineId, { customName, host, apiKey });
50
+ const updated = [];
51
+ const errors = [];
52
+
53
+ for (const agentId of agentIds) {
54
+ const agent = SUPPORTED_AGENTS.find((a) => a.id === agentId);
55
+ if (!agent) {
56
+ errors.push({ agentId, error: `Unsupported agent ID: ${agentId}` });
57
+ continue;
58
+ }
59
+
60
+ try {
61
+ const configPath = resolveConfigPath(agentId, { platform, env, homedir });
62
+
63
+ if (agent.format === 'toml') {
64
+ await upsertTomlMcpServer(configPath, customName, payload);
65
+ } else {
66
+ await upsertJsonMcpServer(
67
+ configPath,
68
+ agent.rootKey,
69
+ customName,
70
+ payload,
71
+ { isOpenCode: agent.isOpenCode }
72
+ );
73
+ }
74
+
75
+ updated.push(agentId);
76
+ } catch (err) {
77
+ errors.push({ agentId, error: err.message });
78
+ }
79
+ }
80
+
81
+ return { success: errors.length === 0, updated, errors };
82
+ }
@@ -0,0 +1,121 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const SUPPORTED_AGENTS = [
5
+ {
6
+ id: 'claude-desktop',
7
+ name: 'Claude Desktop',
8
+ format: 'json',
9
+ rootKey: 'mcpServers'
10
+ },
11
+ {
12
+ id: 'claude-code',
13
+ name: 'Claude Code',
14
+ format: 'json',
15
+ rootKey: 'mcpServers'
16
+ },
17
+ {
18
+ id: 'opencode',
19
+ name: 'OpenCode',
20
+ format: 'json',
21
+ rootKey: 'mcp',
22
+ isOpenCode: true
23
+ },
24
+ {
25
+ id: 'antigravity',
26
+ name: 'Antigravity',
27
+ format: 'json',
28
+ rootKey: 'mcpServers'
29
+ },
30
+ {
31
+ id: 'vscode',
32
+ name: 'VS Code',
33
+ format: 'json',
34
+ rootKey: 'servers'
35
+ },
36
+ {
37
+ id: 'chatgpt',
38
+ name: 'ChatGPT / Codex',
39
+ format: 'toml'
40
+ }
41
+ ];
42
+
43
+ export function resolveConfigPath(agentId, { platform = process.platform, env = process.env, homedir = os.homedir() } = {}) {
44
+ const p = platform === 'win32' ? path.win32 : path.posix;
45
+
46
+ switch (agentId) {
47
+ case 'claude-desktop': {
48
+ if (platform === 'darwin') {
49
+ return p.join(homedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
50
+ }
51
+ if (platform === 'win32') {
52
+ const appData = env.APPDATA || p.join(homedir, 'AppData', 'Roaming');
53
+ return p.join(appData, 'Claude', 'claude_desktop_config.json');
54
+ }
55
+ const configDir = env.XDG_CONFIG_HOME || p.join(homedir, '.config');
56
+ return p.join(configDir, 'Claude', 'claude_desktop_config.json');
57
+ }
58
+ case 'claude-code': {
59
+ const userProfile = (platform === 'win32' && env.USERPROFILE) ? env.USERPROFILE : homedir;
60
+ return p.join(userProfile, '.claude.json');
61
+ }
62
+ case 'opencode': {
63
+ if (platform === 'darwin') {
64
+ return p.join(homedir, 'Library', 'Application Support', 'opencode', 'opencode.json');
65
+ }
66
+ if (platform === 'win32') {
67
+ const appData = env.APPDATA || p.join(homedir, 'AppData', 'Roaming');
68
+ return p.join(appData, 'opencode', 'opencode.json');
69
+ }
70
+ const configDir = env.XDG_CONFIG_HOME || p.join(homedir, '.config');
71
+ return p.join(configDir, 'opencode', 'opencode.json');
72
+ }
73
+ case 'antigravity': {
74
+ const userProfile = (platform === 'win32' && env.USERPROFILE) ? env.USERPROFILE : homedir;
75
+ return p.join(userProfile, '.gemini', 'config', 'mcp_config.json');
76
+ }
77
+ case 'vscode': {
78
+ if (platform === 'darwin') {
79
+ return p.join(homedir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
80
+ }
81
+ if (platform === 'win32') {
82
+ const appData = env.APPDATA || p.join(homedir, 'AppData', 'Roaming');
83
+ return p.join(appData, 'Code', 'User', 'mcp.json');
84
+ }
85
+ const configDir = env.XDG_CONFIG_HOME || p.join(homedir, '.config');
86
+ return p.join(configDir, 'Code', 'User', 'mcp.json');
87
+ }
88
+ case 'chatgpt': {
89
+ const userProfile = (platform === 'win32' && env.USERPROFILE) ? env.USERPROFILE : homedir;
90
+ return p.join(userProfile, '.codex', 'config.toml');
91
+ }
92
+ default:
93
+ throw new Error(`Unsupported agent ID: ${agentId}`);
94
+ }
95
+ }
96
+
97
+ export function buildMcpPayload(engineId, { customName, host, apiKey } = {}) {
98
+ switch (engineId) {
99
+ case 'lmstudio': {
100
+ return {
101
+ command: 'npx',
102
+ args: ['-y', '@mzxrai/mcp-openai@latest'],
103
+ env: {
104
+ OPENAI_BASE_URL: host || 'http://127.0.0.1:1234/v1',
105
+ OPENAI_API_KEY: apiKey || 'sk-lm-local'
106
+ }
107
+ };
108
+ }
109
+ case 'ollama': {
110
+ return {
111
+ command: 'npx',
112
+ args: ['-y', 'ollama-mcp-server@latest'],
113
+ env: {
114
+ OLLAMA_HOST: host || 'http://127.0.0.1:11434'
115
+ }
116
+ };
117
+ }
118
+ default:
119
+ throw new Error(`Unsupported engine ID: ${engineId}`);
120
+ }
121
+ }
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { configDir, readJson, fetchJson } from './util.js';
4
+
5
+ const pkgUrl = new URL('../../package.json', import.meta.url);
6
+ const pkgContent = await fs.readFile(pkgUrl, 'utf8');
7
+ export const CURRENT_VERSION = JSON.parse(pkgContent).version;
8
+
9
+ export const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/llm-tuner/latest';
10
+ export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11
+
12
+ export const updateCachePath = () => path.join(configDir(), 'update-check.json');
13
+
14
+ /**
15
+ * Parse a semver string into [major, minor, patch] numbers.
16
+ * Strips any leading 'v' and ignores pre-release/build identifiers.
17
+ */
18
+ export function parseSemver(v) {
19
+ if (typeof v !== 'string') return null;
20
+ const clean = v.trim().replace(/^v/, '');
21
+ const [core] = clean.split('-');
22
+ const parts = core.split('.').map((p) => Number(p));
23
+ if (parts.length < 3 || parts.some((n) => Number.isNaN(n) || n < 0)) return null;
24
+ return parts.slice(0, 3);
25
+ }
26
+
27
+ /**
28
+ * Returns true if `latest` is strictly newer than `current`.
29
+ */
30
+ export function isNewerVersion(latest, current) {
31
+ const l = parseSemver(latest);
32
+ const c = parseSemver(current);
33
+ if (!l || !c) return false;
34
+ for (let i = 0; i < 3; i++) {
35
+ if (l[i] > c[i]) return true;
36
+ if (l[i] < c[i]) return false;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Checks npm registry for updates with a 24h cache.
43
+ * Never throws; returns safe fallback on network or file errors.
44
+ */
45
+ export async function checkUpdate({
46
+ force = false,
47
+ timeout = 2000,
48
+ cacheTtlMs = DEFAULT_CACHE_TTL_MS,
49
+ registryUrl = DEFAULT_REGISTRY_URL,
50
+ currentVersion = CURRENT_VERSION,
51
+ } = {}) {
52
+ try {
53
+ const cacheFile = updateCachePath();
54
+ if (!force) {
55
+ const cached = await readJson(cacheFile, null);
56
+ if (cached && typeof cached === 'object' && cached.lastChecked && cached.latestVersion) {
57
+ if (Date.now() - cached.lastChecked < cacheTtlMs) {
58
+ return {
59
+ updateAvailable: isNewerVersion(cached.latestVersion, currentVersion),
60
+ currentVersion,
61
+ latestVersion: cached.latestVersion,
62
+ cached: true,
63
+ };
64
+ }
65
+ }
66
+ }
67
+
68
+ const res = await fetchJson(registryUrl, { timeout });
69
+ if (res?.ok && res?.json?.version) {
70
+ const latest = res.json.version;
71
+ try {
72
+ await fs.mkdir(configDir(), { recursive: true });
73
+ await fs.writeFile(cacheFile, JSON.stringify({ lastChecked: Date.now(), latestVersion: latest }, null, 2));
74
+ } catch {
75
+ // Ignore cache write errors
76
+ }
77
+ return {
78
+ updateAvailable: isNewerVersion(latest, currentVersion),
79
+ currentVersion,
80
+ latestVersion: latest,
81
+ cached: false,
82
+ };
83
+ }
84
+
85
+ return {
86
+ updateAvailable: false,
87
+ currentVersion,
88
+ latestVersion: null,
89
+ cached: false,
90
+ };
91
+ } catch {
92
+ return {
93
+ updateAvailable: false,
94
+ currentVersion,
95
+ latestVersion: null,
96
+ cached: false,
97
+ };
98
+ }
99
+ }