scalemax 0.1.14 → 0.1.15

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
@@ -1,6 +1,6 @@
1
1
  # ScaleMax CLI
2
2
 
3
- Universal installer for configuring AI coding clients to use the ScaleMax OpenAI-compatible API gateway.
3
+ Universal installer for configuring AI coding clients to use the ScaleMax OpenAI- and Anthropic-compatible API gateway.
4
4
 
5
5
  ScaleMax CLI is designed to be run from any machine with Node.js installed:
6
6
 
@@ -98,7 +98,7 @@ SCALEMAX_API_KEY=sm_live_xxx scalemax models
98
98
 
99
99
  ## Supported clients
100
100
 
101
- `npx scalemax` supports this client set:
101
+ `scalemax` supports this client set whether it is run through a global install or with `npx`:
102
102
 
103
103
  - Codex
104
104
  - Cursor
@@ -114,7 +114,19 @@ For clients without a stable official config file, the installer writes a ScaleM
114
114
 
115
115
  ### Claude Code
116
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.
117
+ ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`, the stored key as `ANTHROPIC_AUTH_TOKEN`, and gateway model discovery. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings, selects a live model from the authenticated catalog (preferring `claude-opus-4-8`), and verifies a one-token Messages request. If verification fails, it removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the gateway.
118
+
119
+ ### OpenCode
120
+
121
+ ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.json` (or the platform-equivalent config directory). It uses `https://api.scalemax.pro/v1`, keeps unrelated providers and the user's non-ScaleMax default model, and loads every model available to the authenticated key. Each generated model has an explicit output limit of at most 4096 tokens so OpenCode does not send an unsupported default limit.
122
+
123
+ ### OpenClaw
124
+
125
+ ScaleMax merges a native `anthropic-messages` provider into `~/.openclaw/openclaw.json`, using the Anthropic origin `https://api.scalemax.pro`. It adds every authenticated model to the provider and agent allowlist, caps generated output limits at 4096 tokens, and preserves unrelated providers and agent settings. Legacy ScaleMax artifacts are removed only when they carry the ScaleMax managed marker.
126
+
127
+ ### Hermes
128
+
129
+ ScaleMax merges a named custom provider into `~/.hermes/config.yaml` and stores its credential in `~/.hermes/.env` with private permissions. Hermes uses OpenAI chat-completions routing at `https://api.scalemax.pro/v1`, a scoped `ScaleMax-Hermes/1.0` user agent, and a 4096-token output cap. Existing non-ScaleMax providers and defaults are preserved; a fresh Hermes config activates `custom:scalemax` automatically.
118
130
 
119
131
  ### Cursor BYOK routing on macOS
120
132
 
@@ -296,4 +308,4 @@ Yes. The CLI uses Windows-aware config paths and writes `%APPDATA%\scalemax\conf
296
308
 
297
309
  ### What endpoint does it configure?
298
310
 
299
- `https://api.scalemax.pro/v1`
311
+ OpenAI-compatible clients use `https://api.scalemax.pro/v1`. Anthropic-compatible clients such as Claude Code and OpenClaw use the origin `https://api.scalemax.pro`, because those clients append `/v1/messages` themselves.
package/dist/clients.js CHANGED
@@ -21,9 +21,9 @@ export const clients = [
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
23
  { id: 'claude', name: 'Claude Code', detect: claudeDetect.detect, configure: claudeConfig.configure, verify: claudeConfig.verify, remove: claudeConfig.remove },
24
- { id: 'opencode', name: 'OpenCode', detect: opencodeDetect.detect, configure: opencodeConfig.configure, remove: opencodeConfig.remove },
25
- { id: 'openclaw', name: 'OpenClaw', detect: openclawDetect.detect, configure: openclawConfig.configure, remove: openclawConfig.remove },
26
- { id: 'hermes', name: 'Hermes', detect: hermesDetect.detect, configure: hermesConfig.configure, remove: hermesConfig.remove },
24
+ { id: 'opencode', name: 'OpenCode', detect: opencodeDetect.detect, configure: opencodeConfig.configure, verify: opencodeConfig.verify, remove: opencodeConfig.remove },
25
+ { id: 'openclaw', name: 'OpenClaw', detect: openclawDetect.detect, configure: openclawConfig.configure, verify: openclawConfig.verify, remove: openclawConfig.remove },
26
+ { id: 'hermes', name: 'Hermes', detect: hermesDetect.detect, configure: hermesConfig.configure, verify: hermesConfig.verify, remove: hermesConfig.remove },
27
27
  { id: 'cherry-studio', name: 'Cherry Studio', detect: cherryStudioDetect.detect, configure: cherryStudioConfig.configure, remove: cherryStudioConfig.remove },
28
28
  { id: 'api-code', name: 'API Code', detect: apiCodeDetect.detect, configure: apiCodeConfig.configure, remove: apiCodeConfig.remove }
29
29
  ];
@@ -1,5 +1,8 @@
1
+ import { ModelInfo } from '../utils/api.js';
2
+ import { ClientId } from '../types/client.js';
1
3
  export declare function detectAll(home?: string): Promise<import("../types/client.js").DetectionResult[]>;
2
4
  export declare function printDetected(home?: string): Promise<import("../types/client.js").DetectionResult[]>;
5
+ export declare function clientAllowedForModels(clientId: ClientId, models: ModelInfo[]): boolean;
3
6
  export declare function configureCommand(opts?: {
4
7
  all?: boolean;
5
8
  home?: string;
@@ -4,7 +4,7 @@ import ora from 'ora';
4
4
  import { clients } from '../clients.js';
5
5
  import { SCALEMAX_BASE_URL } from '../constants.js';
6
6
  import { loadApiKey } from '../utils/keyStore.js';
7
- import { validateApiKey } from '../utils/api.js';
7
+ import { validateApiKey, isAnthropicModel } from '../utils/api.js';
8
8
  import { homeDir } from '../utils/paths.js';
9
9
  export async function detectAll(home = homeDir()) {
10
10
  const ctx = { home, platform: process.platform, env: process.env };
@@ -39,14 +39,13 @@ async function maybeLaunchCodex(chosen, successful) {
39
39
  }
40
40
  console.log('\nIf Codex asks for authentication,\nit should already be configured to use ScaleMax.\n\nIf you still see the official login screen,\nthe configuration is incomplete or Codex rejected the generated auth/config files.\n');
41
41
  }
42
- function clientAllowedForScope(clientId, scope) {
43
- if (scope === 'both')
42
+ export function clientAllowedForModels(clientId, models) {
43
+ if (!models.length)
44
+ return false;
45
+ const crossProtocolClients = new Set(['claude', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
46
+ if (crossProtocolClients.has(clientId))
44
47
  return true;
45
- if (clientId === 'claude')
46
- return scope === 'anthropic';
47
- if (clientId === 'api-code')
48
- return true;
49
- return scope === 'openai';
48
+ return models.some(model => !isAnthropicModel(model));
50
49
  }
51
50
  function scopeHint(scope) {
52
51
  return scope === 'both' ? 'OpenAI + Claude' : scope === 'anthropic' ? 'Claude only' : 'OpenAI only';
@@ -62,8 +61,8 @@ export async function configureCommand(opts = {}) {
62
61
  if (!keyInfo.models.length)
63
62
  throw new Error(keyInfo.error || 'ScaleMax returned no models for this API key. No client configuration was changed.');
64
63
  const providerScope = keyInfo.key?.provider_scope || 'both';
65
- const availableClients = clients.filter(c => clientAllowedForScope(c.id, providerScope));
66
- console.log(chalk.dim(`API key scope: ${scopeHint(providerScope)}. Incompatible clients are hidden.`));
64
+ const availableClients = clients.filter(c => clientAllowedForModels(c.id, keyInfo.models));
65
+ console.log(chalk.dim(`API key scope: ${scopeHint(providerScope)}. Clients are filtered against the authenticated model catalog.`));
67
66
  const detections = await printDetected(home);
68
67
  const detectedIds = new Set(detections.filter(d => d.installed).map(d => d.id));
69
68
  let chosen;
@@ -1,27 +1,63 @@
1
+ import fs from 'fs-extra';
2
+ import crypto from 'node:crypto';
3
+ import { atomicWrite, backupFile } from '../utils/fs.js';
1
4
  import { clientConfigPath } from './paths.js';
2
- import { writeMergedJsonConfig, removeManagedConfig } from './common.js';
5
+ import { writeMergedJsonConfig } from './common.js';
3
6
  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(/\/$/, ''); }
7
+ import { anthropicOrigin, modelIds, preferredModel } from './models.js';
8
+ const id = 'claude';
9
+ const name = 'Claude Code';
10
+ const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
11
+ const managedEnvKeys = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', discoveryKey];
12
+ function asRecord(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
+ }
11
15
  export async function configure(ctx) {
12
- const file = clientConfigPath(id, ctx.home, ctx.platform), model = claudeModel(ctx);
16
+ const file = clientConfigPath(id, ctx.home, ctx.platform);
17
+ const model = preferredModel(ctx);
18
+ const baseUrl = anthropicOrigin(ctx.baseUrl);
13
19
  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';
20
+ const env = asRecord(existing.env);
21
+ const oldMetadata = asRecord(existing.scalemax);
22
+ const previousEnv = oldMetadata.managedBy === 'ScaleMax CLI'
23
+ ? asRecord(oldMetadata.previousEnv)
24
+ : Object.fromEntries(managedEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(env, key)).map(key => [key, env[key]]));
25
+ return JSON.stringify({
26
+ ...existing,
27
+ env: {
28
+ ...env,
29
+ ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
30
+ ANTHROPIC_BASE_URL: baseUrl,
31
+ ANTHROPIC_MODEL: model,
32
+ [discoveryKey]: '1'
33
+ },
34
+ 'scalemax.managedBy': GENERATED_HEADER,
35
+ scalemax: {
36
+ managedBy: 'ScaleMax CLI',
37
+ anthropicModel: model,
38
+ anthropicBaseUrl: baseUrl,
39
+ gatewayDiscovery: '1',
40
+ authTokenSha256: crypto.createHash('sha256').update(ctx.apiKey).digest('hex'),
41
+ previousEnv
42
+ }
43
+ }, null, 2) + '\n';
16
44
  });
17
- return { ...result, message: `configured Anthropic-compatible routing with ${model}` };
45
+ return { ...result, message: `configured Anthropic Messages routing with ${model} and gateway model discovery` };
18
46
  }
19
47
  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) });
48
+ const file = clientConfigPath(id, ctx.home, ctx.platform);
49
+ const settings = await fs.readJson(file);
50
+ const env = asRecord(settings?.env);
51
+ const model = String(env.ANTHROPIC_MODEL || '');
52
+ if (!modelIds(ctx).includes(model) || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || env.ANTHROPIC_AUTH_TOKEN !== ctx.apiKey || env[discoveryKey] !== '1') {
53
+ throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog.');
54
+ }
55
+ const response = await fetch(`${anthropicOrigin(ctx.baseUrl)}/v1/messages`, {
56
+ method: 'POST',
57
+ headers: { Authorization: `Bearer ${ctx.apiKey}`, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
58
+ body: JSON.stringify({ model, max_tokens: 1, stream: false, messages: [{ role: 'user', content: 'K' }] }),
59
+ signal: AbortSignal.timeout(15000)
60
+ });
25
61
  if (!response.ok) {
26
62
  let message = `HTTP ${response.status}`;
27
63
  try {
@@ -33,4 +69,40 @@ export async function verify(ctx) {
33
69
  }
34
70
  return { id, name, changed: false, path: file, message: `verified Anthropic Messages with ${model}` };
35
71
  }
36
- export async function remove(ctx) { return removeManagedConfig(ctx, id, name, clientConfigPath(id, ctx.home, ctx.platform)); }
72
+ export async function remove(ctx) {
73
+ const file = clientConfigPath(id, ctx.home, ctx.platform);
74
+ if (!(await fs.pathExists(file)))
75
+ return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
76
+ const settings = await fs.readJson(file).catch(() => undefined);
77
+ if (!settings || settings?.scalemax?.managedBy !== 'ScaleMax CLI')
78
+ return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
79
+ const managed = asRecord(settings.scalemax);
80
+ const env = asRecord(settings.env);
81
+ const previous = asRecord(managed.previousEnv);
82
+ const restore = (key) => {
83
+ if (Object.prototype.hasOwnProperty.call(previous, key))
84
+ env[key] = previous[key];
85
+ else
86
+ delete env[key];
87
+ };
88
+ if (env.ANTHROPIC_BASE_URL === managed.anthropicBaseUrl)
89
+ restore('ANTHROPIC_BASE_URL');
90
+ if (env.ANTHROPIC_MODEL === managed.anthropicModel)
91
+ restore('ANTHROPIC_MODEL');
92
+ if (env[discoveryKey] === managed.gatewayDiscovery)
93
+ restore(discoveryKey);
94
+ if (typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && crypto.createHash('sha256').update(env.ANTHROPIC_AUTH_TOKEN).digest('hex') === managed.authTokenSha256)
95
+ restore('ANTHROPIC_AUTH_TOKEN');
96
+ if (Object.keys(env).length)
97
+ settings.env = env;
98
+ else
99
+ delete settings.env;
100
+ delete settings['scalemax.managedBy'];
101
+ delete settings.scalemax;
102
+ await backupFile(file, ctx.home);
103
+ if (Object.keys(settings).length)
104
+ await atomicWrite(file, JSON.stringify(settings, null, 2) + '\n', 0o600, ctx.platform);
105
+ else
106
+ await fs.remove(file);
107
+ return { id, name, changed: true, path: file, message: 'removed only ScaleMax Claude Code settings' };
108
+ }
@@ -1,3 +1,4 @@
1
1
  import { ConfigureContext, ConfigureResult } from '../types/client.js';
2
2
  export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
+ export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
3
4
  export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -1,14 +1,156 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { parseDocument } from 'yaml';
4
+ import { atomicWrite, backupFile } from '../utils/fs.js';
1
5
  import { clientConfigPath } from './paths.js';
2
- import { writeManagedConfig, removeManagedConfig } from './common.js';
3
- import { jsonConfig } from './templates.js';
6
+ import { isScaleMaxManagedText } from './common.js';
7
+ import { modelIds, openAiBaseUrl, preferredModel } from './models.js';
4
8
  const id = 'hermes';
5
9
  const name = 'Hermes';
10
+ const envKey = 'SCALEMAX_HERMES_API_KEY';
11
+ const envMarker = '# ScaleMax managed Hermes API key';
12
+ function asRecord(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
+ }
15
+ function envPath(home) { return path.join(home, '.hermes', '.env'); }
16
+ function managedEnv(text, apiKey) {
17
+ const kept = text.split(/\r?\n/).filter(line => line.trim() !== envMarker && !line.startsWith(`${envKey}=`));
18
+ while (kept.length && !kept[kept.length - 1])
19
+ kept.pop();
20
+ return `${kept.length ? `${kept.join('\n')}\n` : ''}${envMarker}\n${envKey}=${apiKey}\n`;
21
+ }
22
+ function withoutManagedEnv(text) {
23
+ const kept = text.split(/\r?\n/).filter(line => line.trim() !== envMarker && !line.startsWith(`${envKey}=`));
24
+ while (kept.length && !kept[kept.length - 1])
25
+ kept.pop();
26
+ return kept.length ? `${kept.join('\n')}\n` : '';
27
+ }
28
+ function parseYaml(text) {
29
+ const doc = parseDocument(text || '{}');
30
+ if (doc.errors.length)
31
+ throw new Error(`Hermes config.yaml is invalid: ${doc.errors[0].message}`);
32
+ if (!doc.contents)
33
+ doc.contents = doc.createNode({});
34
+ return doc;
35
+ }
36
+ function modelMetadata(ctx) {
37
+ return Object.fromEntries(modelIds(ctx).map(modelId => {
38
+ const model = (ctx.models || []).find(candidate => candidate.id === modelId);
39
+ const metadata = {};
40
+ if (model?.context_window)
41
+ metadata.context_length = model.context_window;
42
+ return [modelId, metadata];
43
+ }));
44
+ }
45
+ async function removeLegacyArtifact(ctx) {
46
+ const legacy = path.join(ctx.home, '.hermes', 'config', 'scalemax.json');
47
+ if (!(await fs.pathExists(legacy)))
48
+ return;
49
+ const text = await fs.readFile(legacy, 'utf8').catch(() => '');
50
+ if (!isScaleMaxManagedText(text) || !text.includes('api.scalemax.pro'))
51
+ return;
52
+ await backupFile(legacy, ctx.home);
53
+ await fs.remove(legacy);
54
+ }
6
55
  export async function configure(ctx) {
7
56
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, ctx.apiKey, ctx.baseUrl);
9
- return writeManagedConfig(ctx, id, name, file, content);
57
+ const secrets = envPath(ctx.home);
58
+ await fs.ensureDir(path.dirname(file));
59
+ const originalConfig = await fs.readFile(file, 'utf8').catch(() => '');
60
+ const originalEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
61
+ const doc = parseYaml(originalConfig);
62
+ const data = asRecord(doc.toJS());
63
+ const providers = Array.isArray(data.custom_providers) ? data.custom_providers.filter((provider) => provider?.name !== 'scalemax') : [];
64
+ providers.push({
65
+ name: 'scalemax',
66
+ base_url: openAiBaseUrl(ctx.baseUrl),
67
+ key_env: envKey,
68
+ api_mode: 'chat_completions',
69
+ extra_headers: { 'User-Agent': 'ScaleMax-Hermes/1.0' },
70
+ max_output_tokens: 4096,
71
+ models: modelMetadata(ctx)
72
+ });
73
+ doc.set('custom_providers', providers);
74
+ const currentModel = asRecord(data.model);
75
+ const activatesScaleMax = !currentModel.provider || String(currentModel.provider).startsWith('custom:scalemax');
76
+ if (activatesScaleMax) {
77
+ doc.set('model', { ...currentModel, provider: 'custom:scalemax', default: preferredModel(ctx), max_tokens: 4096 });
78
+ }
79
+ const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
80
+ try {
81
+ await atomicWrite(file, String(doc), 0o600, ctx.platform);
82
+ await atomicWrite(secrets, managedEnv(originalEnv, ctx.apiKey), 0o600, ctx.platform);
83
+ await removeLegacyArtifact(ctx);
84
+ }
85
+ catch (error) {
86
+ if (originalConfig)
87
+ await atomicWrite(file, originalConfig, 0o600, ctx.platform);
88
+ else
89
+ await fs.remove(file).catch(() => undefined);
90
+ if (originalEnv)
91
+ await atomicWrite(secrets, originalEnv, 0o600, ctx.platform);
92
+ else
93
+ await fs.remove(secrets).catch(() => undefined);
94
+ throw error;
95
+ }
96
+ return { id, name, changed: true, path: file, message: `configured named ScaleMax provider with ${modelIds(ctx).length} models${backups.length ? ' (backups created)' : ''}` };
97
+ }
98
+ export async function verify(ctx) {
99
+ const file = clientConfigPath(id, ctx.home, ctx.platform);
100
+ const secrets = envPath(ctx.home);
101
+ const doc = parseYaml(await fs.readFile(file, 'utf8'));
102
+ const data = asRecord(doc.toJS());
103
+ const provider = (Array.isArray(data.custom_providers) ? data.custom_providers : []).find((candidate) => candidate?.name === 'scalemax');
104
+ if (!provider || provider.base_url !== openAiBaseUrl(ctx.baseUrl) || provider.key_env !== envKey || provider.api_mode !== 'chat_completions' || provider.extra_headers?.['User-Agent'] !== 'ScaleMax-Hermes/1.0') {
105
+ throw new Error('Hermes ScaleMax custom provider settings are incomplete.');
106
+ }
107
+ const configured = Object.keys(asRecord(provider.models));
108
+ const missing = modelIds(ctx).filter(model => !configured.includes(model));
109
+ const env = await fs.readFile(secrets, 'utf8').catch(() => '');
110
+ if (missing.length || !env.includes(envMarker) || !env.includes(`${envKey}=${ctx.apiKey}`))
111
+ throw new Error('Hermes is missing authenticated ScaleMax models or credentials.');
112
+ if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens !== 4096)
113
+ throw new Error('Hermes active ScaleMax model must use max_tokens 4096.');
114
+ return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
10
115
  }
