scalemax 0.1.11 → 0.1.13

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
@@ -12,7 +12,7 @@ It detects supported AI coding clients, safely backs up existing config files, a
12
12
 
13
13
  ## Requirements
14
14
 
15
- - Node.js `18.17` or newer
15
+ - Node.js `18.19` or newer
16
16
  - macOS, Linux, or Windows
17
17
  - A ScaleMax API key
18
18
  - Network access to `https://api.scalemax.pro`
@@ -112,6 +112,10 @@ SCALEMAX_API_KEY=sm_live_xxx scalemax models
112
112
 
113
113
  For clients without a stable official config file, the installer writes a ScaleMax setup guide under `~/.scalemax/` instead of modifying private app storage.
114
114
 
115
+ ### Claude Code
116
+
117
+ ScaleMax configures Claude Code with the Anthropic-compatible origin `https://api.scalemax.pro`, preserves unrelated settings and environment variables, and selects a live `claude-*` model from the authenticated model catalog (preferring `claude-opus-4-8`). It verifies `/v1/messages` after setup and rolls back managed changes if verification fails. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the ScaleMax gateway.
118
+
115
119
  ### Cursor BYOK routing on macOS
116
120
 
117
121
  Cursor can send named models through its own plan-gated router unless its local BYOK runtime is active. When you select Cursor, `npx scalemax`:
package/dist/clients.js CHANGED
@@ -20,7 +20,7 @@ export const clients = [
20
20
  { id: 'codex', name: 'Codex', detect: codexDetect.detect, install: codexConfig.install, configure: codexConfig.configure, verify: codexConfig.verify, launch: codexConfig.launch, remove: codexConfig.remove },
21
21
  { id: 'cursor', name: 'Cursor', detect: cursorDetect.detect, configure: cursorConfig.configure, verify: cursorConfig.verify, remove: cursorConfig.remove },
22
22
  { id: 'trae-solo', name: 'TRAE SOLO', detect: traeSoloDetect.detect, configure: traeSoloConfig.configure, remove: traeSoloConfig.remove },
23
- { id: 'claude', name: 'Claude Code', detect: claudeDetect.detect, configure: claudeConfig.configure, remove: claudeConfig.remove },
23
+ { id: 'claude', name: 'Claude Code', detect: claudeDetect.detect, configure: claudeConfig.configure, verify: claudeConfig.verify, remove: claudeConfig.remove },
24
24
  { id: 'opencode', name: 'OpenCode', detect: opencodeDetect.detect, configure: opencodeConfig.configure, remove: opencodeConfig.remove },
25
25
  { id: 'openclaw', name: 'OpenClaw', detect: openclawDetect.detect, configure: openclawConfig.configure, remove: openclawConfig.remove },
26
26
  { id: 'hermes', name: 'Hermes', detect: hermesDetect.detect, configure: hermesConfig.configure, remove: hermesConfig.remove },
@@ -85,6 +85,7 @@ export async function configureCommand(opts = {}) {
85
85
  return;
86
86
  }
87
87
  const successful = new Set();
88
+ const failures = [];
88
89
  for (const client of availableClients.filter(c => chosen.includes(c.id))) {
89
90
  let allowAppModification = false;
90
91
  if (client.id === 'cursor') {
@@ -111,7 +112,9 @@ export async function configureCommand(opts = {}) {
111
112
  spinner.succeed(`${client.name} ${res.message}`);
112
113
  }
113
114
  catch (err) {
114
- spinner.fail(`${client.name} install failed: ${err instanceof Error ? err.message : String(err)}`);
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ spinner.fail(`${client.name} install failed: ${message}`);
117
+ failures.push(`${client.name}: ${message}`);
115
118
  continue;
116
119
  }
117
120
  }
@@ -122,15 +125,31 @@ export async function configureCommand(opts = {}) {
122
125
  spinner.succeed(`${client.name} ${res.message}`);
123
126
  if (client.verify) {
124
127
  const verify = ora(`Verifying ${client.name}`).start();
125
- const v = await client.verify(ctx);
126
- verify.succeed(`${client.name} ${v.message}`);
128
+ try {
129
+ const v = await client.verify(ctx);
130
+ verify.succeed(`${client.name} ${v.message}`);
131
+ }
132
+ catch (error) {
133
+ verify.fail(`${client.name} verification failed`);
134
+ try {
135
+ await client.remove(ctx);
136
+ }
137
+ catch (rollback) {
138
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; rollback also failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
139
+ }
140
+ throw error;
141
+ }
127
142
  }
128
143
  successful.add(client.id);
129
144
  }
130
145
  catch (err) {
131
- spinner.fail(`${client.name} failed: ${err instanceof Error ? err.message : String(err)}`);
146
+ const message = err instanceof Error ? err.message : String(err);
147
+ spinner.fail(`${client.name} failed: ${message}`);
148
+ failures.push(`${client.name}: ${message}`);
132
149
  }
133
150
  }
134
151
  if (opts.promptLaunch !== false && !opts.all && process.stdin.isTTY)
135
152
  await maybeLaunchCodex(chosen, successful);
153
+ if (failures.length)
154
+ throw new Error(`${failures.length} client configuration(s) failed:\n${failures.map(f => `- ${f}`).join('\n')}`);
136
155
  }
@@ -1,15 +1,15 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
- import fs from 'fs-extra';
4
3
  import { detectAll } from './configure.js';
5
4
  import { loadApiKey } from '../utils/keyStore.js';
6
5
  import { validateApiKey, testStreaming } from '../utils/api.js';
7
6
  import { homeDir } from '../utils/paths.js';
8
- import { clientConfigPath } from '../configure/paths.js';
9
7
  import { clients } from '../clients.js';
8
+ import { SCALEMAX_BASE_URL } from '../constants.js';
10
9
  export async function doctorCommand(home = homeDir()) {
11
10
  let failures = 0;
12
11
  const key = await loadApiKey(home);
12
+ let models = [];
13
13
  if (!key) {
14
14
  console.log(`${chalk.red('✗')} API key missing`);
15
15
  failures++;
@@ -17,34 +17,48 @@ export async function doctorCommand(home = homeDir()) {
17
17
  else {
18
18
  const s = ora('Checking API key and models').start();
19
19
  const res = await validateApiKey(key);
20
- if (res.ok && res.models.length)
21
- s.succeed(`API reachable, key valid, ${res.models.length} models loaded`);
22
- else {
23
- s.fail(`API check failed${res.error ? `: ${res.error}` : ''}`);
24
- failures++;
20
+ models = res.models;
21
+ if (res.ok && models.length) {
22
+ s.succeed(`API reachable, key valid, ${models.length} models loaded`);
23
+ console.log(`${chalk.green('✓')} Usage and key metadata accessible through the authenticated API`);
24
+ const st = ora('Checking streaming (small billable request)').start();
25
+ const stream = await testStreaming(key, models.find(m => m.id === 'fable-5')?.id || models[0]?.id);
26
+ if (stream.ok)
27
+ st.succeed('Streaming available');
28
+ else {
29
+ st.fail(`Streaming check failed${stream.error ? `: ${stream.error}` : ''}`);
30
+ failures++;
31
+ }
25
32
  }
26
- const st = ora('Checking streaming').start();
27
- const stream = await testStreaming(key, res.models.find(m => m.id === 'fable-5')?.id || res.models[0]?.id);
28
- if (stream.ok)
29
- st.succeed('Streaming available');
30
33
  else {
31
- st.fail(`Streaming check failed${stream.error ? `: ${stream.error}` : ''}`);
34
+ s.fail(`API check failed${res.error ? `: ${res.error}` : ''}`);
32
35
  failures++;
33
36
  }
34
- console.log(`${chalk.green('✓')} Billing accessible (verified through authenticated API path; usage is billed by ScaleMax gateway)`);
35
37
  }
36
38
  const detections = await detectAll(home);
37
39
  console.log(chalk.bold('\nClient configuration\n'));
38
- for (const c of clients) {
39
- const d = detections.find(x => x.id === c.id);
40
- const file = clientConfigPath(c.id, home, process.platform, process.env);
41
- const configured = await fs.pathExists(file);
42
- if (configured)
43
- console.log(`${chalk.green('✓')} ${c.name} configured`);
44
- else if (d.installed)
45
- console.log(`${chalk.yellow('!')} ${c.name} detected but not configured`);
40
+ for (const client of clients) {
41
+ const d = detections.find(x => x.id === client.id);
42
+ if (!d.installed) {
43
+ console.log(`${chalk.dim('•')} ${client.name} not detected`);
44
+ continue;
45
+ }
46
+ if (!d.configured) {
47
+ console.log(`${chalk.yellow('!')} ${client.name} detected but not configured`);
48
+ continue;
49
+ }
50
+ if (client.verify && key && models.length) {
51
+ try {
52
+ const v = await client.verify({ home, platform: process.platform, env: process.env, apiKey: key, baseUrl: SCALEMAX_BASE_URL, models });
53
+ console.log(`${chalk.green('✓')} ${client.name} ${v.message}`);
54
+ }
55
+ catch (error) {
56
+ console.log(`${chalk.red('✗')} ${client.name} verification failed: ${error instanceof Error ? error.message : String(error)}`);
57
+ failures++;
58
+ }
59
+ }
46
60
  else
47
- console.log(`${chalk.dim('•')} ${c.name} not detected`);
61
+ console.log(`${chalk.green('✓')} ${client.name} configured (${d.reason || 'detector confirmed'})`);
48
62
  }
49
63
  console.log('');
50
64
  if (failures === 0)
@@ -6,6 +6,7 @@ import { SCALEMAX_BASE_URL } from '../constants.js';
6
6
  import { homeDir } from '../utils/paths.js';
7
7
  export async function removeCommand(home = homeDir()) {
8
8
  const apiKey = (await loadApiKey(home)) || '';
9
+ const failures = [];
9
10
  for (const client of clients) {
10
11
  const spinner = ora(`Removing ${client.name} ScaleMax config`).start();
11
12
  try {
@@ -13,8 +14,12 @@ export async function removeCommand(home = homeDir()) {
13
14
  spinner.succeed(`${client.name} ${res.message}`);
14
15
  }
15
16
  catch (err) {
16
- spinner.fail(`${client.name} remove failed: ${err instanceof Error ? err.message : String(err)}`);
17
+ const message = err instanceof Error ? err.message : String(err);
18
+ spinner.fail(`${client.name} remove failed: ${message}`);
19
+ failures.push(`${client.name}: ${message}`);
17
20
  }
18
21
  }
19
- console.log(chalk.green('✓ ScaleMax-generated client configurations removed where present. Backups were created before removal.'));
22
+ if (failures.length)
23
+ throw new Error(`${failures.length} client removal(s) failed:\n${failures.map(f => `- ${f}`).join('\n')}`);
24
+ console.log(chalk.green('✓ ScaleMax-managed client configurations removed or restored; unmanaged files were left unchanged.'));
20
25
  }
@@ -5,7 +5,7 @@ const id = 'api-code';
5
5
  const name = 'API Code';
6
6
  export async function configure(ctx) {
7
7
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, ctx.apiKey, ctx.baseUrl, { "environment": { "SCALEMAX_API_KEY": "your ScaleMax API key", "SCALEMAX_BASE_URL": "https://api.scalemax.pro/v1" }, "node": "import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: 'https://api.scalemax.pro/v1' });", "python": "from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='https://api.scalemax.pro/v1')" });
8
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "environment": { "SCALEMAX_API_KEY": "your ScaleMax API key", "SCALEMAX_BASE_URL": "https://api.scalemax.pro/v1" }, "node": "import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: 'https://api.scalemax.pro/v1' });", "python": "from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='https://api.scalemax.pro/v1')" });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'API code examples written' };
11
11
  }
@@ -5,7 +5,7 @@ const id = 'cherry-studio';
5
5
  const name = 'Cherry Studio';
6
6
  export async function configure(ctx) {
7
7
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, ctx.apiKey, ctx.baseUrl, { "officialSetup": ["Settings > Model Service / Model Providers", "Provider type: OpenAI or OpenAI Compatible", "API host/base URL: https://api.scalemax.pro/v1", "Add ScaleMax model IDs or fetch from /v1/models"], "recommendedModel": "fable-5" });
8
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Model Service / Model Providers", "Provider type: OpenAI or OpenAI Compatible", "API host/base URL: https://api.scalemax.pro/v1", "Add ScaleMax model IDs or fetch from /v1/models"], "recommendedModel": "fable-5" });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'setup guide written; enter these values in Cherry Studio model provider settings' };
11
11
  }
@@ -1,3 +1,4 @@
1
1
  import { ConfigureContext, ConfigureResult } from '../types/client.js';
2
2
  export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
+ export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
3
4
  export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -1,24 +1,36 @@
1
1
  import { clientConfigPath } from './paths.js';
2
- import { writeManagedConfig, removeManagedConfig } from './common.js';
3
- const id = 'claude';
4
- const name = 'Claude Code';
2
+ import { writeMergedJsonConfig, removeManagedConfig } from './common.js';
3
+ import { GENERATED_HEADER } from '../constants.js';
4
+ import fs from 'fs-extra';
5
+ const id = 'claude', name = 'Claude Code';
6
+ function claudeModel(ctx) { const ids = (ctx.models || []).map(m => m.id).filter(x => x.startsWith('claude-')); for (const preferred of ['claude-opus-4-8', 'claude-sonnet-5', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-haiku-4-5-20251001'])
7
+ if (ids.includes(preferred))
8
+ return preferred; if (ids.length)
9
+ return ids[0]; throw new Error('No Claude models are available for this API key; Claude Code was not changed.'); }
10
+ function anthropicOrigin(baseUrl) { const url = new URL(baseUrl); url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, ''); url.search = ''; url.hash = ''; return url.toString().replace(/\/$/, ''); }
5
11
  export async function configure(ctx) {
6
- const file = clientConfigPath(id, ctx.home, ctx.platform);
7
- const content = JSON.stringify({
8
- env: {
9
- ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
10
- ANTHROPIC_BASE_URL: ctx.baseUrl,
11
- ANTHROPIC_MODEL: 'fable-5'
12
- },
13
- scalemax: {
14
- managedBy: 'ScaleMax CLI',
15
- note: 'Claude Code uses Anthropic Messages, not OpenAI Chat Completions. This file stores ScaleMax values, but Claude Code requires a ScaleMax Anthropic-compatible endpoint before it can work reliably.'
16
- }
17
- }, null, 2) + '\n';
18
- const result = await writeManagedConfig(ctx, id, name, file, content);
19
- return { ...result, message: 'settings written; Claude Code requires Anthropic-compatible ScaleMax endpoint' };
12
+ const file = clientConfigPath(id, ctx.home, ctx.platform), model = claudeModel(ctx);
13
+ const result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
14
+ const env = existing.env && typeof existing.env === 'object' && !Array.isArray(existing.env) ? existing.env : {};
15
+ return JSON.stringify({ ...existing, env: { ...env, ANTHROPIC_AUTH_TOKEN: ctx.apiKey, ANTHROPIC_BASE_URL: anthropicOrigin(ctx.baseUrl), ANTHROPIC_MODEL: model }, 'scalemax.managedBy': GENERATED_HEADER, scalemax: { managedBy: 'ScaleMax CLI', anthropicModel: model } }, null, 2) + '\n';
16
+ });
17
+ return { ...result, message: `configured Anthropic-compatible routing with ${model}` };
20
18
  }
21
- export async function remove(ctx) {
22
- const file = clientConfigPath(id, ctx.home, ctx.platform);
23
- return removeManagedConfig(ctx, id, name, file);
19
+ export async function verify(ctx) {
20
+ const file = clientConfigPath(id, ctx.home, ctx.platform), settings = await fs.readJson(file);
21
+ const env = settings?.env || {}, model = String(env.ANTHROPIC_MODEL || '');
22
+ if (!model.startsWith('claude-') || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || env.ANTHROPIC_AUTH_TOKEN !== ctx.apiKey)
23
+ throw new Error('Claude Code settings do not match the authenticated ScaleMax configuration.');
24
+ const response = await fetch(`${anthropicOrigin(ctx.baseUrl)}/v1/messages`, { method: 'POST', headers: { Authorization: `Bearer ${ctx.apiKey}`, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'Reply OK' }] }), signal: AbortSignal.timeout(15000) });
25
+ if (!response.ok) {
26
+ let message = `HTTP ${response.status}`;
27
+ try {
28
+ const body = await response.json();
29
+ message = body?.error?.message || message;
30
+ }
31
+ catch { }
32
+ throw new Error(`Claude Messages verification failed: ${message}`);
33
+ }
34
+ return { id, name, changed: false, path: file, message: `verified Anthropic Messages with ${model}` };
24
35
  }
36
+ export async function remove(ctx) { return removeManagedConfig(ctx, id, name, clientConfigPath(id, ctx.home, ctx.platform)); }
@@ -6,7 +6,7 @@ export interface CodexVerificationResult {
6
6
  configPath: string;
7
7
  authPath: string;
8
8
  }
9
- export declare function mergeCodexConfig(existing: string, baseUrl: string): string;
9
+ export declare function mergeCodexConfig(existing: string, baseUrl: string, model?: string): string;
10
10
  export declare function verifyCodexConfiguration(ctx: ConfigureContext): Promise<CodexVerificationResult>;
11
11
  export declare function install(ctx: InstallContext): Promise<ConfigureResult>;
12
12
  export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -8,12 +8,19 @@ import { commandExists } from '../utils/exec.js';
8
8
  const id = 'codex';
9
9
  const name = 'Codex';
10
10
  const provider = 'scalemax';
11
- const model = 'gpt-5.5';
11
+ const fallbackModel = 'gpt-5.5';
12
12
  const reasoningEffort = 'medium';
13
- function scalemaxCodexBlock(baseUrl) {
14
- return `# ${GENERATED_HEADER}\nmodel = "${model}"\nmodel_provider = "${provider}"\nmodel_reasoning_effort = "${reasoningEffort}"\n# Recommended ScaleMax Codex models: gpt-5.5, gpt-5.6-terra, gpt-5.6-sol, gpt-5.6-luna, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, fable-5, fable-5-ultra.\n# Provider-internal gpt-5.3-codex-spark is routed behind the public gpt-5.3-codex Codex slug.\n\n[model_providers.${provider}]\nname = "ScaleMax"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
13
+ function selectedModel(ctx) {
14
+ const ids = new Set((ctx.models || []).map(m => m.id));
15
+ for (const candidate of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex'])
16
+ if (ids.has(candidate))
17
+ return candidate;
18
+ return ctx.models?.[0]?.id || fallbackModel;
19
+ }
20
+ function scalemaxCodexBlock(baseUrl, model = fallbackModel) {
21
+ return `# ${GENERATED_HEADER}\nmodel = "${model}"\nmodel_provider = "${provider}"\nmodel_reasoning_effort = "${reasoningEffort}"\n# ScaleMax models are loaded from the authenticated live model catalog.\n\n[model_providers.${provider}]\nname = "ScaleMax"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
15
22
  }
16
- export function mergeCodexConfig(existing, baseUrl) {
23
+ export function mergeCodexConfig(existing, baseUrl, model = fallbackModel) {
17
24
  const preserved = [];
18
25
  let section = '';
19
26
  let skipScaleMaxProvider = false;
@@ -28,17 +35,17 @@ export function mergeCodexConfig(existing, baseUrl) {
28
35
  }
29
36
  if (skipScaleMaxProvider)
30
37
  continue;
31
- if (!section && /^(model|model_provider|model_reasoning_effort|disable_response_storage)\s*=/.test(line))
38
+ if (!section && /^(model|model_provider|model_reasoning_effort)\s*=/.test(line))
32
39
  continue;
33
40
  if (line === `# ${GENERATED_HEADER}`)
34
41
  continue;
35
42
  preserved.push(raw);
36
43
  }
37
44
  const tail = preserved.join('\n').trim();
38
- return `${scalemaxCodexBlock(baseUrl)}${tail ? `\n${tail}\n` : ''}`;
45
+ return `${scalemaxCodexBlock(baseUrl, model)}${tail ? `\n${tail}\n` : ''}`;
39
46
  }
40
- function codexAuth(apiKey) {
41
- return JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2) + '\n';
47
+ function codexAuth(apiKey, existing = {}) {
48
+ return JSON.stringify({ ...existing, auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2) + '\n';
42
49
  }
43
50
  function legacyCodexConfigPath(home) {
44
51
  return path.join(home, '.codex', 'config.json');
@@ -81,6 +88,19 @@ async function latestRestorableBackup(file, home) {
81
88
  return undefined;
82
89
  }
83
90
  async function restoreOrRemove(file, home) {
91
+ if (!(await fs.pathExists(file)))
92
+ return 'no ScaleMax configuration found';
93
+ const current = await fs.readFile(file, 'utf8').catch(() => '');
94
+ let managed = current.includes(GENERATED_HEADER);
95
+ if (!managed && file.endsWith('auth.json')) {
96
+ try {
97
+ const parsed = JSON.parse(current);
98
+ managed = parsed?.auth_mode === 'apikey' && typeof parsed?.OPENAI_API_KEY === 'string' && /^sm_(live|dev|trial)_/.test(parsed.OPENAI_API_KEY);
99
+ }
100
+ catch { }
101
+ }
102
+ if (!managed)
103
+ return 'left unmanaged configuration unchanged';
84
104
  const restore = await latestRestorableBackup(file, home);
85
105
  if (restore) {
86
106
  await fs.ensureDir(path.dirname(file));
@@ -88,17 +108,15 @@ async function restoreOrRemove(file, home) {
88
108
  await fs.chmod(file, 0o600).catch(() => undefined);
89
109
  return `restored backup ${restore}`;
90
110
  }
91
- if (await fs.pathExists(file)) {
92
- await fs.remove(file);
93
- return 'removed ScaleMax configuration';
94
- }
95
- return 'no ScaleMax configuration found';
111
+ await fs.remove(file);
112
+ return 'removed ScaleMax configuration';
96
113
  }
97
114
  export async function verifyCodexConfiguration(ctx) {
98
115
  const configPath = codexConfigPath(ctx.home);
99
116
  const authPath = codexAuthPath(ctx.home);
100
117
  const errors = [];
101
118
  const warnings = [];
119
+ const model = selectedModel(ctx);
102
120
  if (!(await fs.pathExists(configPath)))
103
121
  errors.push(`${configPath} does not exist`);
104
122
  if (!(await fs.pathExists(authPath)))
@@ -177,7 +195,8 @@ export async function verifyCodexConfiguration(ctx) {
177
195
  const res = await fetch(`${ctx.baseUrl}/responses`, {
178
196
  method: 'POST',
179
197
  headers: { Authorization: `Bearer ${ctx.apiKey}`, 'Content-Type': 'application/json' },
180
- body: JSON.stringify({ model, input: 'Say OK', stream: false })
198
+ body: JSON.stringify({ model, input: 'Say OK', stream: false }),
199
+ signal: AbortSignal.timeout(15000)
181
200
  });
182
201
  if (res.status === 404)
183
202
  warnings.push('ScaleMax /v1/responses is not available yet. Current Codex requires the Responses API, so it may launch but cannot complete requests until the gateway supports /v1/responses.');
@@ -210,20 +229,38 @@ export async function configure(ctx) {
210
229
  const authPath = codexAuthPath(ctx.home);
211
230
  const legacyConfig = legacyCodexConfigPath(ctx.home);
212
231
  const backups = [];
232
+ const originals = new Map();
213
233
  await fs.ensureDir(path.dirname(configPath));
214
234
  for (const file of [configPath, authPath, legacyConfig]) {
235
+ const exists = await fs.pathExists(file);
236
+ originals.set(file, { exists, content: exists ? await fs.readFile(file, 'utf8') : '' });
215
237
  const backup = await backupFile(file, ctx.home);
216
238
  if (backup)
217
239
  backups.push(backup);
218
240
  }
219
- const existingConfig = await fs.readFile(configPath, 'utf8').catch(() => '');
220
- await atomicWrite(configPath, mergeCodexConfig(existingConfig, ctx.baseUrl), 0o600);
221
- await atomicWrite(authPath, codexAuth(ctx.apiKey), 0o600);
222
- if (legacyConfig.endsWith('config.json') && await fs.pathExists(legacyConfig))
223
- await fs.remove(legacyConfig);
224
- const verification = await verifyCodexConfiguration(ctx);
225
- if (!verification.ok)
226
- throw new Error(`Codex configuration verification failed:\n${verification.errors.map(e => `- ${e}`).join('\n')}`);
241
+ const existingConfig = originals.get(configPath)?.content || '';
242
+ let verification;
243
+ try {
244
+ await atomicWrite(configPath, mergeCodexConfig(existingConfig, ctx.baseUrl, selectedModel(ctx)), 0o600);
245
+ let existingAuth = {};
246
+ try {
247
+ existingAuth = JSON.parse(originals.get(authPath)?.content || '{}');
248
+ }
249
+ catch { }
250
+ await atomicWrite(authPath, codexAuth(ctx.apiKey, existingAuth), 0o600);
251
+ verification = await verifyCodexConfiguration(ctx);
252
+ if (!verification.ok)
253
+ throw new Error(`Codex configuration verification failed:\n${verification.errors.map(e => `- ${e}`).join('\n')}`);
254
+ }
255
+ catch (error) {
256
+ for (const [file, original] of originals) {
257
+ if (original.exists)
258
+ await atomicWrite(file, original.content, 0o600);
259
+ else
260
+ await fs.remove(file).catch(() => undefined);
261
+ }
262
+ throw error;
263
+ }
227
264
  const warningText = verification.warnings.length ? ` Warnings: ${verification.warnings.join(' ')}` : '';
228
265
  const backupText = backups.length ? ` (backups: ${backups.join(', ')})` : '';
229
266
  return { id, name, changed: true, path: configPath, message: `configured and verified${backupText}.${warningText}` };
@@ -1,4 +1,5 @@
1
1
  import { ConfigureContext, ConfigureResult, ClientId } from '../types/client.js';
2
+ export declare function isScaleMaxManagedText(text: string): boolean;
2
3
  export declare function writeManagedConfig(ctx: ConfigureContext, id: ClientId, name: string, file: string, content: string): Promise<ConfigureResult>;
3
4
  export declare function writeMergedJsonConfig(ctx: ConfigureContext, id: ClientId, name: string, file: string, build: (existing: Record<string, unknown>) => string): Promise<ConfigureResult>;
4
5
  export declare function removeManagedConfig(ctx: ConfigureContext, id: ClientId, name: string, file: string): Promise<ConfigureResult>;
@@ -2,8 +2,16 @@ import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { atomicWrite, backupFile, listBackups, readJson } from '../utils/fs.js';
4
4
  import { GENERATED_HEADER } from '../constants.js';
5
+ export function isScaleMaxManagedText(text) {
6
+ return text.includes(GENERATED_HEADER) || text.includes('"scalemax.managedBy"') || text.includes('"managedBy": "ScaleMax CLI generated file"');
7
+ }
5
8
  export async function writeManagedConfig(ctx, id, name, file, content) {
6
9
  await fs.ensureDir(path.dirname(file));
10
+ if (await fs.pathExists(file)) {
11
+ const current = await fs.readFile(file, 'utf8');
12
+ if (!isScaleMaxManagedText(current))
13
+ throw new Error(`Refusing to overwrite unmanaged configuration: ${file}`);
14
+ }
7
15
  const backup = await backupFile(file, ctx.home);
8
16
  await atomicWrite(file, content, 0o600);
9
17
  return { id, name, changed: true, path: file, message: backup ? `configured (backup: ${backup})` : 'configured' };
@@ -24,6 +32,11 @@ async function latestRestorableBackup(file, home) {
24
32
  return undefined;
25
33
  }
26
34
  export async function removeManagedConfig(ctx, id, name, file) {
35
+ if (!(await fs.pathExists(file)))
36
+ return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
37
+ const current = await fs.readFile(file, 'utf8').catch(() => '');
38
+ if (!isScaleMaxManagedText(current))
39
+ return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
27
40
  const restore = await latestRestorableBackup(file, ctx.home);
28
41
  if (restore) {
29
42
  await fs.ensureDir(path.dirname(file));
@@ -31,9 +44,6 @@ export async function removeManagedConfig(ctx, id, name, file) {
31
44
  await fs.chmod(file, 0o600).catch(() => undefined);
32
45
  return { id, name, changed: true, path: file, message: `restored backup ${restore}` };
33
46
  }
34
- if (await fs.pathExists(file)) {
35
- await fs.remove(file);
36
- return { id, name, changed: true, path: file, message: 'removed ScaleMax configuration' };
37
- }
38
- return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
47
+ await fs.remove(file);
48
+ return { id, name, changed: true, path: file, message: 'removed ScaleMax configuration' };
39
49
  }
@@ -21,6 +21,7 @@ export interface CursorBundlePatchResult {
21
21
  backupRoot?: string;
22
22
  }
23
23
  export declare function cursorAppPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string;
24
+ export declare function resolveCursorAppPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, home: string, preferred?: string): Promise<string>;
24
25
  export declare function cursorBundlePaths(appPath: string): string[];
25
26
  export declare function sha256Buffer(value: Buffer | string): string;
26
27
  export declare function inspectCursorBundle(file: string): Promise<CursorBundleInspection>;
@@ -18,6 +18,46 @@ export function cursorAppPath(platform, env) {
18
18
  return '/Applications/Cursor.app';
19
19
  throw new Error(`Automatic Cursor BYOK setup is currently verified on macOS only (${platform} is not modified).`);
20
20
  }
21
+ async function appLooksLikeCursor(appPath) {
22
+ if (!appPath.endsWith('.app'))
23
+ return false;
24
+ return (await Promise.all(CURSOR_BUNDLE_RELATIVE_PATHS.map(p => fs.pathExists(path.join(appPath, p))))).every(Boolean);
25
+ }
26
+ async function cursorAppCandidates(home, env) {
27
+ const roots = env.SCALEMAX_CURSOR_SEARCH_ROOTS ? env.SCALEMAX_CURSOR_SEARCH_ROOTS.split(path.delimiter).filter(Boolean) : ['/Applications', path.join(home, 'Applications')];
28
+ const candidates = roots.map(root => path.join(root, 'Cursor.app'));
29
+ for (const root of roots) {
30
+ let entries = [];
31
+ try {
32
+ entries = await fs.readdir(root);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ for (const entry of entries) {
38
+ if (/cursor/i.test(entry) && entry.endsWith('.app'))
39
+ candidates.push(path.join(root, entry));
40
+ }
41
+ }
42
+ return Array.from(new Set(candidates));
43
+ }
44
+ export async function resolveCursorAppPath(platform, env, home, preferred) {
45
+ if (platform !== 'darwin')
46
+ throw new Error(`Automatic Cursor BYOK setup is currently verified on macOS only (${platform} is not modified).`);
47
+ if (env.SCALEMAX_CURSOR_APP_PATH)
48
+ return path.resolve(env.SCALEMAX_CURSOR_APP_PATH);
49
+ if (preferred && await appLooksLikeCursor(preferred))
50
+ return preferred;
51
+ const valid = [];
52
+ for (const candidate of await cursorAppCandidates(home, env))
53
+ if (await appLooksLikeCursor(candidate))
54
+ valid.push(candidate);
55
+ if (valid.length === 1)
56
+ return valid[0];
57
+ if (valid.length > 1)
58
+ throw new Error(`Multiple Cursor apps were found. Set SCALEMAX_CURSOR_APP_PATH to one of:\n${valid.map(v => `- ${v}`).join('\n')}`);
59
+ return '/Applications/Cursor.app';
60
+ }
21
61
  export function cursorBundlePaths(appPath) { return CURSOR_BUNDLE_RELATIVE_PATHS.map(p => path.join(appPath, p)); }
22
62
  export function sha256Buffer(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
23
63
  function count(text, needle) { let n = 0, from = 0; while ((from = text.indexOf(needle, from)) !== -1) {
@@ -57,23 +97,40 @@ export async function patchCursorBundles(appPath, home, platform, env) {
57
97
  for (let i = 0; i < before.bundles.length; i++) {
58
98
  const b = before.bundles[i], relativePath = CURSOR_BUNDLE_RELATIVE_PATHS[i], backupPath = path.join(backupRoot, relativePath);
59
99
  await fs.ensureDir(path.dirname(backupPath), { mode: 0o700 });
100
+ const original = await fs.readFile(b.path, 'utf8');
101
+ if (sha256Buffer(original) !== b.sha256)
102
+ throw new Error(`Cursor changed during backup: ${b.path}`);
60
103
  await fs.copy(b.path, backupPath, { overwrite: false, errorOnExist: true, preserveTimestamps: true });
61
- const mode = (await fs.stat(b.path)).mode & 0o777;
62
- const original = await fs.readFile(b.path, 'utf8'), patched = original.replace(ORIGINAL, PATCHED);
104
+ const patched = original.replace(ORIGINAL, PATCHED);
63
105
  if (patched === original)
64
106
  throw new Error(`Cursor patch anchor was not changed in ${b.path}`);
65
- await atomicWrite(b.path, patched, mode);
66
107
  records.push({ path: b.path, relativePath, originalSha256: b.sha256, patchedSha256: sha256Buffer(patched), backupPath });
67
108
  }
109
+ for (const r of records) {
110
+ const original = await fs.readFile(r.path, 'utf8'), mode = (await fs.stat(r.path)).mode & 0o777;
111
+ await atomicWrite(r.path, original.replace(ORIGINAL, PATCHED), mode);
112
+ }
68
113
  const after = await inspectCursorBundles(appPath);
69
114
  if (after.status !== 'patched' || records.some((r, i) => r.patchedSha256 !== after.bundles[i].sha256))
70
115
  throw new Error('Cursor bundle postcondition failed.');
71
116
  return { status: 'patched', managed: true, records, backupRoot };
72
117
  }
73
118
  catch (error) {
74
- for (const r of records.slice().reverse())
75
- if (r.backupPath && await fs.pathExists(r.backupPath))
76
- await fs.copy(r.backupPath, r.path, { overwrite: true, preserveTimestamps: true }).catch(() => undefined);
119
+ let rollbackError;
120
+ try {
121
+ for (const r of records)
122
+ if (r.backupPath && await fs.pathExists(r.backupPath)) {
123
+ const mode = await fs.pathExists(r.path) ? (await fs.stat(r.path)).mode & 0o777 : 0o644;
124
+ await atomicWrite(r.path, await fs.readFile(r.backupPath, 'utf8'), mode);
125
+ }
126
+ if (records.some(r => sha256Buffer(fs.readFileSync(r.path)) !== r.originalSha256))
127
+ throw new Error('rollback hash mismatch');
128
+ }
129
+ catch (e) {
130
+ rollbackError = e;
131
+ }
132
+ if (rollbackError)
133
+ throw new Error(`Cursor patch failed and rollback was incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
77
134
  throw error;
78
135
  }
79
136
  }
@@ -54,15 +54,16 @@ async function writeRows(db, state, secret) {
54
54
  if (result.exitCode !== 0)
55
55
  throw new Error('Unable to update Cursor protected state. No API key was printed.');
56
56
  }
57
+ function sqliteDotPath(value) { return `"${value.replaceAll('"', '""')}"`; }
58
+ async function sqliteBackup(db, backup) { const r = await execa('/usr/bin/sqlite3', [db, `.backup ${sqliteDotPath(backup)}`], { reject: false }); if (r.exitCode !== 0)
59
+ throw new Error('Unable to create a consistent Cursor SQLite backup.'); }
60
+ async function sqliteRestore(db, backup) { const r = await execa('/usr/bin/sqlite3', [db, `.restore ${sqliteDotPath(backup)}`], { reject: false }); if (r.exitCode !== 0)
61
+ throw new Error('Unable to restore the Cursor SQLite backup.'); }
57
62
  async function keychainPassword() {
58
63
  const found = await execa('/usr/bin/security', ['find-generic-password', '-w', '-s', KEYCHAIN_SERVICE], { reject: false });
59
64
  if (found.exitCode === 0 && found.stdout)
60
65
  return found.stdout.trimEnd();
61
- const password = crypto.randomBytes(32).toString('base64url');
62
- const add = await execa('/usr/bin/security', ['add-generic-password', '-U', '-a', 'Cursor', '-s', KEYCHAIN_SERVICE, '-w', password], { reject: false });
63
- if (add.exitCode !== 0)
64
- throw new Error('Cursor Safe Storage is unavailable. Launch Cursor once, close it, and retry.');
65
- return password;
66
+ throw new Error('Cursor Safe Storage is unavailable. Launch Cursor once, close it, and retry.');
66
67
  }
67
68
  export async function configureCursorState(home, platform, env, apiKey, baseUrl, models, backupRoot) {
68
69
  const dbPath = cursorStateDbPath(home, platform, env);
@@ -82,18 +83,18 @@ export async function configureCursorState(home, platform, env, apiKey, baseUrl,
82
83
  const originalSecret = await sqliteValue(dbPath, CURSOR_SECRET_KEY), configuredSecret = encryptCursorSecret(await keychainPassword(), apiKey);
83
84
  const backupPath = path.join(backupRoot, 'state.vscdb');
84
85
  await fs.ensureDir(path.dirname(backupPath), { mode: 0o700 });
85
- await fs.copy(dbPath, backupPath, { overwrite: false, errorOnExist: true });
86
- await fs.chmod(backupPath, 0o600).catch(() => undefined);
86
+ await sqliteBackup(dbPath, backupPath);
87
+ await fs.chmod(backupPath, 0o600);
87
88
  try {
88
89
  await writeRows(dbPath, configuredState, configuredSecret);
89
90
  }
90
91
  catch (error) {
91
- await fs.copy(backupPath, dbPath, { overwrite: true });
92
+ await sqliteRestore(dbPath, backupPath);
92
93
  throw error;
93
94
  }
94
95
  const verifyState = await sqliteValue(dbPath, CURSOR_STATE_KEY), verifySecret = await sqliteValue(dbPath, CURSOR_SECRET_KEY);
95
96
  if (verifyState !== configuredState || verifySecret !== configuredSecret) {
96
- await fs.copy(backupPath, dbPath, { overwrite: true });
97
+ await sqliteRestore(dbPath, backupPath);
97
98
  throw new Error('Cursor protected-state verification failed; original database restored.');
98
99
  }
99
100
  return { dbPath, backupPath, originalState, configuredState, originalSecret, configuredSecret, baseUrl: normalizeCursorBaseUrl(baseUrl), modelIds: configured.localProviderModelIds };
@@ -122,6 +123,7 @@ export async function restoreCursorState(record) {
122
123
  for (const key of ['useOpenAIKey', 'openAIBaseUrl', 'localProviderModelIds', 'availableDefaultModels2'])
123
124
  restoreField(key);
124
125
  const currentSecret = await sqliteValue(record.dbPath, CURSOR_SECRET_KEY), secret = currentSecret === record.configuredSecret ? record.originalSecret : currentSecret;
125
- await writeRows(record.dbPath, JSON.stringify(current), secret);
126
- return true;
126
+ const restoredState = JSON.stringify(current);
127
+ await writeRows(record.dbPath, restoredState, secret);
128
+ return await sqliteValue(record.dbPath, CURSOR_STATE_KEY) === restoredState && await sqliteValue(record.dbPath, CURSOR_SECRET_KEY) === secret;
127
129
  }
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
3
3
  import { execa } from 'execa';
4
4
  import { scaleMaxDir, timestamp } from '../utils/paths.js';
5
5
  import { writeJsonAtomic, readJson } from '../utils/fs.js';
6
- import { cursorAppPath, inspectCursorBundles, patchCursorBundles, restoreCursorBundles } from './cursor-bundle.js';
6
+ import { resolveCursorAppPath, inspectCursorBundles, patchCursorBundles, restoreCursorBundles } from './cursor-bundle.js';
7
7
  import { configureCursorState, restoreCursorState } from './cursor-state.js';
8
8
  const id = 'cursor', name = 'Cursor';
9
9
  function manifestPath(ctx) { return path.join(scaleMaxDir(ctx.home, ctx.platform, ctx.env), 'cursor', 'manifest.json'); }
@@ -13,17 +13,21 @@ async function cursorVersion(appPath) { try {
13
13
  catch {
14
14
  return undefined;
15
15
  } }
16
- export async function isCursorRunning(appPath) { const r = await execa('/usr/bin/pgrep', ['-f', `${appPath}/Contents/MacOS/Cursor`], { reject: false }); return r.exitCode === 0 && Boolean(r.stdout.trim()); }
17
- async function stopCursor(appPath) { await execa('/usr/bin/osascript', ['-e', 'tell application "Cursor" to quit'], { reject: false }); for (let i = 0; i < 30; i++) {
16
+ export async function isCursorRunning(appPath) { const r = await execa('/usr/bin/pgrep', ['-f', `${appPath}/Contents/MacOS/`], { reject: false }); return r.exitCode === 0 && Boolean(r.stdout.trim()); }
17
+ async function stopCursor(appPath) { await execa('/usr/bin/pkill', ['-TERM', '-f', `${appPath}/Contents/MacOS/`], { reject: false }); for (let i = 0; i < 30; i++) {
18
18
  if (!(await isCursorRunning(appPath)))
19
19
  return;
20
20
  await new Promise(r => setTimeout(r, 500));
21
21
  } throw new Error('Cursor is still running. Close Cursor completely and rerun `npx scalemax`.'); }
22
- async function launchCursor(appPath) { await execa('/usr/bin/open', ['-a', appPath], { reject: false }); }
22
+ async function launchCursor(appPath) { const r = await execa('/usr/bin/open', ['-a', appPath], { reject: false }); if (r.exitCode !== 0)
23
+ throw new Error(`Cursor was configured but could not be relaunched: ${r.stderr || r.stdout || 'open failed'}`); }
23
24
  export async function configure(ctx) {
24
25
  if (ctx.platform !== 'darwin')
25
26
  return { id, name, changed: false, message: `automatic Cursor BYOK setup is currently verified on macOS only; ${ctx.platform} was not modified` };
26
- const appPath = cursorAppPath(ctx.platform, ctx.env);
27
+ if (typeof process.geteuid === 'function' && process.geteuid() === 0)
28
+ throw new Error('Refusing to modify Cursor.app as root. Run `npx scalemax` as the signed-in macOS user.');
29
+ const prior = await readJson(manifestPath(ctx), undefined);
30
+ const appPath = await resolveCursorAppPath(ctx.platform, ctx.env, ctx.home, prior?.appPath);
27
31
  if (!(await fs.pathExists(appPath)))
28
32
  return { id, name, changed: false, path: appPath, message: 'Cursor.app was not found' };
29
33
  const inspection = await inspectCursorBundles(appPath);
@@ -37,7 +41,6 @@ export async function configure(ctx) {
37
41
  throw new Error('Close Cursor completely and rerun `npx scalemax`.');
38
42
  await stopCursor(appPath);
39
43
  }
40
- const prior = await readJson(manifestPath(ctx), undefined);
41
44
  let patchResult, state;
42
45
  const stateBackupRoot = path.join(scaleMaxDir(ctx.home, ctx.platform, ctx.env), 'cursor', 'backups', timestamp());
43
46
  try {
@@ -45,12 +48,9 @@ export async function configure(ctx) {
45
48
  const backupRoot = patchResult.backupRoot || stateBackupRoot;
46
49
  await fs.ensureDir(backupRoot, { mode: 0o700 });
47
50
  state = await configureCursorState(ctx.home, ctx.platform, ctx.env, ctx.apiKey, ctx.baseUrl, ctx.models || [], backupRoot);
48
- if (prior?.state) {
49
- state.originalState = prior.state.originalState;
50
- state.originalSecret = prior.state.originalSecret;
51
- }
51
+ const manifestState = prior?.state ? { ...state, originalState: prior.state.originalState, originalSecret: prior.state.originalSecret } : state;
52
52
  const bundles = patchResult.managed ? patchResult.records : (prior?.bundleManaged ? prior.bundles : patchResult.records);
53
- const manifest = { schemaVersion: 1, createdAt: new Date().toISOString(), appPath, version: await cursorVersion(appPath), bundleManaged: Boolean(patchResult.managed || prior?.bundleManaged), bundles, state };
53
+ const manifest = { schemaVersion: 1, createdAt: new Date().toISOString(), appPath, version: await cursorVersion(appPath), bundleManaged: Boolean(patchResult.managed || prior?.bundleManaged), bundles, state: manifestState };
54
54
  await writeJsonAtomic(manifestPath(ctx), manifest, 0o600);
55
55
  if (wasRunning)
56
56
  await launchCursor(appPath);
@@ -69,7 +69,7 @@ export async function configure(ctx) {
69
69
  export async function verify(ctx) {
70
70
  if (ctx.platform !== 'darwin')
71
71
  return { id, name, changed: false, message: 'verification is currently available on macOS only' };
72
- const appPath = cursorAppPath(ctx.platform, ctx.env), inspection = await inspectCursorBundles(appPath), manifest = await readJson(manifestPath(ctx), undefined);
72
+ const manifest = await readJson(manifestPath(ctx), undefined), appPath = await resolveCursorAppPath(ctx.platform, ctx.env, ctx.home, manifest?.appPath), inspection = await inspectCursorBundles(appPath);
73
73
  if (inspection.status !== 'patched' || !manifest?.state?.modelIds?.length)
74
74
  throw new Error('Cursor local BYOK patch or protected model state is incomplete.');
75
75
  const live = new Set((ctx.models || []).map(m => m.id)), configured = new Set(manifest.state.modelIds), missing = [...live].filter(x => !configured.has(x));
@@ -85,7 +85,10 @@ export async function remove(ctx) {
85
85
  return { id, name, changed: false, path: file, message: 'no ScaleMax-managed Cursor manifest found' };
86
86
  if (await isCursorRunning(manifest.appPath))
87
87
  throw new Error('Close Cursor completely before removing its ScaleMax configuration.');
88
- const stateRestored = await restoreCursorState(manifest.state), bundlesRestored = manifest.bundleManaged ? await restoreCursorBundles(manifest.bundles) : false;
88
+ const stateRestored = await restoreCursorState(manifest.state);
89
+ if (!stateRestored)
90
+ throw new Error('Cursor protected state was not fully restored; recovery manifest was kept for retry.');
91
+ const bundlesRestored = manifest.bundleManaged ? await restoreCursorBundles(manifest.bundles) : false;
89
92
  await fs.remove(file);
90
93
  return { id, name, changed: stateRestored || bundlesRestored, path: file, message: `removed protected BYOK state${bundlesRestored ? ' and restored original Cursor bundles' : ' (pre-existing external Cursor patch left unchanged)'}` };
91
94
  }
@@ -5,7 +5,7 @@ const id = 'trae-solo';
5
5
  const name = 'TRAE SOLO';
6
6
  export async function configure(ctx) {
7
7
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, ctx.apiKey, ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", "Request URL: https://api.scalemax.pro/v1/chat/completions or base URL https://api.scalemax.pro/v1", "Model ID: fable-5 or another ScaleMax model"], "recommendedModel": "fable-5" });
8
+ const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", "Request URL: https://api.scalemax.pro/v1/chat/completions or base URL https://api.scalemax.pro/v1", "Model ID: fable-5 or another ScaleMax model"], "recommendedModel": "fable-5" });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'setup guide written; enter these values in TRAE SOLO custom model settings' };
11
11
  }
@@ -1,28 +1,27 @@
1
1
  import fs from 'fs-extra';
2
2
  import { commandExists } from '../utils/exec.js';
3
3
  import { clientConfigPath, installHints } from '../configure/paths.js';
4
+ import { GENERATED_HEADER } from '../constants.js';
4
5
  const commands = {
5
- codex: ['codex'],
6
- cursor: ['cursor'],
7
- 'trae-solo': ['trae', 'trae-solo'],
8
- claude: ['claude'],
9
- opencode: ['opencode'],
10
- openclaw: ['openclaw'],
11
- hermes: ['hermes'],
12
- 'cherry-studio': ['cherry-studio', 'cherrystudio'],
13
- 'api-code': []
6
+ codex: ['codex'], cursor: ['cursor'], 'trae-solo': ['trae', 'trae-solo'], claude: ['claude'], opencode: ['opencode'], openclaw: ['openclaw'], hermes: ['hermes'], 'cherry-studio': ['cherry-studio', 'cherrystudio'], 'api-code': []
14
7
  };
8
+ const guiPathDetection = new Set(['trae-solo', 'cherry-studio']);
15
9
  export async function detectClient(ctx, id, name) {
16
- const paths = [clientConfigPath(id, ctx.home, ctx.platform, ctx.env), ...installHints(id, ctx.home, ctx.platform, ctx.env)];
17
- const pathHits = [];
18
- for (const p of paths)
19
- if (await fs.pathExists(p))
20
- pathHits.push(p);
10
+ const config = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
21
11
  const cmdHits = [];
22
12
  for (const cmd of commands[id])
23
- if (await commandExists(cmd))
13
+ if (await commandExists(cmd, ctx.env))
24
14
  cmdHits.push(`command:${cmd}`);
25
- const hits = [...cmdHits, ...pathHits];
26
- const alwaysAvailable = id === 'api-code';
27
- return { id, name, installed: hits.length > 0 || alwaysAvailable, reason: hits.length || alwaysAvailable ? undefined : 'not detected', paths: hits };
15
+ const appHits = [];
16
+ if (guiPathDetection.has(id))
17
+ for (const p of installHints(id, ctx.home, ctx.platform, ctx.env))
18
+ if (!p.includes('.scalemax') && await fs.pathExists(p))
19
+ appHits.push(p);
20
+ let configured = false;
21
+ if (await fs.pathExists(config)) {
22
+ const text = await fs.readFile(config, 'utf8').catch(() => '');
23
+ configured = text.includes(GENERATED_HEADER) || text.includes('"scalemax.managedBy"');
24
+ }
25
+ const hits = [...cmdHits, ...appHits];
26
+ return { id, name, installed: hits.length > 0, configured, reason: hits.length ? undefined : 'not detected', paths: [...hits, ...(configured ? [config] : [])] };
28
27
  }
@@ -1,11 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { scaleMaxDir } from '../utils/paths.js';
4
- import { cursorAppPath, inspectCursorBundles } from '../configure/cursor-bundle.js';
4
+ import { resolveCursorAppPath, inspectCursorBundles } from '../configure/cursor-bundle.js';
5
5
  export async function detect(ctx) {
6
6
  if (ctx.platform !== 'darwin')
7
7
  return { id: 'cursor', name: 'Cursor', installed: false, configured: false, paths: [], reason: `automatic BYOK setup currently supports macOS only (${ctx.platform})` };
8
- const appPath = cursorAppPath(ctx.platform, ctx.env), manifest = path.join(scaleMaxDir(ctx.home, ctx.platform, ctx.env), 'cursor', 'manifest.json');
8
+ const manifest = path.join(scaleMaxDir(ctx.home, ctx.platform, ctx.env), 'cursor', 'manifest.json');
9
+ const managed = await fs.readJson(manifest).catch(() => undefined);
10
+ const appPath = await resolveCursorAppPath(ctx.platform, ctx.env, ctx.home, managed?.appPath);
9
11
  if (!(await fs.pathExists(appPath)))
10
12
  return { id: 'cursor', name: 'Cursor', installed: false, configured: false, paths: [appPath], reason: 'Cursor.app not found' };
11
13
  try {
package/dist/index.js CHANGED
@@ -31,6 +31,27 @@ function welcome() {
31
31
  console.log(chalk.cyan('╚══════════════════════════════╝'));
32
32
  console.log('\nThe fastest way to connect every AI coding client.\n');
33
33
  }
34
+ function printHelp(command) {
35
+ const details = { update: 'Revalidate the API key and reconfigure every detected client.', remove: 'Restore backups or remove only ScaleMax-managed client configuration.', doctor: 'Validate the API key and verify detected client configuration.', models: 'List models available to the stored API key.', login: 'Store or replace the ScaleMax API key.', logout: 'Remove the locally stored ScaleMax API key.' };
36
+ if (command && details[command]) {
37
+ console.log(`Usage: scalemax ${command}\n\n${details[command]}`);
38
+ return;
39
+ }
40
+ console.log(`ScaleMax CLI
41
+
42
+ Commands:
43
+ scalemax Interactive installer
44
+ scalemax init Interactive installer (alias for no-arg scalemax)
45
+ scalemax setup Interactive installer (alias)
46
+ scalemax login Store or replace API key
47
+ scalemax logout Remove API key
48
+ scalemax doctor Run diagnostics
49
+ scalemax models List available models
50
+ scalemax update Reconfigure detected clients
51
+ scalemax remove Remove ScaleMax-generated configs
52
+ scalemax --version Show installed version
53
+ `);
54
+ }
34
55
  async function runInteractiveInstaller() {
35
56
  welcome();
36
57
  await runSystemAudit();
@@ -42,17 +63,23 @@ async function runInteractiveInstaller() {
42
63
  }
43
64
  async function main() {
44
65
  if (!isNodeSupported()) {
45
- console.error(chalk.red(`ScaleMax requires Node.js >=18.17. Current: ${process.versions.node}`));
66
+ console.error(chalk.red(`ScaleMax requires Node.js >=18.19. Current: ${process.versions.node}`));
46
67
  console.error(nodeUpgradeHint());
47
68
  process.exitCode = 1;
48
69
  return;
49
70
  }
50
- const [, , command] = process.argv;
71
+ const [, , command, ...args] = process.argv;
51
72
  try {
52
73
  if (!command) {
53
74
  await runInteractiveInstaller();
54
75
  return;
55
76
  }
77
+ if (args.some(arg => arg === '--help' || arg === '-h')) {
78
+ printHelp(command);
79
+ return;
80
+ }
81
+ if (args.length)
82
+ throw new Error(`Unknown option for ${command}: ${args.join(' ')}`);
56
83
  switch (command) {
57
84
  case 'init':
58
85
  case 'setup':
@@ -82,20 +109,7 @@ async function main() {
82
109
  break;
83
110
  case '--help':
84
111
  case '-h':
85
- console.log(`ScaleMax CLI
86
-
87
- Commands:
88
- scalemax Interactive installer
89
- scalemax init Interactive installer (alias for no-arg scalemax)
90
- scalemax setup Interactive installer (alias)
91
- scalemax login Store or replace API key
92
- scalemax logout Remove API key
93
- scalemax doctor Run diagnostics
94
- scalemax models List available models
95
- scalemax update Reconfigure detected clients
96
- scalemax remove Remove ScaleMax-generated configs
97
- scalemax --version Show installed version
98
- `);
112
+ printHelp();
99
113
  break;
100
114
  default: throw new Error(`Unknown command: ${command}`);
101
115
  }
package/dist/utils/api.js CHANGED
@@ -27,7 +27,9 @@ async function request(path, apiKey, init = {}) {
27
27
  headers.set('Cache-Control', 'no-cache');
28
28
  if (!headers.has('Content-Type') && init.body)
29
29
  headers.set('Content-Type', 'application/json');
30
- return fetch(`${SCALEMAX_BASE_URL}${path}`, { ...init, headers });
30
+ const timeout = AbortSignal.timeout(15000);
31
+ const signal = init.signal && typeof AbortSignal.any === 'function' ? AbortSignal.any([init.signal, timeout]) : timeout;
32
+ return fetch(`${SCALEMAX_BASE_URL}${path}`, { ...init, headers, signal });
31
33
  }
32
34
  export async function validateApiKey(apiKey) {
33
35
  try {
@@ -38,12 +40,16 @@ export async function validateApiKey(apiKey) {
38
40
  const keyMeta = { provider_scope: usage?.key?.provider_scope || 'both', mode: usage?.key?.mode, prefix: usage?.key?.prefix, label: usage?.key?.label };
39
41
  const modelsRes = await request('/models', apiKey);
40
42
  if (!modelsRes.ok)
41
- return { ok: true, models: [], key: keyMeta, error: await friendlyError(modelsRes) };
43
+ return { ok: false, models: [], key: keyMeta, error: await friendlyError(modelsRes) };
42
44
  const data = await modelsRes.json();
43
- return { ok: true, models: Array.isArray(data.data) ? data.data : [], key: keyMeta };
45
+ const models = Array.isArray(data.data) ? data.data : [];
46
+ if (!models.length)
47
+ return { ok: false, models: [], key: keyMeta, error: 'ScaleMax returned an empty model catalog.' };
48
+ return { ok: true, models, key: keyMeta };
44
49
  }
45
50
  catch (err) {
46
- return { ok: false, models: [], error: err instanceof Error ? err.message : String(err) };
51
+ const message = err instanceof Error ? err.message : String(err);
52
+ return { ok: false, models: [], error: /abort|timeout/i.test(message) ? 'ScaleMax request timed out after 15 seconds.' : message };
47
53
  }
48
54
  }
49
55
  export async function testStreaming(apiKey, model) {
package/dist/utils/fs.js CHANGED
@@ -1,13 +1,37 @@
1
1
  import fs from 'fs-extra';
2
+ import fsp from 'node:fs/promises';
3
+ import crypto from 'node:crypto';
2
4
  import path from 'node:path';
3
5
  import { backupDir, timestamp } from './paths.js';
4
6
  export async function exists(p) { return fs.pathExists(p); }
5
7
  export async function atomicWrite(file, content, mode = 0o600) {
6
8
  await fs.ensureDir(path.dirname(file));
7
- const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
8
- await fs.writeFile(tmp, content, { mode });
9
- await fs.rename(tmp, file);
10
- await fs.chmod(file, mode).catch(() => undefined);
9
+ const existing = await fs.lstat(file).catch(() => undefined);
10
+ if (existing?.isSymbolicLink())
11
+ throw new Error(`Refusing to overwrite symlink: ${file}`);
12
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
13
+ let handle;
14
+ try {
15
+ handle = await fsp.open(tmp, 'wx', mode);
16
+ await handle.writeFile(content, 'utf8');
17
+ await handle.sync();
18
+ await handle.close();
19
+ handle = undefined;
20
+ await fsp.rename(tmp, file);
21
+ await fsp.chmod(file, mode);
22
+ const dir = await fsp.open(path.dirname(file), 'r');
23
+ try {
24
+ await dir.sync();
25
+ }
26
+ finally {
27
+ await dir.close();
28
+ }
29
+ }
30
+ finally {
31
+ if (handle)
32
+ await handle.close().catch(() => undefined);
33
+ await fsp.rm(tmp, { force: true }).catch(() => undefined);
34
+ }
11
35
  }
12
36
  export function backupNameFor(file) {
13
37
  const withoutDrive = file.replace(/^[A-Za-z]:[\\/]?/, '').replace(/^[\\/]+/, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  "vitest": "^2.1.8"
42
42
  },
43
43
  "engines": {
44
- "node": ">=18.17"
44
+ "node": ">=18.19"
45
45
  },
46
46
  "keywords": [
47
47
  "scalemax",
@@ -62,11 +62,6 @@
62
62
  "license": "MIT",
63
63
  "author": "ScaleMax <support@scalemax.pro>",
64
64
  "homepage": "https://scalemax.pro",
65
- "repository": {
66
- "type": "git",
67
- "url": "git+https://github.com/scalemax-pro/scalemax.git",
68
- "directory": "packages/cli"
69
- },
70
65
  "bugs": {
71
66
  "url": "https://scalemax.pro",
72
67
  "email": "support@scalemax.pro"