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 +5 -1
- package/dist/clients.js +1 -1
- package/dist/commands/configure.js +23 -4
- package/dist/commands/doctor.js +36 -22
- package/dist/commands/remove.js +7 -2
- package/dist/configure/api-code.js +1 -1
- package/dist/configure/cherry-studio.js +1 -1
- package/dist/configure/claude.d.ts +1 -0
- package/dist/configure/claude.js +32 -20
- package/dist/configure/codex.d.ts +1 -1
- package/dist/configure/codex.js +59 -22
- package/dist/configure/common.d.ts +1 -0
- package/dist/configure/common.js +15 -5
- package/dist/configure/cursor-bundle.d.ts +1 -0
- package/dist/configure/cursor-bundle.js +63 -6
- package/dist/configure/cursor-state.js +13 -11
- package/dist/configure/cursor.js +16 -13
- package/dist/configure/trae-solo.js +1 -1
- package/dist/detect/common.js +17 -18
- package/dist/detect/cursor.js +4 -2
- package/dist/index.js +30 -16
- package/dist/utils/api.js +10 -4
- package/dist/utils/fs.js +28 -4
- package/package.json +2 -7
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.
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
const d = detections.find(x => x.id ===
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
console.log(`${chalk.yellow('!')} ${
|
|
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.
|
|
61
|
+
console.log(`${chalk.green('✓')} ${client.name} configured (${d.reason || 'detector confirmed'})`);
|
|
48
62
|
}
|
|
49
63
|
console.log('');
|
|
50
64
|
if (failures === 0)
|
package/dist/commands/remove.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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>;
|
package/dist/configure/claude.js
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
import { clientConfigPath } from './paths.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
8
|
-
env: {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
22
|
-
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
23
|
-
|
|
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>;
|
package/dist/configure/codex.js
CHANGED
|
@@ -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
|
|
11
|
+
const fallbackModel = 'gpt-5.5';
|
|
12
12
|
const reasoningEffort = 'medium';
|
|
13
|
-
function
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
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 =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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>;
|
package/dist/configure/common.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
await fs.chmod(backupPath, 0o600)
|
|
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
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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
|
}
|
package/dist/configure/cursor.js
CHANGED
|
@@ -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 {
|
|
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
|
|
17
|
-
async function stopCursor(appPath) { await execa('/usr/bin/
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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,
|
|
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
|
}
|
package/dist/detect/common.js
CHANGED
|
@@ -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
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
}
|
package/dist/detect/cursor.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
43
|
+
return { ok: false, models: [], key: keyMeta, error: await friendlyError(modelsRes) };
|
|
42
44
|
const data = await modelsRes.json();
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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.
|
|
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"
|