11
116
  export async function remove(ctx) {
12
117
  const file = clientConfigPath(id, ctx.home, ctx.platform);
13
- return removeManagedConfig(ctx, id, name, file);
118
+ const secrets = envPath(ctx.home);
119
+ if (!(await fs.pathExists(file)))
120
+ return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
121
+ const text = await fs.readFile(file, 'utf8');
122
+ const doc = parseYaml(text);
123
+ const data = asRecord(doc.toJS());
124
+ const providers = Array.isArray(data.custom_providers) ? data.custom_providers : [];
125
+ const managedProvider = providers.find((provider) => provider?.name === 'scalemax' && provider?.base_url === openAiBaseUrl(ctx.baseUrl));
126
+ if (!managedProvider)
127
+ return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
128
+ const managedModels = Object.keys(asRecord(managedProvider.models));
129
+ const remaining = providers.filter((provider) => provider?.name !== 'scalemax');
130
+ if (remaining.length)
131
+ doc.set('custom_providers', remaining);
132
+ else
133
+ doc.delete('custom_providers');
134
+ if (data?.model?.provider === 'custom:scalemax')
135
+ doc.deleteIn(['model', 'provider']);
136
+ if (data?.model?.provider === 'custom:scalemax' && managedModels.includes(String(data?.model?.default || '')))
137
+ doc.deleteIn(['model', 'default']);
138
+ if (data?.model?.provider === 'custom:scalemax' && data?.model?.max_tokens === 4096)
139
+ doc.deleteIn(['model', 'max_tokens']);
140
+ const currentEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
141
+ await backupFile(file, ctx.home);
142
+ if (currentEnv)
143
+ await backupFile(secrets, ctx.home);
144
+ const nextData = asRecord(doc.toJS());
145
+ if (Object.keys(nextData).length)
146
+ await atomicWrite(file, String(doc), 0o600, ctx.platform);
147
+ else
148
+ await fs.remove(file);
149
+ const nextEnv = withoutManagedEnv(currentEnv);
150
+ if (nextEnv)
151
+ await atomicWrite(secrets, nextEnv, 0o600, ctx.platform);
152
+ else
153
+ await fs.remove(secrets).catch(() => undefined);
154
+ await removeLegacyArtifact(ctx);
155
+ return { id, name, changed: true, path: file, message: 'removed only the ScaleMax custom provider and credential' };
14
156
  }
@@ -0,0 +1,6 @@
1
+ import { ConfigureContext } from '../types/client.js';
2
+ export declare function modelIds(ctx: ConfigureContext): string[];
3
+ export declare function preferredModel(ctx: ConfigureContext): string;
4
+ export declare function modelName(ctx: ConfigureContext, id: string): string;
5
+ export declare function anthropicOrigin(baseUrl: string): string;
6
+ export declare function openAiBaseUrl(baseUrl: string): string;
@@ -0,0 +1,38 @@
1
+ const preferred = [
2
+ 'claude-opus-4-8',
3
+ 'claude-sonnet-5',
4
+ 'claude-opus-4-7',
5
+ 'claude-opus-4-6',
6
+ 'claude-haiku-4-5-20251001',
7
+ 'fable-5',
8
+ 'fable-5-ultra'
9
+ ];
10
+ export function modelIds(ctx) {
11
+ return [...new Set((ctx.models || []).map(model => model.id.trim()).filter(Boolean))];
12
+ }
13
+ export function preferredModel(ctx) {
14
+ const ids = modelIds(ctx);
15
+ for (const candidate of preferred)
16
+ if (ids.includes(candidate))
17
+ return candidate;
18
+ if (ids.length)
19
+ return ids[0];
20
+ throw new Error('No compatible models are available for this API key; the client was not changed.');
21
+ }
22
+ export function modelName(ctx, id) {
23
+ const model = (ctx.models || []).find(candidate => candidate.id === id);
24
+ if (model?.display_name)
25
+ return model.display_name;
26
+ return id.split('-').map(part => part ? part[0].toUpperCase() + part.slice(1) : part).join(' ');
27
+ }
28
+ export function anthropicOrigin(baseUrl) {
29
+ const url = new URL(baseUrl);
30
+ url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, '');
31
+ url.search = '';
32
+ url.hash = '';
33
+ return url.toString().replace(/\/$/, '');
34
+ }
35
+ export function openAiBaseUrl(baseUrl) {
36
+ const origin = anthropicOrigin(baseUrl);
37
+ return `${origin}/v1`;
38
+ }
@@ -1,3 +1,4 @@
1
1
  import { ConfigureContext, ConfigureResult } from '../types/client.js';
2
2
  export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
+ export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
3
4
  export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -1,14 +1,129 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { atomicWrite, backupFile } from '../utils/fs.js';
1
4
  import { clientConfigPath } from './paths.js';
2
- import { writeManagedConfig, removeManagedConfig } from './common.js';
3
- import { jsonConfig } from './templates.js';
5
+ import { isScaleMaxManagedText, writeMergedJsonConfig } from './common.js';
6
+ import { anthropicOrigin, modelIds, modelName, preferredModel } from './models.js';
4
7
  const id = 'openclaw';
5
8
  const name = 'OpenClaw';
9
+ function asRecord(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
11
+ }
12
+ function entries(ctx) {
13
+ return modelIds(ctx).map(modelId => {
14
+ const metadata = (ctx.models || []).find(model => model.id === modelId);
15
+ const result = {
16
+ id: modelId,
17
+ name: modelName(ctx, modelId),
18
+ input: modelId.startsWith('claude-') && !modelId.startsWith('claude-fable-') ? ['text', 'image'] : ['text'],
19
+ maxTokens: Math.min(metadata?.max_output_tokens || 4096, 4096)
20
+ };
21
+ if (metadata?.context_window || modelId.startsWith('claude-'))
22
+ result.contextWindow = metadata?.context_window || 200000;
23
+ return result;
24
+ });
25
+ }
26
+ async function removeLegacyArtifact(ctx) {
27
+ const legacy = path.join(ctx.home, '.openclaw', 'config.json');
28
+ if (!(await fs.pathExists(legacy)))
29
+ return;
30
+ const text = await fs.readFile(legacy, 'utf8').catch(() => '');
31
+ if (!isScaleMaxManagedText(text) || !text.includes('api.scalemax.pro'))
32
+ return;
33
+ await backupFile(legacy, ctx.home);
34
+ await fs.remove(legacy);
35
+ }
6
36
  export async function configure(ctx) {
7
37
  const file = clientConfigPath(id, ctx.home, ctx.platform);
8
- const content = jsonConfig(name, ctx.apiKey, ctx.baseUrl);
9
- return writeManagedConfig(ctx, id, name, file, content);
38
+ const primary = `scalemax/${preferredModel(ctx)}`;
39
+ const result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
40
+ const modelsRoot = asRecord(existing.models);
41
+ const providers = asRecord(modelsRoot.providers);
42
+ const agents = asRecord(existing.agents);
43
+ const defaults = asRecord(agents.defaults);
44
+ const allowlist = asRecord(defaults.models);
45
+ for (const modelId of modelIds(ctx))
46
+ allowlist[`scalemax/${modelId}`] = asRecord(allowlist[`scalemax/${modelId}`]);
47
+ const currentModel = defaults.model;
48
+ const currentPrimary = typeof currentModel === 'string' ? currentModel : asRecord(currentModel).primary;
49
+ const nextModel = !currentPrimary || String(currentPrimary).startsWith('scalemax/')
50
+ ? { ...asRecord(currentModel), primary }
51
+ : currentModel;
52
+ return JSON.stringify({
53
+ ...existing,
54
+ models: {
55
+ ...modelsRoot,
56
+ mode: modelsRoot.mode || 'merge',
57
+ providers: {
58
+ ...providers,
59
+ scalemax: {
60
+ baseUrl: anthropicOrigin(ctx.baseUrl),
61
+ apiKey: ctx.apiKey,
62
+ api: 'anthropic-messages',
63
+ maxTokens: 4096,
64
+ models: entries(ctx)
65
+ }
66
+ }
67
+ },
68
+ agents: {
69
+ ...agents,
70
+ defaults: { ...defaults, model: nextModel, models: allowlist }
71
+ }
72
+ }, null, 2) + '\n';
73
+ });
74
+ await removeLegacyArtifact(ctx);
75
+ return { ...result, message: `configured native Anthropic provider with ${modelIds(ctx).length} models` };
76
+ }
77
+ export async function verify(ctx) {
78
+ const file = clientConfigPath(id, ctx.home, ctx.platform);
79
+ const config = await fs.readJson(file);
80
+ const provider = config?.models?.providers?.scalemax;
81
+ if (provider?.baseUrl !== anthropicOrigin(ctx.baseUrl) || provider?.apiKey !== ctx.apiKey || provider?.api !== 'anthropic-messages') {
82
+ throw new Error('OpenClaw ScaleMax provider settings are incomplete.');
83
+ }
84
+ const configured = new Set((Array.isArray(provider.models) ? provider.models : []).map((model) => model?.id));
85
+ const missing = modelIds(ctx).filter(model => !configured.has(model));
86
+ if (missing.length)
87
+ throw new Error(`OpenClaw is missing ${missing.length} authenticated model(s).`);
88
+ return { id, name, changed: false, path: file, message: `verified ${configured.size} ScaleMax models` };
10
89
  }
11
90
  export async function remove(ctx) {
12
91
  const file = clientConfigPath(id, ctx.home, ctx.platform);
13
- return removeManagedConfig(ctx, id, name, file);
92
+ if (!(await fs.pathExists(file)))
93
+ return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
94
+ const config = await fs.readJson(file).catch(() => undefined);
95
+ if (!config?.models?.providers?.scalemax || config.models.providers.scalemax.baseUrl !== anthropicOrigin(ctx.baseUrl)) {
96
+ return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
97
+ }
98
+ delete config.models.providers.scalemax;
99
+ if (!Object.keys(config.models.providers).length)
100
+ delete config.models.providers;
101
+ if (Object.keys(config.models).length === 1 && config.models.mode === 'merge')
102
+ delete config.models;
103
+ const defaults = config?.agents?.defaults;
104
+ if (defaults?.models && typeof defaults.models === 'object') {
105
+ for (const key of Object.keys(defaults.models))
106
+ if (key.startsWith('scalemax/'))
107
+ delete defaults.models[key];
108
+ if (!Object.keys(defaults.models).length)
109
+ delete defaults.models;
110
+ }
111
+ if (typeof defaults?.model === 'string' && defaults.model.startsWith('scalemax/'))
112
+ delete defaults.model;
113
+ if (defaults?.model?.primary?.startsWith?.('scalemax/')) {
114
+ delete defaults.model.primary;
115
+ if (!Object.keys(defaults.model).length)
116
+ delete defaults.model;
117
+ }
118
+ if (defaults && !Object.keys(defaults).length)
119
+ delete config.agents.defaults;
120
+ if (config.agents && !Object.keys(config.agents).length)
121
+ delete config.agents;
122
+ await backupFile(file, ctx.home);
123
+ if (Object.keys(config).length)
124
+ await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
125
+ else
126
+ await fs.remove(file);
127
+ await removeLegacyArtifact(ctx);
128
+ return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider and model entries' };
14
129
  }
@@ -1,3 +1,4 @@
1
1
  import { ConfigureContext, ConfigureResult } from '../types/client.js';
2
2
  export declare function configure(ctx: ConfigureContext): Promise<ConfigureResult>;
