scalemax 0.1.32 → 0.1.33

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
@@ -136,6 +136,7 @@ The helper refuses unrelated requests and redacts ScaleMax-style secrets from pr
136
136
  - Codex
137
137
  - Cursor
138
138
  - Roo Code (VS Code)
139
+ - Cline (CLI and VS Code)
139
140
  - Continue (VS Code)
140
141
  - TRAE SOLO
141
142
  - Claude Code
@@ -171,6 +172,10 @@ ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and
171
172
 
172
173
  Roo Code stores provider profiles and secrets in VS Code extension storage, so ScaleMax does not mutate its private database. Instead, the CLI writes a private `0600` Roo settings-import file at `~/.scalemax/roo-code-import.json` and points the supported `roo-cline.autoImportSettingsPath` VS Code setting at it. Codex, Claude, and tool-capable DeepSeek profiles remain separate. DeepSeek is offered only when authenticated discovery reports `capabilities.tools=true`; otherwise Roo fails closed and points the user to Continue Chat/Edit. Reload VS Code once so Roo imports supported profiles into its SecretStorage. CLI verification intentionally reports the handoff as verified, not the extension runtime, until that reload occurs.
173
174
 
175
+ ### Cline (CLI and VS Code)
176
+
177
+ ScaleMax supports the verified Cline CLI `3.0.47` and VS Code extension `4.0.12` formats. The CLI surface uses Cline's built-in `openai-native` provider and Responses protocol. The extension uses its built-in OpenAI-compatible provider. Both receive the raw ScaleMax key and the matching credit or token base URL; ScaleMax never writes a custom `Authorization` or `x-api-key` header because Cline generates `Authorization: Bearer …` itself. Configuration is attempted only while Cline and VS Code are stopped, preserves unrelated state, uses private atomic files with backups and rollback, and fails closed on unknown versions or malformed/symlinked state.
178
+
174
179
  ### Continue (VS Code)
175
180
 
176
181
  Continue is the universal VS Code Chat/Edit route for `deepseek-v4-flash`. The CLI installs the official `Continue.continue` extension when VS Code is available, merges an OpenAI-compatible model into `~/.continue/config.yaml`, sets `useResponsesApi: false`, and keeps the authenticated ScaleMax key in private `~/.continue/.env` rather than the YAML. Existing Continue models and environment entries are preserved. Reload VS Code and select **ScaleMax DeepSeek V4 Flash**. For agent workflows, prefer Roo/OpenClaw/Hermes when the authenticated catalog reports `capabilities.tools=true`.
package/dist/clients.js CHANGED
@@ -4,6 +4,8 @@ import * as cursorDetect from './detect/cursor.js';
4
4
  import * as cursorConfig from './configure/cursor.js';
5
5
  import * as rooCodeDetect from './detect/roo-code.js';
6
6
  import * as rooCodeConfig from './configure/roo-code.js';
7
+ import * as clineDetect from './detect/cline.js';
8
+ import * as clineConfig from './configure/cline.js';
7
9
  import * as continueDetect from './detect/continue.js';
8
10
  import * as continueConfig from './configure/continue.js';
9
11
  import * as traeSoloDetect from './detect/trae-solo.js';