3
+ export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
3
4
  export declare function remove(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -1,33 +1,71 @@
1
+ import fs from 'fs-extra';
2
+ import { atomicWrite, backupFile } from '../utils/fs.js';
1
3
  import { clientConfigPath } from './paths.js';
2
- import { writeManagedConfig, removeManagedConfig } from './common.js';
4
+ import { writeMergedJsonConfig } from './common.js';
5
+ import { modelIds, modelName, openAiBaseUrl, preferredModel } from './models.js';
3
6
  const id = 'opencode';
4
7
  const name = 'OpenCode';
8
+ function asRecord(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
+ }
11
+ function providerConfig(ctx) {
12
+ const models = Object.fromEntries(modelIds(ctx).map(modelId => {
13
+ const metadata = (ctx.models || []).find(model => model.id === modelId);
14
+ return [modelId, {
15
+ name: modelName(ctx, modelId),
16
+ limit: {
17
+ context: metadata?.context_window || (modelId.startsWith('claude-') ? 200000 : 128000),
18
+ output: Math.min(metadata?.max_output_tokens || 4096, 4096)
19
+ }
20
+ }];
21
+ }));
22
+ return {
23
+ npm: '@ai-sdk/openai-compatible',
24
+ name: 'ScaleMax',
25
+ options: { baseURL: openAiBaseUrl(ctx.baseUrl), apiKey: ctx.apiKey },
26
+ models
27
+ };
28
+ }
5
29
  export async function configure(ctx) {
6
30
  const file = clientConfigPath(id, ctx.home, ctx.platform);
7
- const content = JSON.stringify({
8
- $schema: 'https://opencode.ai/config.json',
9
- provider: {
10
- scalemax: {
11
- npm: '@ai-sdk/openai-compatible',
12
- name: 'ScaleMax',
13
- options: { baseURL: ctx.baseUrl, apiKey: ctx.apiKey },
14
- models: {
15
- 'fable-5': { name: 'Fable 5' },
16
- 'fable-5-ultra': { name: 'Fable 5 Ultra' },
17
- 'gpt-5.6-luna': { name: 'GPT-5.6 Luna' },
18
- 'gpt-5.6-terra': { name: 'GPT-5.6 Terra' },
19
- 'gpt-5.6-sol': { name: 'GPT-5.6 Sol' },
20
- 'gpt-5.4': { name: 'GPT-5.4' },
21
- 'gpt-5.4-mini': { name: 'GPT-5.4 Mini' },
22
- 'gpt-5.3-codex': { name: 'GPT-5.3 Codex' },
23
- 'codex-auto-review': { name: 'Codex Auto Review' }
24
- }
25
- }
26
- }
27
- }, null, 2) + '\n';
28
- return writeManagedConfig(ctx, id, name, file, content);
31
+ const selected = `scalemax/${preferredModel(ctx)}`;
32
+ return writeMergedJsonConfig(ctx, id, name, file, existing => {
33
+ const providers = asRecord(existing.provider);
34
+ const currentModel = typeof existing.model === 'string' ? existing.model : undefined;
35
+ return JSON.stringify({
36
+ ...existing,
37
+ $schema: typeof existing.$schema === 'string' ? existing.$schema : 'https://opencode.ai/config.json',
38
+ provider: { ...providers, scalemax: providerConfig(ctx) },
39
+ model: !currentModel || currentModel.startsWith('scalemax/') ? selected : currentModel
40
+ }, null, 2) + '\n';
41
+ });
42
+ }
43
+ export async function verify(ctx) {
44
+ const file = clientConfigPath(id, ctx.home, ctx.platform);
45
+ const config = await fs.readJson(file);
46
+ const provider = config?.provider?.scalemax;
47
+ if (provider?.npm !== '@ai-sdk/openai-compatible' || provider?.options?.baseURL !== openAiBaseUrl(ctx.baseUrl) || provider?.options?.apiKey !== ctx.apiKey) {
48
+ throw new Error('OpenCode ScaleMax provider settings are incomplete.');
49
+ }
50
+ const configured = Object.keys(asRecord(provider.models));
51
+ const missing = modelIds(ctx).filter(model => !configured.includes(model));
52
+ if (missing.length)
53
+ throw new Error(`OpenCode is missing ${missing.length} authenticated model(s).`);
54
+ return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
29
55
  }
30
56
  export async function remove(ctx) {
31
57
  const file = clientConfigPath(id, ctx.home, ctx.platform);
32
- return removeManagedConfig(ctx, id, name, file);
58
+ if (!(await fs.pathExists(file)))
59
+ return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
60
+ const config = await fs.readJson(file).catch(() => undefined);
61
+ if (!config?.provider?.scalemax || config.provider.scalemax.name !== 'ScaleMax' || config.provider.scalemax.options?.baseURL !== openAiBaseUrl(ctx.baseUrl))
62
+ return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
63
+ delete config.provider.scalemax;
64
+ if (!Object.keys(config.provider).length)
65
+ delete config.provider;
66
+ if (typeof config.model === 'string' && config.model.startsWith('scalemax/'))
67
+ delete config.model;
68
+ await backupFile(file, ctx.home);
69
+ await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
70
+ return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider' };
33
71
  }
@@ -12,8 +12,8 @@ export function clientConfigPath(id, home, platform, env = process.env) {
12
12
  case 'trae-solo': return path.join(home, '.scalemax', 'trae-solo.setup.json');
13
13
  case 'claude': return path.join(home, '.claude', 'settings.json');
14
14
  case 'opencode': return path.join(linux, 'opencode', 'opencode.json');
15
- case 'openclaw': return path.join(home, '.openclaw', 'config.json');
16
- case 'hermes': return path.join(home, '.hermes', 'config', 'scalemax.json');
15
+ case 'openclaw': return path.join(home, '.openclaw', 'openclaw.json');
16
+ case 'hermes': return path.join(home, '.hermes', 'config.yaml');
17
17
  case 'cherry-studio': return path.join(home, '.scalemax', 'cherry-studio.setup.json');
18
18
  case 'api-code': return path.join(home, '.scalemax', 'api-code.examples.json');
19
19
  }
@@ -6,6 +6,26 @@ const commands = {
6
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': []
7
7
  };
8
8
  const guiPathDetection = new Set(['trae-solo', 'cherry-studio']);
9
+ function hasNativeScaleMaxConfig(id, text) {
10
+ if (text.includes(GENERATED_HEADER) || text.includes('"scalemax.managedBy"'))
11
+ return true;
12
+ if (!text.includes('api.scalemax.pro'))
13
+ return false;
14
+ if (id === 'hermes')
15
+ return /(?:^|\n)\s*-?\s*name:\s*["']?scalemax["']?\s*(?:#.*)?(?:\n|$)/i.test(text);
16
+ if (id === 'opencode' || id === 'openclaw') {
17
+ try {
18
+ const config = JSON.parse(text);
19
+ return id === 'opencode'
20
+ ? config?.provider?.scalemax?.name === 'ScaleMax'
21
+ : typeof config?.models?.providers?.scalemax?.baseUrl === 'string';
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ return false;
28
+ }
9
29
  export async function detectClient(ctx, id, name) {
10
30
  const config = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
11
31
  const cmdHits = [];
@@ -20,7 +40,7 @@ export async function detectClient(ctx, id, name) {
20
40
  let configured = false;
21
41
  if (await fs.pathExists(config)) {
22
42
  const text = await fs.readFile(config, 'utf8').catch(() => '');
23
- configured = text.includes(GENERATED_HEADER) || text.includes('"scalemax.managedBy"');
43
+ configured = hasNativeScaleMaxConfig(id, text);
24
44
  }
25
45
  const hits = [...cmdHits, ...appHits];
26
46
  return { id, name, installed: hits.length > 0, configured, reason: hits.length ? undefined : 'not detected', paths: [...hits, ...(configured ? [config] : [])] };
@@ -34,11 +34,15 @@ export interface ConfigureContext extends DetectContext {
34
34
  dryRun?: boolean;
35
35
  interactive?: boolean;
36
36
  allowAppModification?: boolean;
37
+ providerScope?: ProviderScope;
37
38
  models?: Array<{
38
39
  id: string;
39
40
  object?: string;
40
41
  owned_by?: string;
42
+ provider?: string;
41
43
  display_name?: string;
44
+ context_window?: number | null;
45
+ max_output_tokens?: number | null;
42
46
  }>;
43
47
  }
44
48
  export interface ConfigureResult {
@@ -3,7 +3,10 @@ export interface ModelInfo {
3
3
  id: string;
4
4
  object?: string;
5
5
  owned_by?: string;
6
+ provider?: string;
6
7
  display_name?: string;
8
+ context_window?: number | null;
9
+ max_output_tokens?: number | null;
7
10
  }
8
11
  export interface KeyMetadata {
9
12
  provider_scope: ProviderScope;
@@ -11,6 +14,8 @@ export interface KeyMetadata {
11
14
  prefix?: string;
12
15
  label?: string;
13
16
  }
17
+ export declare function isAnthropicModel(model: ModelInfo): boolean;
18
+ export declare function filterModelsForScope(models: ModelInfo[], scope: ProviderScope): ModelInfo[];
14
19
  export declare function validateApiKey(apiKey: string): Promise<{
15
20
  ok: boolean;
16
21
  models: ModelInfo[];
package/dist/utils/api.js CHANGED
@@ -1,5 +1,16 @@
1
1
  import { SCALEMAX_BASE_URL } from '../constants.js';
2
2
  import { sanitizeApiKey } from './apiKey.js';
3
+ export function isAnthropicModel(model) {
4
+ const provider = String(model.provider || model.owned_by || '').trim().toLowerCase();
5
+ if (provider)
6
+ return provider === 'anthropic' || provider.startsWith('anthropic-');
7
+ return model.id.startsWith('claude-') && !model.id.startsWith('claude-fable-');
8
+ }
9
+ export function filterModelsForScope(models, scope) {
10
+ if (scope === 'both')
11
+ return models;
12
+ return models.filter(model => scope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model));
13
+ }
3
14
  async function friendlyError(res) {
4
15
  let body = '';
5
16
  try {
@@ -42,7 +53,8 @@ export async function validateApiKey(apiKey) {
42
53
  if (!modelsRes.ok)
43
54
  return { ok: false, models: [], key: keyMeta, error: await friendlyError(modelsRes) };
44
55
  const data = await modelsRes.json();
45
- const models = Array.isArray(data.data) ? data.data : [];
56
+ const catalog = Array.isArray(data.data) ? data.data : [];
57
+ const models = filterModelsForScope(catalog, keyMeta.provider_scope);
46
58
  if (!models.length)
47
59
  return { ok: false, models: [], key: keyMeta, error: 'ScaleMax returned an empty model catalog.' };
48
60
  return { ok: true, models, key: keyMeta };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,8 @@
31
31
  "chalk": "^5.3.0",
32
32
  "execa": "^9.5.1",
33
33
  "fs-extra": "^11.2.0",
34
- "ora": "^8.1.1"
34
+ "ora": "^8.1.1",
35
+ "yaml": "^2.9.0"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@types/fs-extra": "^11.0.4",