@@ -24,6 +26,7 @@ export const clients = [
24
26
  { id: 'codex', name: 'Codex', detect: codexDetect.detect, install: codexConfig.install, configure: codexConfig.configure, verify: codexConfig.verify, launch: codexConfig.launch, remove: codexConfig.remove },
25
27
  { id: 'cursor', name: 'Cursor', detect: cursorDetect.detect, configure: cursorConfig.configure, verify: cursorConfig.verify, remove: cursorConfig.remove },
26
28
  { id: 'roo-code', name: 'Roo Code (VS Code)', detect: rooCodeDetect.detect, configure: rooCodeConfig.configure, verify: rooCodeConfig.verify, remove: rooCodeConfig.remove },
29
+ { id: 'cline', name: 'Cline', detect: clineDetect.detect, install: clineConfig.install, configure: clineConfig.configure, verify: clineConfig.verify, remove: clineConfig.remove },
27
30
  { id: 'continue', name: 'Continue (VS Code)', detect: continueDetect.detect, install: continueConfig.install, configure: continueConfig.configure, verify: continueConfig.verify, remove: continueConfig.remove },
28
31
  { id: 'trae-solo', name: 'TRAE SOLO', detect: traeSoloDetect.detect, configure: traeSoloConfig.configure, remove: traeSoloConfig.remove },
29
32
  { id: 'claude', name: 'Claude Code', detect: claudeDetect.detect, configure: claudeConfig.configure, verify: claudeConfig.verify, remove: claudeConfig.remove },
@@ -48,7 +48,7 @@ export function clientAllowedForModels(clientId, models) {
48
48
  // normalized OpenAI-compatible Chat Completions route, which supports both
49
49
  // authenticated model families. Codex remains Responses-only; TRAE uses
50
50
  // OpenAI-compatible Chat Completions.
51
- const crossProtocolClients = new Set(['cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
51
+ const crossProtocolClients = new Set(['cursor', 'roo-code', 'cline', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
52
52
  if (clientId === 'continue')
53
53
  return models.some(model => isExactChatCompletionsModelId(model.id));
54
54
  if (clientId === 'claude')
@@ -60,7 +60,7 @@ export function clientAllowedForModels(clientId, models) {
60
60
  // Roo is an agent, not a text-only chat surface. Its task loop depends on
61
61
  // native function calls for every model family, so offer it only when the
62
62
  // authenticated catalog confirms at least one viable route.
63
- if (clientId === 'roo-code')
63
+ if (clientId === 'roo-code' || clientId === 'cline')
64
64
  return models.some(model => model.capabilities?.tools === true);
65
65
  if (crossProtocolClients.has(clientId))
66
66
  return true;
@@ -763,6 +763,7 @@ For automatic configuration, exit with \`/quit\`, run \`scalemax update\`, then
763
763
  const HELPER_CLIENT_PATTERNS = [
764
764
  { id: 'continue', pattern: /\b(?:continue(?:\.dev)?|vscode\s+continue|vs\s+code\s+continue)\b/i },
765
765
  { id: 'roo-code', pattern: /\b(?:roo(?:\s+code)?|roo\s+(?:extension|plugin)|vscode\s+roo|vs\s+code\s+roo)\b/i },
766
+ { id: 'cline', pattern: /\bcline(?:\s+(?:extension|plugin|cli))?\b/i },
766
767
  { id: 'openclaw', pattern: /\bopen\s*claw\b/i },
767
768
  { id: 'opencode', pattern: /\bopen\s*code\b/i },
768
769
  { id: 'cherry-studio', pattern: /\bcherry\s*studio\b/i },
@@ -841,7 +842,7 @@ function actionModels(connection, requestedScope) {
841
842
  function helperClientAllowedForModels(clientId, models) {
842
843
  if (!models.length)
843
844
  return false;
844
- const crossProtocolClients = new Set(['cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
845
+ const crossProtocolClients = new Set(['cursor', 'roo-code', 'cline', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
845
846
  const onlyExactChat = models.every(model => isExactChatCompletionsModelId(model.id));
846
847
  if (clientId === 'continue')
847
848
  return onlyExactChat;
@@ -851,7 +852,7 @@ function helperClientAllowedForModels(clientId, models) {
851
852
  return false;
852
853
  if (clientId === 'hermes' && onlyExactChat)
853
854
  return models.some(model => model.capabilities?.tools === true);
854
- if (clientId === 'roo-code')
855
+ if (clientId === 'roo-code' || clientId === 'cline')
855
856
  return models.some(model => model.capabilities?.tools === true);
856
857
  if (crossProtocolClients.has(clientId))
857
858
  return true;
@@ -1014,7 +1015,9 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
1014
1015
  }
1015
1016
  const activation = client.id === 'roo-code'
1016
1017
  ? ' Reload VS Code once so Roo Code imports the verified ScaleMax profile.'
1017
- : client.id === 'continue' ? ' Reload VS Code once so Continue loads the verified ScaleMax profile.' : '';
1018
+ : client.id === 'cline'
1019
+ ? ' Restart Cline; reload VS Code too when using the extension.'
1020
+ : client.id === 'continue' ? ' Reload VS Code once so Continue loads the verified ScaleMax profile.' : '';
1018
1021
  return `✅ Successfully completed: ${client.name} is configured and locally verified.${activation}`;
1019
1022
  }
1020
1023
  /** Execute only narrowly allowlisted ScaleMax client actions; never model-generated shell commands. */
@@ -0,0 +1,29 @@
1
+ import { ConfigureContext, ConfigureResult, DetectContext, InstallContext } from '../types/client.js';
2
+ export interface ClineExtensionSurface {
3
+ path: string;
4
+ version?: string;
5
+ supported: boolean;
6
+ }
7
+ export interface ClineSurfaces {
8
+ cliInstalled: boolean;
9
+ cliVersion?: string;
10
+ cliSupported: boolean;
11
+ extensions: ClineExtensionSurface[];
12
+ extensionInstalled: boolean;
13
+ extensionSupported: boolean;
14
+ }
15
+ export declare function clineExtensionStatePaths(ctx: Pick<DetectContext, 'home' | 'env'>): {
16
+ globalStateFile: string;
17
+ secretsFile: string;
18
+ };
19
+ export declare function inspectClineSurfaces(ctx: DetectContext): Promise<ClineSurfaces>;
20
+ export declare function clineProcessConflicts(output: string, platform: NodeJS.Platform): {
21
+ cli: boolean;
22
+ extension: boolean;
23
+ };
24
+ export declare function install(ctx: InstallContext): Promise<ConfigureResult>;
25
+ export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
26
+ export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
27
+ export declare function isManagedCliStore(value: unknown): boolean;
28
+ export declare function isManagedExtensionState(globalState: unknown, secrets: unknown): boolean;
29
+ export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -0,0 +1,581 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { execa } from 'execa';
4
+ import { commandExists } from '../utils/exec.js';
5
+ import { atomicWrite, backupFile } from '../utils/fs.js';
6
+ import { clientConfigPath, installHints } from './paths.js';
7
+ import { modelSupportsTools, openAiResponsesModelIds, preferredChatCompletionsModel, preferredOpenAiResponsesModel, } from './models.js';
8
+ const id = 'cline';
9
+ const name = 'Cline';
10
+ const cliVersion = '3.0.47';
11
+ const extensionVersion = '4.0.12';
12
+ const extensionId = 'saoudrizwan.claude-dev';
13
+ const cliProviderId = 'openai-native';
14
+ const legacyProviderId = 'scalemax';
15
+ const scaleMaxBaseUrl = /^https:\/\/api\.scalemax\.pro\/(?:token\/)?v1\/?$/i;
16
+ function clineDataDir(ctx) {
17
+ return ctx.env.CLINE_DATA_DIR?.trim()
18
+ || path.join(ctx.env.CLINE_DIR?.trim() || path.join(ctx.home, '.cline'), 'data');
19
+ }
20
+ export function clineExtensionStatePaths(ctx) {
21
+ const data = clineDataDir(ctx);
22
+ return {
23
+ globalStateFile: path.join(data, 'globalState.json'),
24
+ secretsFile: path.join(data, 'secrets.json'),
25
+ };
26
+ }
27
+ function legacyModelsPath(ctx) {
28
+ return path.join(path.dirname(clientConfigPath(id, ctx.home, ctx.platform, ctx.env)), 'models.json');
29
+ }
30
+ function asObject(value, file) {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
32
+ throw new Error(`Refusing to overwrite invalid Cline configuration at ${file}: root value must be an object.`);
33
+ }
34
+ return value;
35
+ }
36
+ async function regularFileExists(file) {
37
+ const stat = await fs.lstat(file).catch(() => undefined);
38
+ if (!stat)
39
+ return false;
40
+ if (stat.isSymbolicLink())
41
+ throw new Error(`Refusing to read or overwrite symlinked Cline configuration: ${file}`);
42
+ if (!stat.isFile())
43
+ throw new Error(`Refusing to overwrite non-file Cline configuration path: ${file}`);
44
+ return true;
45
+ }
46
+ async function readObject(file) {
47
+ if (!(await regularFileExists(file)))
48
+ return {};
49
+ try {
50
+ return asObject(JSON.parse(await fs.readFile(file, 'utf8')), file);
51
+ }
52
+ catch (error) {
53
+ if (error instanceof Error && error.message.startsWith('Refusing to overwrite'))
54
+ throw error;
55
+ throw new Error(`Refusing to overwrite invalid Cline configuration at ${file}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ async function readProviderStore(file) {
59
+ if (!(await regularFileExists(file)))
60
+ return { version: 1, providers: {} };
61
+ const value = await readObject(file);
62
+ if (value.version !== 1 || !value.providers || typeof value.providers !== 'object' || Array.isArray(value.providers)) {
63
+ throw new Error(`Refusing to overwrite unsupported Cline CLI provider configuration at ${file}.`);
64
+ }
65
+ return value;
66
+ }
67
+ async function readLegacyModelStore(file) {
68
+ if (!(await regularFileExists(file)))
69
+ return undefined;
70
+ const value = await readObject(file);
71
+ if (value.version !== 1 || !value.providers || typeof value.providers !== 'object' || Array.isArray(value.providers)) {
72
+ throw new Error(`Refusing to overwrite unsupported Cline CLI model configuration at ${file}.`);
73
+ }
74
+ return value;
75
+ }
76
+ function parseVersion(output) {
77
+ return output.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
78
+ }
79
+ async function cliInstalledVersion(env) {
80
+ if (!(await commandExists('cline', env)))
81
+ return { installed: false };
82
+ const result = await execa('cline', ['--version'], {
83
+ env,
84
+ reject: false,
85
+ timeout: 5_000,
86
+ all: true,
87
+ });
88
+ return {
89
+ installed: true,
90
+ version: parseVersion(result.all || result.stdout || ''),
91
+ };
92
+ }
93
+ async function extensionSurfaces(ctx) {
94
+ const hits = [];
95
+ const roots = installHints(id, ctx.home, ctx.platform, ctx.env)
96
+ .filter(candidate => path.basename(candidate).toLowerCase() === 'extensions');
97
+ for (const root of roots) {
98
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory() || !/^saoudrizwan\.claude-dev-/i.test(entry.name))
101
+ continue;
102
+ const extensionPath = path.join(root, entry.name);
103
+ const manifest = await fs.readJson(path.join(extensionPath, 'package.json')).catch(() => undefined);
104
+ const version = typeof manifest?.version === 'string' ? manifest.version : undefined;
105
+ const identityMatches = String(manifest?.publisher || '').toLowerCase() === 'saoudrizwan'
106
+ && String(manifest?.name || '').toLowerCase() === 'claude-dev';
107
+ hits.push({
108
+ path: extensionPath,
109
+ version,
110
+ supported: identityMatches && version === extensionVersion,
111
+ });
112
+ }
113
+ }
114
+ return hits;
115
+ }
116
+ export async function inspectClineSurfaces(ctx) {
117
+ const [cli, extensions] = await Promise.all([
118
+ cliInstalledVersion(ctx.env),
119
+ extensionSurfaces(ctx),
120
+ ]);
121
+ return {
122
+ cliInstalled: cli.installed,
123
+ cliVersion: cli.version,
124
+ cliSupported: cli.installed && cli.version === cliVersion,
125
+ extensions,
126
+ extensionInstalled: extensions.length > 0,
127
+ extensionSupported: extensions.length > 0 && extensions.every(extension => extension.supported),
128
+ };
129
+ }
130
+ function assertSupportedVersions(surfaces) {
131
+ if (surfaces.cliInstalled && !surfaces.cliSupported) {
132
+ throw new Error(`Unsupported Cline CLI version ${surfaces.cliVersion || 'unknown'} detected. ScaleMax only writes the verified ${cliVersion} format; no configuration was changed.`);
133
+ }
134
+ const unsupportedExtensions = surfaces.extensions.filter(extension => !extension.supported);
135
+ if (unsupportedExtensions.length) {
136
+ const versions = unsupportedExtensions.map(extension => extension.version || 'unknown').join(', ');
137
+ throw new Error(`Unsupported Cline VS Code extension version(s) ${versions} detected. ScaleMax only writes the verified ${extensionVersion} format; no configuration was changed.`);
138
+ }
139
+ if (!surfaces.cliSupported && !surfaces.extensionSupported) {
140
+ throw new Error(`Cline CLI ${cliVersion} or the Cline VS Code extension ${extensionVersion} must be installed before configuration. No configuration was changed.`);
141
+ }
142
+ }
143
+ function shouldInspectProcesses(ctx) {
144
+ const nodeEnv = ctx.env.NODE_ENV || process.env.NODE_ENV;
145
+ return nodeEnv !== 'test' || ctx.env.SCALEMAX_FORCE_CLINE_PROCESS_CHECK === '1';
146
+ }
147
+ export function clineProcessConflicts(output, platform) {
148
+ if (platform === 'win32') {
149
+ return {
150
+ cli: /(^|[\r\n,"])cline\.exe(?:["\r\n,]|$)/i.test(output),
151
+ extension: /(^|[\r\n,"])code(?: - insiders)?\.exe(?:["\r\n,]|$)/i.test(output),
152
+ };
153
+ }
154
+ const lines = output.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
155
+ return {
156
+ cli: lines.some(line => /(?:^|\/)cline(?:\s|$)/i.test(line.split(/\s+/, 1)[0] || '')),
157
+ extension: lines.some(line => /Visual Studio Code\.app|Code Helper|\/(?:usr\/share\/code|opt\/visual-studio-code)\/|(?:^|\s)code(?:\s|$)/i.test(line)),
158
+ };
159
+ }
160
+ async function assertProcessesStopped(ctx, surfaces) {
161
+ if (!shouldInspectProcesses(ctx))
162
+ return;
163
+ const command = ctx.platform === 'win32' ? 'tasklist' : '/bin/ps';
164
+ const args = ctx.platform === 'win32' ? ['/FO', 'CSV', '/NH'] : ['-ax', '-o', 'comm=', '-o', 'args='];
165
+ const result = await execa(command, args, {
166
+ env: ctx.env,
167
+ reject: false,
168
+ timeout: 5_000,
169
+ all: true,
170
+ });
171
+ if (result.exitCode !== 0) {
172
+ throw new Error(`Could not verify that Cline is stopped (${result.all?.trim() || `${command} exited with ${result.exitCode}`}). No configuration was changed.`);
173
+ }
174
+ const conflicts = clineProcessConflicts(result.all || result.stdout || '', ctx.platform);
175
+ if (surfaces.cliSupported && conflicts.cli) {
176
+ throw new Error('Cline CLI is running. Exit Cline completely, then retry; no configuration was changed.');
177
+ }
178
+ if (surfaces.extensionSupported && conflicts.extension) {
179
+ throw new Error('VS Code is running. Exit every VS Code window completely, then retry Cline setup; no configuration was changed.');
180
+ }
181
+ }
182
+ function responseModels(ctx) {
183
+ return openAiResponsesModelIds(ctx).filter(modelId => modelSupportsTools(ctx, modelId));
184
+ }
185
+ function chatModels(ctx) {
186
+ return (ctx.models || [])
187
+ .map(model => model.id.trim())
188
+ .filter(modelId => modelId && modelSupportsTools(ctx, modelId));
189
+ }
190
+ function cliSelectedModel(ctx) {
191
+ const compatible = responseModels(ctx);
192
+ if (!compatible.length) {
193
+ throw new Error('Cline CLI 3.0.47 uses the Responses protocol, but this API key has no Responses-capable tool model. No configuration was changed.');
194
+ }
195
+ return preferredOpenAiResponsesModel({
196
+ ...ctx,
197
+ models: (ctx.models || []).filter(model => compatible.includes(model.id)),
198
+ });
199
+ }
200
+ function extensionSelectedModel(ctx) {
201
+ const compatible = chatModels(ctx);
202
+ if (!compatible.length) {
203
+ throw new Error('Cline requires native tool calling, but this API key has no available tool-capable models. No configuration was changed.');
204
+ }
205
+ return preferredChatCompletionsModel({
206
+ ...ctx,
207
+ models: (ctx.models || []).filter(model => compatible.includes(model.id)),
208
+ });
209
+ }
210
+ function cliSettings(ctx, model) {
211
+ return {
212
+ provider: cliProviderId,
213
+ apiKey: ctx.apiKey,
214
+ model,
215
+ protocol: 'openai-responses',
216
+ client: 'openai',
217
+ baseUrl: ctx.baseUrl.replace(/\/$/, ''),
218
+ capabilities: ['tools', 'streaming'],
219
+ };
220
+ }
221
+ function positiveInteger(value) {
222
+ const parsed = Number(value);
223
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
224
+ }
225
+ function extensionModelInfo(ctx, modelId) {
226
+ const source = (ctx.models || []).find(candidate => candidate.id === modelId);
227
+ return {
228
+ maxTokens: positiveInteger(source?.max_output_tokens) ?? -1,
229
+ contextWindow: positiveInteger(source?.context_window) ?? 128_000,
230
+ supportsImages: source?.capabilities?.images === true || source?.capabilities?.vision === true,
231
+ supportsPromptCache: source?.capabilities?.prompt_cache === true,
232
+ supportsReasoning: source?.capabilities?.reasoning === true,
233
+ supportsReasoningEffort: source?.capabilities?.reasoning_effort === true,
234
+ supportsTools: true,
235
+ supportsStreaming: true,
236
+ isR1FormatRequired: false,
237
+ inputPrice: 0,
238
+ outputPrice: 0,
239
+ temperature: 0,
240
+ };
241
+ }
242
+ function extensionGlobalState(ctx, current, model) {
243
+ const info = extensionModelInfo(ctx, model);
244
+ return {
245
+ ...current,
246
+ openAiBaseUrl: ctx.baseUrl.replace(/\/$/, ''),
247
+ openAiHeaders: {},
248
+ planModeApiProvider: 'openai',
249
+ actModeApiProvider: 'openai',
250
+ planModeOpenAiModelId: model,
251
+ actModeOpenAiModelId: model,
252
+ planModeOpenAiModelInfo: info,
253
+ actModeOpenAiModelInfo: info,
254
+ };
255
+ }
256
+ function sameJson(left, right) {
257
+ return JSON.stringify(left) === JSON.stringify(right);
258
+ }
259
+ function removeLegacyProvider(store) {
260
+ const legacy = store.providers?.[legacyProviderId];
261
+ if (legacy?.settings?.provider !== legacyProviderId
262
+ || !scaleMaxBaseUrl.test(String(legacy?.settings?.baseUrl || ''))) {
263
+ return { state: store, changed: false };
264
+ }
265
+ const providers = { ...store.providers };
266
+ delete providers[legacyProviderId];
267
+ const state = { ...store, providers };
268
+ if (state.lastUsedProvider === legacyProviderId)
269
+ delete state.lastUsedProvider;
270
+ return { state, changed: true };
271
+ }
272
+ function removeLegacyModels(store) {
273
+ const legacy = store?.providers?.[legacyProviderId];
274
+ if (!store || legacy?.provider?.name !== 'ScaleMax'
275
+ || !scaleMaxBaseUrl.test(String(legacy?.provider?.baseUrl || ''))) {
276
+ return { state: store, changed: false };
277
+ }
278
+ const providers = { ...store.providers };
279
+ delete providers[legacyProviderId];
280
+ return { state: { ...store, providers }, changed: true };
281
+ }
282
+ async function originalContents(files) {
283
+ return new Map(await Promise.all(files.map(async (file) => [
284
+ file,
285
+ await fs.readFile(file, 'utf8').catch(() => undefined),
286
+ ])));
287
+ }
288
+ async function rollbackFiles(originals, platform) {
289
+ for (const [file, original] of originals) {
290
+ if (original === undefined)
291
+ await fs.remove(file).catch(() => undefined);
292
+ else
293
+ await atomicWrite(file, original, 0o600, platform).catch(() => undefined);
294
+ }
295
+ }
296
+ async function writeTransaction(ctx, changes) {
297
+ const files = [...changes.keys()];
298
+ const originals = await originalContents(files);
299
+ for (const file of files)
300
+ await backupFile(file, ctx.home);
301
+ try {
302
+ for (const [file, state] of changes) {
303
+ await atomicWrite(file, JSON.stringify(state, null, 2) + '\n', 0o600, ctx.platform);
304
+ }
305
+ }
306
+ catch (error) {
307
+ await rollbackFiles(originals, ctx.platform);
308
+ throw error;
309
+ }
310
+ }
311
+ function assertRawCredential(apiKey) {
312
+ if (/^Bearer\s+/i.test(apiKey) || apiKey !== apiKey.trim()) {
313
+ throw new Error('Cline API Key must contain only the raw ScaleMax key; do not include a Bearer prefix or surrounding whitespace.');
314
+ }
315
+ }
316
+ export async function install(ctx) {
317
+ const surfaces = await inspectClineSurfaces(ctx);
318
+ if (surfaces.extensionSupported || surfaces.cliSupported) {
319
+ return {
320
+ id,
321
+ name,
322
+ changed: false,
323
+ message: surfaces.extensionSupported
324
+ ? `verified Cline VS Code extension ${extensionVersion} is already installed`
325
+ : `verified Cline CLI ${cliVersion} is already installed`,
326
+ };
327
+ }
328
+ if (!(await commandExists('code', ctx.env))) {
329
+ if (surfaces.cliInstalled || surfaces.extensionInstalled)
330
+ assertSupportedVersions(surfaces);
331
+ throw new Error(`VS Code was not detected. Install VS Code, then retry the pinned Cline ${extensionVersion} setup.`);
332
+ }
333
+ const result = await execa('code', ['--install-extension', `${extensionId}@${extensionVersion}`, '--force'], {
334
+ env: ctx.env,
335
+ reject: false,
336
+ all: true,
337
+ });
338
+ if (result.exitCode !== 0) {
339
+ throw new Error(`Cline extension installation failed: ${result.all?.trim() || `code exited with ${result.exitCode}`}`);
340
+ }
341
+ const after = await inspectClineSurfaces(ctx);
342
+ if (!after.extensionSupported) {
343
+ throw new Error(`VS Code reported success, but Cline extension ${extensionVersion} could not be verified. No configuration was changed.`);
344
+ }
345
+ return { id, name, changed: true, message: `official Cline VS Code extension ${extensionVersion} installed and verified` };
346
+ }
347
+ export async function configure(ctx) {
348
+ assertRawCredential(ctx.apiKey);
349
+ const surfaces = await inspectClineSurfaces(ctx);
350
+ assertSupportedVersions(surfaces);
351
+ await assertProcessesStopped(ctx, surfaces);
352
+ const changes = new Map();
353
+ const configuredPaths = [];
354
+ if (surfaces.cliSupported) {
355
+ const providerFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
356
+ const modelFile = legacyModelsPath(ctx);
357
+ const providers = await readProviderStore(providerFile);
358
+ const legacyCleaned = removeLegacyProvider(providers);
359
+ const model = cliSelectedModel(ctx);
360
+ const nextProviders = {
361
+ ...legacyCleaned.state,
362
+ lastUsedProvider: cliProviderId,
363
+ providers: {
364
+ ...legacyCleaned.state.providers,
365
+ [cliProviderId]: {
366
+ settings: cliSettings(ctx, model),
367
+ updatedAt: new Date().toISOString(),
368
+ tokenSource: 'manual',
369
+ },
370
+ },
371
+ };
372
+ const existing = providers.providers?.[cliProviderId]?.settings;
373
+ const cliCurrent = sameJson(existing, cliSettings(ctx, model))
374
+ && providers.lastUsedProvider === cliProviderId
375
+ && !providers.providers?.[legacyProviderId];
376
+ if (!cliCurrent)
377
+ changes.set(providerFile, nextProviders);
378
+ configuredPaths.push(providerFile);
379
+ const legacyModels = await readLegacyModelStore(modelFile);
380
+ const cleanedModels = removeLegacyModels(legacyModels);
381
+ if (cleanedModels.changed && cleanedModels.state)
382
+ changes.set(modelFile, cleanedModels.state);
383
+ }
384
+ if (surfaces.extensionSupported) {
385
+ const { globalStateFile, secretsFile } = clineExtensionStatePaths(ctx);
386
+ const [globalState, secrets] = await Promise.all([
387
+ readObject(globalStateFile),
388
+ readObject(secretsFile),
389
+ ]);
390
+ const model = extensionSelectedModel(ctx);
391
+ const nextGlobalState = extensionGlobalState(ctx, globalState, model);
392
+ const nextSecrets = { ...secrets, openAiApiKey: ctx.apiKey };
393
+ if (!sameJson(globalState, nextGlobalState))
394
+ changes.set(globalStateFile, nextGlobalState);
395
+ if (!sameJson(secrets, nextSecrets))
396
+ changes.set(secretsFile, nextSecrets);
397
+ configuredPaths.push(globalStateFile, secretsFile);
398
+ }
399
+ if (!changes.size) {
400
+ for (const file of configuredPaths)
401
+ await fs.chmod(file, 0o600).catch(() => undefined);
402
+ return {
403
+ id,
404
+ name,
405
+ changed: false,
406
+ path: configuredPaths[0],
407
+ message: 'verified Cline configuration is already current',
408
+ };
409
+ }
410
+ await writeTransaction(ctx, changes);
411
+ return {
412
+ id,
413
+ name,
414
+ changed: true,
415
+ path: configuredPaths[0],
416
+ message: `configured ${surfaces.cliSupported ? `CLI ${cliVersion}` : ''}${surfaces.cliSupported && surfaces.extensionSupported ? ' and ' : ''}${surfaces.extensionSupported ? `VS Code extension ${extensionVersion}` : ''} with one raw ScaleMax credential; restart Cline to activate`,
417
+ };
418
+ }
419
+ function cliConfigurationValid(ctx, store) {
420
+ const model = cliSelectedModel(ctx);
421
+ return store.version === 1
422
+ && store.lastUsedProvider === cliProviderId
423
+ && sameJson(store.providers?.[cliProviderId]?.settings, cliSettings(ctx, model))
424
+ && store.providers?.[cliProviderId]?.settings?.headers === undefined
425
+ && store.providers?.[legacyProviderId] === undefined;
426
+ }
427
+ function extensionConfigurationValid(ctx, globalState, secrets) {
428
+ const model = extensionSelectedModel(ctx);
429
+ const expected = extensionGlobalState(ctx, globalState, model);
430
+ return sameJson(globalState, expected)
431
+ && secrets.openAiApiKey === ctx.apiKey
432
+ && !/^Bearer\s+/i.test(String(secrets.openAiApiKey || ''))
433
+ && sameJson(globalState.openAiHeaders, {});
434
+ }
435
+ async function assertPrivateFiles(files, platform) {
436
+ if (platform === 'win32')
437
+ return;
438
+ for (const file of files) {
439
+ if (((await fs.stat(file)).mode & 0o077) !== 0) {
440
+ throw new Error(`Cline configuration permissions are too broad: ${file}`);
441
+ }
442
+ }
443
+ }
444
+ export async function verify(ctx) {
445
+ assertRawCredential(ctx.apiKey);
446
+ const surfaces = await inspectClineSurfaces(ctx);
447
+ assertSupportedVersions(surfaces);
448
+ const files = [];
449
+ if (surfaces.cliSupported) {
450
+ const providerFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
451
+ const providers = await readProviderStore(providerFile);
452
+ if (!cliConfigurationValid(ctx, providers)) {
453
+ throw new Error('Cline CLI ScaleMax configuration is incomplete or uses an unverified provider format.');
454
+ }
455
+ files.push(providerFile);
456
+ }
457
+ if (surfaces.extensionSupported) {
458
+ const { globalStateFile, secretsFile } = clineExtensionStatePaths(ctx);
459
+ const [globalState, secrets] = await Promise.all([
460
+ readObject(globalStateFile),
461
+ readObject(secretsFile),
462
+ ]);
463
+ if (!extensionConfigurationValid(ctx, globalState, secrets)) {
464
+ throw new Error('Cline VS Code ScaleMax configuration is incomplete or contains a custom authentication header.');
465
+ }
466
+ files.push(globalStateFile, secretsFile);
467
+ }
468
+ await assertPrivateFiles(files, ctx.platform);
469
+ return {
470
+ id,
471
+ name,
472
+ changed: false,
473
+ path: files[0],
474
+ message: `verified ${surfaces.cliSupported ? `CLI ${cliVersion}` : ''}${surfaces.cliSupported && surfaces.extensionSupported ? ' and ' : ''}${surfaces.extensionSupported ? `VS Code extension ${extensionVersion}` : ''} raw-key authentication with no custom auth header`,
475
+ };
476
+ }
477
+ export function isManagedCliStore(value) {
478
+ if (!value || typeof value !== 'object' || Array.isArray(value))
479
+ return false;
480
+ const store = value;
481
+ const settings = store.providers?.[cliProviderId]?.settings;
482
+ return store.version === 1
483
+ && store.lastUsedProvider === cliProviderId
484
+ && settings?.provider === cliProviderId
485
+ && typeof settings?.apiKey === 'string'
486
+ && /^sm_(?:live|test)_/i.test(settings.apiKey)
487
+ && scaleMaxBaseUrl.test(String(settings?.baseUrl || ''))
488
+ && settings?.client === 'openai'
489
+ && settings?.protocol === 'openai-responses'
490
+ && settings?.headers === undefined
491
+ && store.providers?.[legacyProviderId] === undefined;
492
+ }
493
+ export function isManagedExtensionState(globalState, secrets) {
494
+ if (!globalState || typeof globalState !== 'object' || Array.isArray(globalState)
495
+ || !secrets || typeof secrets !== 'object' || Array.isArray(secrets))
496
+ return false;
497
+ const state = globalState;
498
+ const secretState = secrets;
499
+ return state.planModeApiProvider === 'openai'
500
+ && state.actModeApiProvider === 'openai'
501
+ && scaleMaxBaseUrl.test(String(state.openAiBaseUrl || ''))
502
+ && sameJson(state.openAiHeaders, {})
503
+ && typeof state.planModeOpenAiModelId === 'string'
504
+ && state.planModeOpenAiModelId.length > 0
505
+ && state.actModeOpenAiModelId === state.planModeOpenAiModelId
506
+ && state.planModeOpenAiModelInfo?.supportsTools === true
507
+ && state.planModeOpenAiModelInfo?.supportsStreaming === true
508
+ && state.actModeOpenAiModelInfo?.supportsTools === true
509
+ && state.actModeOpenAiModelInfo?.supportsStreaming === true
510
+ && typeof secretState.openAiApiKey === 'string'
511
+ && /^sm_(?:live|test)_/i.test(secretState.openAiApiKey)
512
+ && !/^Bearer\s+/i.test(secretState.openAiApiKey);
513
+ }
514
+ function deleteKeys(source, keys) {
515
+ const next = { ...source };
516
+ for (const key of keys)
517
+ delete next[key];
518
+ return next;
519
+ }
520
+ export async function remove(ctx) {
521
+ const surfaces = await inspectClineSurfaces(ctx);
522
+ if (!surfaces.cliInstalled && !surfaces.extensionInstalled) {
523
+ return { id, name, changed: false, message: 'Cline is not installed' };
524
+ }
525
+ assertSupportedVersions(surfaces);
526
+ await assertProcessesStopped(ctx, surfaces);
527
+ const changes = new Map();
528
+ let changed = false;
529
+ let resultPath;
530
+ if (surfaces.cliSupported) {
531
+ const providerFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
532
+ if (await fs.pathExists(providerFile)) {
533
+ const providers = await readProviderStore(providerFile);
534
+ if (isManagedCliStore(providers)) {
535
+ const nextProviders = { ...providers.providers };
536
+ delete nextProviders[cliProviderId];
537
+ const next = { ...providers, providers: nextProviders };
538
+ delete next.lastUsedProvider;
539
+ changes.set(providerFile, next);
540
+ resultPath ||= providerFile;
541
+ changed = true;
542
+ }
543
+ }
544
+ }
545
+ if (surfaces.extensionSupported) {
546
+ const { globalStateFile, secretsFile } = clineExtensionStatePaths(ctx);
547
+ const [globalState, secrets] = await Promise.all([
548
+ readObject(globalStateFile),
549
+ readObject(secretsFile),
550
+ ]);
551
+ if (isManagedExtensionState(globalState, secrets)) {
552
+ const nextGlobal = deleteKeys(globalState, [
553
+ 'openAiBaseUrl',
554
+ 'openAiHeaders',
555
+ 'planModeApiProvider',
556
+ 'actModeApiProvider',
557
+ 'planModeOpenAiModelId',
558
+ 'actModeOpenAiModelId',
559
+ 'planModeOpenAiModelInfo',
560
+ 'actModeOpenAiModelInfo',
561
+ ]);
562
+ const nextSecrets = { ...secrets };
563
+ delete nextSecrets.openAiApiKey;
564
+ changes.set(globalStateFile, nextGlobal);
565
+ changes.set(secretsFile, nextSecrets);
566
+ resultPath ||= globalStateFile;
567
+ changed = true;
568
+ }
569
+ }
570
+ if (!changed) {
571
+ return { id, name, changed: false, message: 'left unmanaged Cline configuration unchanged' };
572
+ }
573
+ await writeTransaction(ctx, changes);
574
+ return {
575
+ id,
576
+ name,
577
+ changed: true,
578
+ path: resultPath,
579
+ message: 'removed only ScaleMax-managed Cline authentication fields',
580
+ };
581
+ }
@@ -10,6 +10,14 @@ export function clientConfigPath(id, home, platform, env = process.env) {
10
10
  case 'codex': return codexConfigPath(home);
11
11
  case 'cursor': return platform === 'win32' ? path.join(app, 'Cursor', 'User', 'settings.json') : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json') : path.join(linux, 'Cursor', 'User', 'settings.json');
12
12
  case 'roo-code': return platform === 'win32' ? path.join(app, 'Code', 'User', 'settings.json') : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json') : path.join(linux, 'Code', 'User', 'settings.json');
13
+ case 'cline': {
14
+ const explicit = env.CLINE_PROVIDER_SETTINGS_PATH?.trim();
15
+ if (explicit)
16
+ return explicit;
17
+ const dataDir = env.CLINE_DATA_DIR?.trim()
18
+ || path.join(env.CLINE_DIR?.trim() || path.join(home, '.cline'), 'data');
19
+ return path.join(dataDir, 'settings', 'providers.json');
20
+ }
13
21
  case 'continue': return path.join(home, '.continue', 'config.yaml');
14
22
  case 'trae-solo': return path.join(home, '.scalemax', 'trae-solo.setup.json');
15
23
  case 'claude': return path.join(home, '.claude', 'settings.json');
@@ -27,6 +35,7 @@ export function installHints(id, home, platform, env = process.env) {
27
35
  case 'codex': return [codexDir(home), path.join(linux, 'codex')];
28
36
  case 'cursor': return [platform === 'darwin' ? '/Applications/Cursor.app' : path.join(app, 'Cursor'), path.join(linux, 'Cursor')];
29
37
  case 'roo-code': return [path.join(home, '.vscode', 'extensions'), path.join(home, '.vscode-insiders', 'extensions')];
38
+ case 'cline': return [path.join(home, '.vscode', 'extensions'), path.join(home, '.vscode-insiders', 'extensions')];
30
39
  case 'continue': return [path.join(home, '.vscode', 'extensions'), path.join(home, '.vscode-insiders', 'extensions')];
31
40
  case 'trae-solo': return [platform === 'darwin' ? '/Applications/Trae.app' : path.join(app, 'Trae'), path.join(linux, 'Trae')];
32
41
  case 'claude': return [path.join(home, '.claude')];
@@ -0,0 +1,2 @@
1
+ import { DetectContext, DetectionResult } from '../types/client.js';
2
+ export declare function detect(ctx: DetectContext): Promise<DetectionResult>;
@@ -0,0 +1,62 @@
1
+ import fs from 'fs-extra';
2
+ import { clientConfigPath } from '../configure/paths.js';
3
+ import { clineExtensionStatePaths, inspectClineSurfaces, isManagedCliStore, isManagedExtensionState, } from '../configure/cline.js';
4
+ import { commandExists } from '../utils/exec.js';
5
+ const id = 'cline';
6
+ const name = 'Cline';
7
+ export async function detect(ctx) {
8
+ const surfaces = await inspectClineSurfaces(ctx);
9
+ const paths = [];
10
+ const configuredSurfaces = [];
11
+ if (surfaces.cliInstalled) {
12
+ paths.push(`command:cline${surfaces.cliVersion ? `@${surfaces.cliVersion}` : ''}`);
13
+ if (surfaces.cliSupported) {
14
+ const providerFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
15
+ const configured = isManagedCliStore(await fs.readJson(providerFile).catch(() => undefined));
16
+ configuredSurfaces.push(configured);
17
+ if (configured)
18
+ paths.push(providerFile);
19
+ }
20
+ else {
21
+ configuredSurfaces.push(false);
22
+ }
23
+ }
24
+ for (const extension of surfaces.extensions)
25
+ paths.push(extension.path);
26
+ if (surfaces.extensionInstalled) {
27
+ if (surfaces.extensionSupported) {
28
+ const { globalStateFile, secretsFile } = clineExtensionStatePaths(ctx);
29
+ const [globalState, secrets] = await Promise.all([
30
+ fs.readJson(globalStateFile).catch(() => undefined),
31
+ fs.readJson(secretsFile).catch(() => undefined),
32
+ ]);
33
+ const configured = isManagedExtensionState(globalState, secrets);
34
+ configuredSurfaces.push(configured);
35
+ if (configured)
36
+ paths.push(globalStateFile, secretsFile);
37
+ }
38
+ else {
39
+ configuredSurfaces.push(false);
40
+ }
41
+ }
42
+ const installed = surfaces.cliInstalled || surfaces.extensionInstalled;
43
+ const configured = installed && configuredSurfaces.length > 0 && configuredSurfaces.every(Boolean);
44
+ const unsupported = [];
45
+ if (surfaces.cliInstalled && !surfaces.cliSupported)
46
+ unsupported.push(`CLI ${surfaces.cliVersion || 'unknown'} (requires 3.0.47)`);
47
+ for (const extension of surfaces.extensions.filter(candidate => !candidate.supported)) {
48
+ unsupported.push(`VS Code extension ${extension.version || 'unknown'} (requires 4.0.12)`);
49
+ }
50
+ return {
51
+ id,
52
+ name,
53
+ installed,
54
+ configured,
55
+ executable: surfaces.cliInstalled,
56
+ installable: await commandExists('code', ctx.env),
57
+ reason: unsupported.length
58
+ ? `Unsupported Cline surface detected: ${unsupported.join(', ')}`
59
+ : installed ? undefined : 'Cline CLI and VS Code extension were not detected',
60
+ paths,
61
+ };
62
+ }
@@ -3,7 +3,7 @@ import { commandExists } from '../utils/exec.js';
3
3
  import { clientConfigPath, installHints } from '../configure/paths.js';
4
4
  import { GENERATED_HEADER } from '../constants.js';
5
5
  const commands = {
6
- codex: ['codex'], cursor: ['cursor'], 'roo-code': ['code'], continue: ['code'], 'trae-solo': ['trae', 'trae-solo'], claude: ['claude'], opencode: ['opencode'], openclaw: ['openclaw'], hermes: ['hermes'], 'cherry-studio': ['cherry-studio', 'cherrystudio'], 'api-code': []
6
+ codex: ['codex'], cursor: ['cursor'], 'roo-code': ['code'], cline: ['cline'], continue: ['code'], 'trae-solo': ['trae', 'trae-solo'], claude: ['claude'], opencode: ['opencode'], openclaw: ['openclaw'], hermes: ['hermes'], 'cherry-studio': ['cherry-studio', 'cherrystudio'], 'api-code': []
7
7
  };
8
8
  const guiPathDetection = new Set(['trae-solo', 'cherry-studio']);
9
9
  function hasNativeScaleMaxConfig(id, text) {
@@ -1,4 +1,4 @@
1
- export type ClientId = 'codex' | 'cursor' | 'roo-code' | 'continue' | 'trae-solo' | 'claude' | 'opencode' | 'openclaw' | 'hermes' | 'cherry-studio' | 'api-code';
1
+ export type ClientId = 'codex' | 'cursor' | 'roo-code' | 'cline' | 'continue' | 'trae-solo' | 'claude' | 'opencode' | 'openclaw' | 'hermes' | 'cherry-studio' | 'api-code';
2
2
  export type ProviderScope = 'openai' | 'anthropic' | 'both' | 'deepseek-v4-flash' | 'glm-5.2';
3
3
  export interface ClientDefinition {
4
4
  id: ClientId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {