scalemax 0.1.25 → 0.1.28

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
@@ -77,6 +77,7 @@ scalemax logout Remove stored ScaleMax API key
77
77
  scalemax doctor Run diagnostics
78
78
  scalemax models List available ScaleMax models
79
79
  scalemax helper-bot Open the ScaleMax-only support assistant
80
+ scalemax claude-tools Install bounded GitHub skill tools for Claude
80
81
  scalemax update Reconfigure detected clients with the stored key
81
82
  scalemax remove Remove ScaleMax-generated client configuration
82
83
  scalemax --help Show help
@@ -121,7 +122,10 @@ npx scalemax@latest helper-bot --model fable-5 "Help me configure ScaleMax in Cu
121
122
 
122
123
  Recognized setup, repair, and verification requests execute the CLI's allowlisted client operations directly. Requests such as `setup all providers`, `fix Hermes request failed`, `configure Claude Code`, `verify OpenCode`, and `setup vs code roo extension for claude` are handled by the controller before any model request. The controller detects the relevant client, runs a safe installer when the client has one, verifies existing ScaleMax configuration, repairs ScaleMax-managed files when verification fails, re-verifies, and reports each step.
123
124
 
124
- For Roo Code, helper-bot can install VS Code on Windows with the fixed Microsoft winget package, install the official Roo Code extension, write OpenAI and Claude ScaleMax profiles when the key exposes both families, and verify the import handoff. For general troubleshooting that cannot be completed by a fixed client action, helper-bot sends a small `gpt-5.4` request grounded with bundled ScaleMax documentation about stored authentication, credit/token base URLs, provider scope, client setup, and common errors. The language model never generates or executes shell commands; only fixed ScaleMax client detection, installation, configuration, verification, and rollback operations are available to the controller.
125
+ For Claude tool access, run `scalemax claude-tools`, or ask helper-bot to `install GitHub skill tools for Claude Desktop and Claude Code`. Claude Code receives Anthropic's official `example-skills@anthropic-agent-skills` plugin plus public-GitHub read permissions. Claude Desktop/Cowork receives a local MCP server with only three tools: `github_fetch`, `github_tree`, and `github_install_skill`. The MCP server accepts only public GitHub HTTPS hosts, limits downloads, rejects unsafe archive paths and symlinks, refuses to replace an existing skill, and can write only beneath `~/Claude/skills`. It does not expose a general shell, arbitrary URLs, or unrestricted filesystem access. Restart Claude Desktop after installation and use Cowork or Code; a normal Chat tab does not expose local tools.
126
+ Only install skills from repositories you trust: a `SKILL.md` is instruction content that Claude may follow after installation, even though the bridge itself cannot execute arbitrary repository code.
127
+
128
+ For Roo Code, helper-bot can install VS Code on Windows with the fixed Microsoft winget package, install the official Roo Code extension, write OpenAI and Claude ScaleMax profiles when the key exposes those families, and verify the import handoff. For DeepSeek V4 Flash, helper-bot installs and configures Continue's Chat/Edit integration instead because the Azure deployment has no tool calling. For general troubleshooting that cannot be completed by a fixed client action, helper-bot sends a small `gpt-5.4` request grounded with bundled ScaleMax documentation about stored authentication, credit/token base URLs, provider scope, client setup, and common errors. The language model never generates or executes shell commands; only fixed ScaleMax client detection, installation, configuration, verification, and rollback operations are available to the controller.
125
129
 
126
130
  The helper refuses unrelated requests and redacts ScaleMax-style secrets from prompts, provider output, errors, and progress notifications. Use `/status` for non-secret connection details, `/help` for commands, and `/quit` to exit.
127
131
 
@@ -132,6 +136,7 @@ The helper refuses unrelated requests and redacts ScaleMax-style secrets from pr
132
136
  - Codex
133
137
  - Cursor
134
138
  - Roo Code (VS Code)
139
+ - Continue (VS Code)
135
140
  - TRAE SOLO
136
141
  - Claude Code
137
142
  - OpenCode
@@ -156,7 +161,7 @@ ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.js
156
161
 
157
162
  ### OpenClaw
158
163
 
159
- ScaleMax partitions OpenClaw models into native `openai-responses` (`scalemax-codex`, `/v1`) and `anthropic-messages` (`scalemax-claude`, origin) providers. The API key and compatibility User-Agent are OpenClaw file-backed SecretRefs stored in private `0600` files under `~/.openclaw/secrets/`; neither value is embedded in the main provider JSON. Migration removes the obsolete mixed provider, preserves the active model family, and scrubs plaintext credentials from generated ScaleMax agent registries even when they contain an older key. Output is capped at 4096 tokens, private-network routing is denied for the public gateway, and unrelated providers and agent settings are preserved.
164
+ ScaleMax partitions OpenClaw models into native `openai-responses` (`scalemax-codex`, `/v1`), OpenAI-compatible `openai-completions` (`scalemax-deepseek`, `/v1`), and `anthropic-messages` (`scalemax-claude`, origin) providers. `deepseek-v4-flash` is isolated in the Chat Completions provider with tools disabled; it is never advertised through Responses. The API key and compatibility User-Agent are OpenClaw file-backed SecretRefs stored in private `0600` files under `~/.openclaw/secrets/`; neither value is embedded in the main provider JSON. Migration removes the obsolete mixed provider, preserves the active model family, and scrubs plaintext credentials from generated ScaleMax agent registries even when they contain an older key. Output is capped at 4096 tokens, private-network routing is denied for the public gateway, and unrelated providers and agent settings are preserved.
160
165
 
161
166
  ### Hermes
162
167
 
@@ -164,7 +169,11 @@ ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and
164
169
 
165
170
  ### Roo Code (VS Code)
166
171
 
167
- 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. Reload VS Code once so Roo imports the Codex/OpenAI-only profile into its SecretStorage. CLI verification intentionally reports the handoff as verified, not the extension runtime, until that reload occurs.
172
+ 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 and Claude profiles remain separate. DeepSeek V4 Flash is deliberately not offered in Roo because Roo's task-completion loop requires tool calling and this Azure deployment does not expose it. 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
+ ### Continue (VS Code)
175
+
176
+ Continue is the supported VS Code route for `deepseek-v4-flash`. The CLI installs the official `Continue.continue` extension when VS Code is available, merges a Chat/Edit-only 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, select **ScaleMax DeepSeek V4 Flash**, and use Chat or Edit; Agent mode is unavailable because the deployment does not expose tool calling.
168
177
 
169
178
  ### Cursor BYOK routing on macOS
170
179
 
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Least-privilege GitHub MCP bridge installed by ScaleMax for Claude Desktop.
5
+ *
6
+ * It exposes no general shell, arbitrary URL, or general filesystem tool. It
7
+ * can read public GitHub content and install one repository subdirectory that
8
+ * contains SKILL.md under ~/Claude/skills. Existing skills are never replaced.
9
+ */
10
+ import { createInterface } from 'node:readline';
11
+ import { promises as fs } from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+ const MAX_FETCH_BYTES = 512 * 1024;
19
+ const MAX_TREE_BYTES = 2 * 1024 * 1024;
20
+ const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024;
21
+ const MAX_INSTALLED_BYTES = 64 * 1024 * 1024;
22
+ const MAX_INSTALLED_FILES = 2_000;
23
+ const ALLOWED_HOSTS = new Set([
24
+ 'github.com',
25
+ 'raw.githubusercontent.com',
26
+ 'api.github.com',
27
+ 'codeload.github.com',
28
+ 'objects.githubusercontent.com',
29
+ ]);
30
+ const SKILLS_ROOT = path.join(os.homedir(), 'Claude', 'skills');
31
+
32
+ function send(message) {
33
+ process.stdout.write(`${JSON.stringify(message)}\n`);
34
+ }
35
+
36
+ function result(id, value) {
37
+ send({ jsonrpc: '2.0', id, result: value });
38
+ }
39
+
40
+ function failure(id, code, message) {
41
+ send({ jsonrpc: '2.0', id, error: { code, message } });
42
+ }
43
+
44
+ function isSafeRelative(value) {
45
+ return typeof value === 'string' && value.length > 0 && value.length <= 500 &&
46
+ !path.posix.isAbsolute(value) && !value.split('/').includes('..') &&
47
+ !value.split('/').some(part => !part || part === '.') &&
48
+ !value.includes('\\') && !value.includes('\0');
49
+ }
50
+
51
+ function validateHost(rawUrl) {
52
+ let url;
53
+ try {
54
+ url = new URL(rawUrl);
55
+ } catch {
56
+ throw new Error('url must be a valid HTTPS URL');
57
+ }
58
+ if (url.protocol !== 'https:' || !ALLOWED_HOSTS.has(url.hostname.toLowerCase())) {
59
+ throw new Error('only public GitHub HTTPS hosts are allowed');
60
+ }
61
+ if (url.username || url.password) throw new Error('credentialed URLs are not allowed');
62
+ for (const key of url.searchParams.keys()) {
63
+ if (/(?:token|key|secret|signature|credential|auth)/i.test(key)) {
64
+ throw new Error('credential-like URL query parameters are not allowed');
65
+ }
66
+ }
67
+ return url;
68
+ }
69
+
70
+ async function readLimited(response, limit = MAX_FETCH_BYTES) {
71
+ const length = Number(response.headers.get('content-length') || 0);
72
+ if (length > limit) throw new Error(`response exceeds ${limit} bytes`);
73
+ const reader = response.body?.getReader();
74
+ if (!reader) {
75
+ const bytes = Buffer.from(await response.arrayBuffer());
76
+ if (bytes.byteLength > limit) throw new Error(`response exceeds ${limit} bytes`);
77
+ return bytes;
78
+ }
79
+ const chunks = [];
80
+ let total = 0;
81
+ for (;;) {
82
+ const { done, value } = await reader.read();
83
+ if (done) break;
84
+ total += value.byteLength;
85
+ if (total > limit) {
86
+ await reader.cancel();
87
+ throw new Error(`response exceeds ${limit} bytes`);
88
+ }
89
+ chunks.push(Buffer.from(value));
90
+ }
91
+ return Buffer.concat(chunks);
92
+ }
93
+
94
+ async function fetchBytes(rawUrl, limit = MAX_FETCH_BYTES) {
95
+ const url = validateHost(rawUrl);
96
+ const response = await fetch(url, {
97
+ method: 'GET',
98
+ redirect: 'error',
99
+ headers: { 'user-agent': 'ScaleMax-Claude-GitHub-Tools/1.1' },
100
+ });
101
+ if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}`);
102
+ return { url: url.toString(), response, bytes: await readLimited(response, limit) };
103
+ }
104
+
105
+ function repoSlug(repo) {
106
+ if (typeof repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
107
+ throw new Error('repo must be in owner/name form');
108
+ }
109
+ return repo;
110
+ }
111
+
112
+ function safeRef(ref = 'main') {
113
+ if (typeof ref !== 'string' || !/^[A-Za-z0-9._/-]{1,120}$/.test(ref) || ref.includes('..')) {
114
+ throw new Error('ref contains unsupported characters');
115
+ }
116
+ return ref;
117
+ }
118
+
119
+ async function githubFetch(args) {
120
+ const maxBytes = Number.isInteger(args?.max_bytes)
121
+ ? Math.min(Math.max(args.max_bytes, 1), MAX_FETCH_BYTES)
122
+ : MAX_FETCH_BYTES;
123
+ const { url, response, bytes } = await fetchBytes(args?.url, maxBytes);
124
+ const contentType = response.headers.get('content-type') || 'application/octet-stream';
125
+ const text = /(?:text|json|javascript|xml|yaml)/i.test(contentType)
126
+ ? bytes.toString('utf8')
127
+ : `[binary response: ${bytes.byteLength} bytes, ${contentType}]`;
128
+ return { url, status: response.status, content_type: contentType, bytes: bytes.byteLength, content: text };
129
+ }
130
+
131
+ async function githubTree(args) {
132
+ const repo = repoSlug(args?.repo);
133
+ const ref = safeRef(args?.ref);
134
+ const encodedRef = ref.split('/').map(encodeURIComponent).join('/');
135
+ const { bytes } = await fetchBytes(`https://api.github.com/repos/${repo}/git/trees/${encodedRef}?recursive=1`, MAX_TREE_BYTES);
136
+ const payload = JSON.parse(bytes.toString('utf8'));
137
+ if (!Array.isArray(payload.tree)) throw new Error('GitHub did not return a repository tree');
138
+ return {
139
+ repo,
140
+ ref,
141
+ truncated: payload.truncated === true,
142
+ paths: payload.tree.filter(entry => entry.type === 'blob').map(entry => entry.path).slice(0, 5_000),
143
+ };
144
+ }
145
+
146
+ function archiveRoot(entries) {
147
+ const roots = new Set();
148
+ for (const entry of entries) {
149
+ const normalized = entry.replace(/^\.?\//, '');
150
+ if (normalized.startsWith('/') || normalized.split('/').includes('..') || normalized.includes('\\')) {
151
+ throw new Error('unsafe archive path rejected');
152
+ }
153
+ const root = normalized.split('/')[0];
154
+ if (!root) throw new Error('invalid archive entry');
155
+ roots.add(root);
156
+ }
157
+ if (roots.size !== 1) throw new Error('unexpected archive layout');
158
+ return [...roots][0];
159
+ }
160
+
161
+ async function validateExtractedTree(root) {
162
+ const pending = [root];
163
+ let files = 0;
164
+ let bytes = 0;
165
+ while (pending.length) {
166
+ const current = pending.pop();
167
+ const stat = await fs.lstat(current);
168
+ if (stat.isSymbolicLink()) throw new Error('skill archives may not contain symbolic links');
169
+ if (stat.isDirectory()) {
170
+ for (const name of await fs.readdir(current)) pending.push(path.join(current, name));
171
+ continue;
172
+ }
173
+ if (!stat.isFile()) throw new Error('skill archives may contain only files and directories');
174
+ files += 1;
175
+ bytes += stat.size;
176
+ if (files > MAX_INSTALLED_FILES || bytes > MAX_INSTALLED_BYTES) throw new Error('extracted skill exceeds safety limits');
177
+ }
178
+ }
179
+
180
+ async function tarCommand() {
181
+ const candidates = process.platform === 'win32'
182
+ ? [path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe')]
183
+ : ['/usr/bin/tar', '/bin/tar'];
184
+ for (const candidate of candidates) {
185
+ try { await fs.access(candidate); return candidate; } catch {}
186
+ }
187
+ throw new Error('a system tar utility is required to install a GitHub skill');
188
+ }
189
+
190
+ async function safeSkillsRoot() {
191
+ await fs.mkdir(SKILLS_ROOT, { recursive: true, mode: 0o700 });
192
+ const resolved = await fs.realpath(SKILLS_ROOT);
193
+ const expected = path.resolve(SKILLS_ROOT);
194
+ const samePath = process.platform === 'win32'
195
+ ? resolved.toLowerCase() === expected.toLowerCase()
196
+ : resolved === expected;
197
+ if (!samePath) throw new Error('refusing a symlinked Claude skills directory');
198
+ return resolved;
199
+ }
200
+
201
+ async function githubInstallSkill(args) {
202
+ const repo = repoSlug(args?.repo);
203
+ const ref = safeRef(args?.ref);
204
+ const skillPath = args?.skill_path || '';
205
+ if (!isSafeRelative(skillPath)) throw new Error('skill_path must be a non-empty safe relative path');
206
+ const skillName = path.posix.basename(skillPath);
207
+ if (!/^[A-Za-z0-9._-]{1,80}$/.test(skillName)) throw new Error('skill directory name is not safe');
208
+ const skillsRoot = await safeSkillsRoot();
209
+ const destination = path.join(skillsRoot, skillName);
210
+ try {
211
+ await fs.access(destination);
212
+ throw new Error(`skill already exists at ${destination}; refusing to overwrite`);
213
+ } catch (error) {
214
+ if (error?.message?.startsWith('skill already exists')) throw error;
215
+ if (error?.code !== 'ENOENT') throw error;
216
+ }
217
+
218
+ const encodedRef = ref.split('/').map(encodeURIComponent).join('/');
219
+ const { bytes } = await fetchBytes(`https://codeload.github.com/${repo}/tar.gz/${encodedRef}`, MAX_ARCHIVE_BYTES);
220
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'scalemax-skill-'));
221
+ const archive = path.join(tempRoot, 'repo.tar.gz');
222
+ const extracted = path.join(tempRoot, 'extracted');
223
+ try {
224
+ await fs.writeFile(archive, bytes, { mode: 0o600 });
225
+ await fs.mkdir(extracted, { mode: 0o700 });
226
+ const tar = await tarCommand();
227
+ const verbose = await execFileAsync(tar, ['-tvzf', archive], { maxBuffer: 4 * 1024 * 1024 });
228
+ for (const line of String(verbose.stdout).split('\n')) {
229
+ const marker = line.trimStart()[0];
230
+ if (marker === 'l' || marker === 'h') throw new Error('skill archives may not contain symbolic or hard links');
231
+ }
232
+ const listing = await execFileAsync(tar, ['-tzf', archive], { maxBuffer: 4 * 1024 * 1024 });
233
+ const entries = String(listing.stdout).split('\n').map(value => value.trim()).filter(Boolean);
234
+ const root = archiveRoot(entries);
235
+ const normalizedSkillPath = skillPath.replace(/^\/+|\/+$/g, '');
236
+ const prefix = `${root}/${normalizedSkillPath}`;
237
+ if (!entries.some(entry => entry.replace(/\/$/, '') === `${prefix}/SKILL.md`)) {
238
+ throw new Error('requested repository path does not contain SKILL.md');
239
+ }
240
+ await execFileAsync(tar, ['-xzf', archive, '-C', extracted, prefix], { maxBuffer: 4 * 1024 * 1024 });
241
+ const source = path.join(extracted, root, ...normalizedSkillPath.split('/'));
242
+ await validateExtractedTree(source);
243
+ const skillFile = path.join(source, 'SKILL.md');
244
+ if (!(await fs.lstat(skillFile)).isFile()) throw new Error('SKILL.md is not a regular file');
245
+ try { await fs.rename(source, destination); }
246
+ catch (error) {
247
+ if (error?.code !== 'EXDEV') throw error;
248
+ await fs.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
249
+ await fs.rm(source, { recursive: true, force: true });
250
+ }
251
+ return { repo, ref, skill_path: skillPath, installed_to: destination };
252
+ } finally {
253
+ await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {});
254
+ }
255
+ }
256
+
257
+ const tools = [
258
+ {
259
+ name: 'github_fetch',
260
+ description: 'Read public GitHub text or metadata over HTTPS. Only GitHub hosts are permitted; no writes or arbitrary URLs.',
261
+ inputSchema: { type: 'object', properties: { url: { type: 'string' }, max_bytes: { type: 'integer', minimum: 1, maximum: MAX_FETCH_BYTES } }, required: ['url'] },
262
+ annotations: { readOnlyHint: true, openWorldHint: false },
263
+ },
264
+ {
265
+ name: 'github_tree',
266
+ description: 'List public files in a GitHub repository/ref so a skill directory can be selected.',
267
+ inputSchema: { type: 'object', properties: { repo: { type: 'string' }, ref: { type: 'string' } }, required: ['repo'] },
268
+ annotations: { readOnlyHint: true, openWorldHint: false },
269
+ },
270
+ {
271
+ name: 'github_install_skill',
272
+ description: 'Download one public GitHub skill directory containing SKILL.md into ~/Claude/skills. Refuses overwrite and unsafe paths.',
273
+ inputSchema: { type: 'object', properties: { repo: { type: 'string' }, skill_path: { type: 'string' }, ref: { type: 'string' } }, required: ['repo', 'skill_path'] },
274
+ annotations: { readOnlyHint: false, openWorldHint: false },
275
+ },
276
+ ];
277
+
278
+ async function handle(request) {
279
+ const id = request.id;
280
+ switch (request.method) {
281
+ case 'initialize':
282
+ return result(id, {
283
+ protocolVersion: request.params?.protocolVersion || '2024-11-05',
284
+ capabilities: { tools: {} },
285
+ serverInfo: { name: 'scalemax-github-tools', version: '1.1.0' },
286
+ });
287
+ case 'notifications/initialized':
288
+ case 'notifications/cancelled':
289
+ return;
290
+ case 'ping':
291
+ return result(id, {});
292
+ case 'tools/list':
293
+ return result(id, { tools });
294
+ case 'tools/call': {
295
+ try {
296
+ const name = request.params?.name;
297
+ const args = request.params?.arguments || {};
298
+ let value;
299
+ if (name === 'github_fetch') value = await githubFetch(args);
300
+ else if (name === 'github_tree') value = await githubTree(args);
301
+ else if (name === 'github_install_skill') value = await githubInstallSkill(args);
302
+ else throw new Error(`unknown tool: ${name}`);
303
+ return result(id, { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], structuredContent: value });
304
+ } catch (error) {
305
+ return result(id, { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true });
306
+ }
307
+ }
308
+ default:
309
+ return failure(id, -32601, `method not found: ${request.method}`);
310
+ }
311
+ }
312
+
313
+ const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
314
+ input.on('line', line => {
315
+ if (!line.trim()) return;
316
+ let request;
317
+ try { request = JSON.parse(line); } catch { return; }
318
+ Promise.resolve(handle(request)).catch(error => failure(request.id, -32603, error instanceof Error ? error.message : String(error)));
319
+ });
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 continueDetect from './detect/continue.js';
8
+ import * as continueConfig from './configure/continue.js';
7
9
  import * as traeSoloDetect from './detect/trae-solo.js';
8
10
  import * as traeSoloConfig from './configure/trae-solo.js';
9
11
  import * as claudeDetect from './detect/claude.js';
@@ -22,6 +24,7 @@ export const clients = [
22
24
  { id: 'codex', name: 'Codex', detect: codexDetect.detect, install: codexConfig.install, configure: codexConfig.configure, verify: codexConfig.verify, launch: codexConfig.launch, remove: codexConfig.remove },
23
25
  { id: 'cursor', name: 'Cursor', detect: cursorDetect.detect, configure: cursorConfig.configure, verify: cursorConfig.verify, remove: cursorConfig.remove },
24
26
  { id: 'roo-code', name: 'Roo Code (VS Code)', detect: rooCodeDetect.detect, configure: rooCodeConfig.configure, verify: rooCodeConfig.verify, remove: rooCodeConfig.remove },
27
+ { id: 'continue', name: 'Continue (VS Code)', detect: continueDetect.detect, install: continueConfig.install, configure: continueConfig.configure, verify: continueConfig.verify, remove: continueConfig.remove },
25
28
  { id: 'trae-solo', name: 'TRAE SOLO', detect: traeSoloDetect.detect, configure: traeSoloConfig.configure, remove: traeSoloConfig.remove },
26
29
  { id: 'claude', name: 'Claude Code', detect: claudeDetect.detect, configure: claudeConfig.configure, verify: claudeConfig.verify, remove: claudeConfig.remove },
27
30
  { id: 'opencode', name: 'OpenCode', detect: opencodeDetect.detect, configure: opencodeConfig.configure, verify: opencodeConfig.verify, remove: opencodeConfig.remove },
@@ -0,0 +1 @@
1
+ export declare function claudeToolsCommand(home?: string): Promise<void>;
@@ -0,0 +1,16 @@
1
+ import chalk from 'chalk';
2
+ import { installClaudeGithubTools } from '../configure/claude-tools.js';
3
+ import { homeDir } from '../utils/paths.js';
4
+ export async function claudeToolsCommand(home = homeDir()) {
5
+ console.log(chalk.dim('Installing bounded public-GitHub tools for Claude Desktop/Cowork and Claude Code…'));
6
+ const result = await installClaudeGithubTools('both', home, process.platform, process.env);
7
+ if (result.desktop) {
8
+ console.log(chalk.green(`✓ Claude Desktop MCP verified (${result.desktop.configPaths.length} config${result.desktop.configPaths.length === 1 ? '' : 's'}).`));
9
+ console.log(chalk.dim(` Tools: github_fetch, github_tree, github_install_skill · destination: ~/Claude/skills`));
10
+ }
11
+ if (result.code?.installed)
12
+ console.log(chalk.green(`✓ Claude Code ${result.code.message}.`));
13
+ else if (result.code)
14
+ console.log(chalk.yellow(`! ${result.code.message}`));
15
+ console.log(chalk.dim('No general shell, arbitrary URL, or unrestricted filesystem tool was granted. Restart Claude Desktop before using the new MCP tools.'));
16
+ }
@@ -6,6 +6,7 @@ import { SCALEMAX_BASE_URL } from '../constants.js';
6
6
  import { loadApiKey } from '../utils/keyStore.js';
7
7
  import { validateApiKey, isAnthropicModel } from '../utils/api.js';
8
8
  import { homeDir } from '../utils/paths.js';
9
+ import { isDeepSeekChatModelId } from '../configure/models.js';
9
10
  export async function detectAll(home = homeDir()) {
10
11
  const ctx = { home, platform: process.platform, env: process.env };
11
12
  return Promise.all(clients.map(c => c.detect(ctx)));
@@ -44,13 +45,26 @@ export function clientAllowedForModels(clientId, models) {
44
45
  return false;
45
46
  // These clients use either a native family-specific adapter or the gateway's
46
47
  // normalized OpenAI-compatible Chat Completions route, which supports both
47
- // authenticated model families. Codex and TRAE remain OpenAI-only.
48
- const crossProtocolClients = new Set(['claude', 'cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
48
+ // authenticated model families. Codex remains Responses-only; TRAE uses
49
+ // OpenAI-compatible Chat Completions.
50
+ const crossProtocolClients = new Set(['cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
51
+ if (clientId === 'continue')
52
+ return models.some(model => isDeepSeekChatModelId(model.id));
53
+ if (clientId === 'claude')
54
+ return models.some(isAnthropicModel);
55
+ if (clientId === 'codex')
56
+ return models.some(model => !isAnthropicModel(model) && !isDeepSeekChatModelId(model.id));
57
+ if (clientId === 'hermes' && models.every(model => isDeepSeekChatModelId(model.id)))
58
+ return false;
59
+ if (clientId === 'roo-code' && models.every(model => isDeepSeekChatModelId(model.id)))
60
+ return false;
49
61
  if (crossProtocolClients.has(clientId))
50
62
  return true;
51
63
  return models.some(model => !isAnthropicModel(model));
52
64
  }
53
65
  function scopeHint(scope) {
66
+ if (scope === 'deepseek-v4-flash')
67
+ return 'DeepSeek V4 Flash only';
54
68
  return scope === 'both' ? 'OpenAI + Claude' : scope === 'anthropic' ? 'Claude only' : 'OpenAI only';
55
69
  }
56
70
  export async function configureCommand(opts = {}) {
@@ -1,7 +1,7 @@
1
1
  import { type ChatCompletionOptions, type ChatCompletionResult, type ChatMessage, type ModelInfo } from '../utils/api.js';
2
2
  import { loadApiKey } from '../utils/keyStore.js';
3
3
  import { validateApiKey } from '../utils/api.js';
4
- import type { ClientDefinition } from '../types/client.js';
4
+ import type { ClientDefinition, ProviderScope } from '../types/client.js';
5
5
  export declare const SCALEMAX_HELPER_REFUSAL: string;
6
6
  export declare const SCALEMAX_HELPER_SYSTEM_PROMPT: string;
7
7
  export declare const SCALEMAX_HELPER_INVALID_PROVIDER_RESPONSE: string;
@@ -14,7 +14,7 @@ export interface HelperConnection {
14
14
  baseUrl: string;
15
15
  model: string;
16
16
  platform?: NodeJS.Platform;
17
- providerScope?: 'openai' | 'anthropic' | 'both';
17
+ providerScope?: ProviderScope;
18
18
  keyMode?: string;
19
19
  availableModels?: string[];
20
20
  modelCatalog?: ModelInfo[];
@@ -10,6 +10,7 @@ import { loadApiKey } from '../utils/keyStore.js';
10
10
  import { homeDir } from '../utils/paths.js';
11
11
  import { validateApiKey } from '../utils/api.js';
12
12
  import { commandExists } from '../utils/exec.js';
13
+ import { installClaudeGithubTools } from '../configure/claude-tools.js';
13
14
  export const SCALEMAX_HELPER_REFUSAL = `I am the official ScaleMax Helper.
14
15
 
15
16
  I can only assist with installing, configuring, repairing, verifying, and troubleshooting ScaleMax.
@@ -542,6 +543,7 @@ const SCALEMAX_TOPIC_PATTERN = /\b(?:scalemax|api\s*key|stored\s+key|customer\s+
542
543
  const TROUBLESHOOTING_PATTERN = /\b(?:error|failed?|failure|broken|not\s+working|doesn['’]?t\s+work|cannot|can['’]?t|timeout|invalid|unauthorized|forbidden|help)\b/i;
543
544
  const GREETING_PATTERN = /^(?:hi|hello|hey|help|what can you do)[!.?\s]*$/i;
544
545
  const CLAUDE_DESKTOP_PATTERN = /\bclaude\s+desktop\b/i;
546
+ const CLAUDE_GITHUB_TOOLS_PATTERN = /\b(?:github|skills?|mcp|tools?|download)\b/i;
545
547
  const CLAUDE_DESKTOP_CONFIGURATION_PATTERN = /\b(?:configur(?:e|ation)|setup|set\s*up|gateway|base\s*url|api\s*key|connect(?:ion)?|third[- ]party|inference|field|install|verify)\b/i;
546
548
  const CODEX_CLI_PATTERN = /\bcodex(?:\s+cli)?\b/i;
547
549
  const CLIENT_CONFIGURATION_PATTERN = /\b(?:configur(?:e|ation)|setup|set\s*up|base\s*url|api\s*key|connect(?:ion)?|provider|install|repair|verify|update)\b/i;
@@ -758,7 +760,8 @@ For automatic configuration, exit with \`/quit\`, run \`scalemax update\`, then
758
760
  return reply;
759
761
  }
760
762
  const HELPER_CLIENT_PATTERNS = [
761
- { id: 'roo-code', pattern: /\b(?:roo(?:\s+code)?|roo\s+(?:extension|plugin)|vscode\s+roo|vs\s+code\s+roo|vs\s*code\s+extension|visual\s+studio\s+code\s+extension)\b/i },
763
+ { id: 'continue', pattern: /\b(?:continue(?:\.dev)?|vscode\s+continue|vs\s+code\s+continue)\b/i },
764
+ { id: 'roo-code', pattern: /\b(?:roo(?:\s+code)?|roo\s+(?:extension|plugin)|vscode\s+roo|vs\s+code\s+roo)\b/i },
762
765
  { id: 'openclaw', pattern: /\bopen\s*claw\b/i },
763
766
  { id: 'opencode', pattern: /\bopen\s*code\b/i },
764
767
  { id: 'cherry-studio', pattern: /\bcherry\s*studio\b/i },
@@ -770,6 +773,10 @@ const HELPER_CLIENT_PATTERNS = [
770
773
  { id: 'claude', pattern: /\bclaude(?:\s+code|\s+cli)?\b/i }
771
774
  ];
772
775
  function helperActionKind(question) {
776
+ if (CLAUDE_GITHUB_TOOLS_PATTERN.test(question) && /\b(?:cannot|can['’]?t|unable|not\s+(?:able|being\s+able)|doesn['’]?t\s+have|no\s+tools?)\b/i.test(question))
777
+ return 'repair';
778
+ if (CLAUDE_GITHUB_TOOLS_PATTERN.test(question) && /\b(?:enable|grant|provide|add)\b/i.test(question))
779
+ return 'configure';
773
780
  if (/\b(?:repair|fix|troubleshoot|diagnose|not\s+working|doesn['’]?t\s+work|broken|failed?|invalid|timeout|rate\s*limit(?:ed)?)\b/i.test(question))
774
781
  return 'repair';
775
782
  if (HELPER_MUTATION_PATTERN.test(question))
@@ -779,6 +786,8 @@ function helperActionKind(question) {
779
786
  return undefined;
780
787
  }
781
788
  function helperActionTarget(question) {
789
+ if (/\bclaude\b/i.test(question) && CLAUDE_GITHUB_TOOLS_PATTERN.test(question))
790
+ return 'claude-tools';
782
791
  if (CLAUDE_DESKTOP_PATTERN.test(question))
783
792
  return undefined;
784
793
  if (ALL_CLIENTS_PATTERN.test(question) || /\b(?:all|every)\b.{0,30}\bproviders?\b/i.test(question))
@@ -791,6 +800,8 @@ function helperActionTarget(question) {
791
800
  return undefined;
792
801
  }
793
802
  function requestedProviderScope(question, target) {
803
+ if (/\bdeepseek(?:[-\s]+v4[-\s]+flash)?\b/i.test(question))
804
+ return 'deepseek-v4-flash';
794
805
  if (/\b(?:both|openai\s*\+\s*claude|claude\s*\+\s*openai)\b/i.test(question))
795
806
  return 'both';
796
807
  if (/\b(?:claude|anthropic)\b/i.test(question))
@@ -803,17 +814,32 @@ function actionModels(connection, requestedScope) {
803
814
  const catalog = connection.modelCatalog?.length
804
815
  ? connection.modelCatalog
805
816
  : (connection.availableModels || []).map(id => ({ id }));
806
- if (!requestedScope || requestedScope === 'both')
817
+ if (!requestedScope)
807
818
  return catalog;
808
- return catalog.filter(model => requestedScope === 'anthropic' ? isAnthropicModel(model) : !isAnthropicModel(model));
819
+ if (requestedScope === 'deepseek-v4-flash') {
820
+ return catalog.filter(model => model.id.trim().toLowerCase() === 'deepseek-v4-flash');
821
+ }
822
+ if (requestedScope === 'both') {
823
+ return catalog.filter(model => model.id.trim().toLowerCase() !== 'deepseek-v4-flash');
824
+ }
825
+ return catalog.filter(model => requestedScope === 'anthropic'
826
+ ? isAnthropicModel(model)
827
+ : !isAnthropicModel(model) && model.id.trim().toLowerCase() !== 'deepseek-v4-flash');
809
828
  }
810
829
  function helperClientAllowedForModels(clientId, models) {
811
830
  if (!models.length)
812
831
  return false;
813
- const crossProtocolClients = new Set(['claude', 'cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
832
+ const crossProtocolClients = new Set(['cursor', 'roo-code', 'opencode', 'openclaw', 'hermes', 'cherry-studio', 'api-code']);
833
+ const onlyDeepSeek = models.every(model => model.id.trim().toLowerCase() === 'deepseek-v4-flash');
834
+ if (clientId === 'continue')
835
+ return onlyDeepSeek;
836
+ if (clientId === 'claude')
837
+ return models.some(isAnthropicModel);
838
+ if ((clientId === 'codex' || clientId === 'hermes' || clientId === 'roo-code') && onlyDeepSeek)
839
+ return false;
814
840
  if (crossProtocolClients.has(clientId))
815
841
  return true;
816
- return models.some(model => !isAnthropicModel(model));
842
+ return models.some(model => !isAnthropicModel(model) && model.id.trim().toLowerCase() !== 'deepseek-v4-flash');
817
843
  }
818
844
  function hasRooCodeExtension(paths) {
819
845
  return paths.some(candidate => /^rooveterinaryinc\.roo-cline-/i.test(path.basename(candidate)));
@@ -897,9 +923,9 @@ async function installRooCodeExtension(ctx, client, write) {
897
923
  async function runOneHelperClientAction(kind, client, connection, home, requestedScope, write) {
898
924
  const models = actionModels(connection, requestedScope);
899
925
  if (!models.length)
900
- throw new Error(`The authenticated key does not expose ${requestedScope === 'anthropic' ? 'Claude' : requestedScope === 'openai' ? 'OpenAI' : 'compatible'} models for ${client.name}. No configuration was changed.`);
901
- if (client.id === 'codex' && models.every(isAnthropicModel))
902
- throw new Error('Codex requires an OpenAI-compatible model, but this key exposes only Claude models. No configuration was changed.');
926
+ throw new Error(`The authenticated key does not expose ${requestedScope === 'anthropic' ? 'Claude' : requestedScope === 'openai' ? 'OpenAI' : requestedScope === 'deepseek-v4-flash' ? 'DeepSeek V4 Flash' : 'compatible'} models for ${client.name}. No configuration was changed.`);
927
+ if (client.id === 'codex' && models.every(model => isAnthropicModel(model) || model.id.trim().toLowerCase() === 'deepseek-v4-flash'))
928
+ throw new Error('Codex requires a Responses-capable model, but this key exposes only Claude or Chat-Completions-only models. No configuration was changed.');
903
929
  if (client.id === 'cursor' && connection.platform !== 'darwin')
904
930
  throw new Error(`Automatic Cursor configuration is currently verified on macOS only. ${helperPlatformName(connection.platform)} was not modified.`);
905
931
  const ctx = {
@@ -970,7 +996,9 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
970
996
  throw new Error(`${client.name} configuration was written but could not be detected afterward.`);
971
997
  write(`✓ ${client.name} configuration detected after write.`);
972
998
  }
973
- const activation = client.id === 'roo-code' ? ' Reload VS Code once so Roo Code imports the verified ScaleMax profile.' : '';
999
+ const activation = client.id === 'roo-code'
1000
+ ? ' Reload VS Code once so Roo Code imports the verified ScaleMax profile.'
1001
+ : client.id === 'continue' ? ' Reload VS Code once so Continue loads the verified ScaleMax profile.' : '';
974
1002
  return `✅ Successfully completed: ${client.name} is configured and locally verified.${activation}`;
975
1003
  }
976
1004
  /** Execute only narrowly allowlisted ScaleMax client actions; never model-generated shell commands. */
@@ -982,6 +1010,29 @@ export async function executeHelperAction(question, connection, home, clientCata
982
1010
  const target = helperActionTarget(cleanQuestion);
983
1011
  if (!kind || !target)
984
1012
  return undefined;
1013
+ if (target === 'claude-tools') {
1014
+ const toolTarget = CLAUDE_DESKTOP_PATTERN.test(cleanQuestion)
1015
+ ? 'desktop'
1016
+ : /\bclaude\s+(?:code|cli|terminal)\b/i.test(cleanQuestion) ? 'code' : 'both';
1017
+ write(`✓ Stored ${helperBillingMode(connection)} key is authenticated; secrets remain hidden.`);
1018
+ write(`→ ${kind === 'verify' ? 'Verifying and reconciling' : 'Installing'} bounded public-GitHub tools for ${toolTarget === 'both' ? 'Claude Desktop/Cowork and Claude Code' : toolTarget === 'desktop' ? 'Claude Desktop/Cowork' : 'Claude Code'}…`);
1019
+ const tools = await installClaudeGithubTools(toolTarget, home, connection.platform || process.platform, process.env);
1020
+ const completed = [];
1021
+ if (tools.desktop) {
1022
+ completed.push(`Claude Desktop MCP exposes github_fetch, github_tree, and github_install_skill; installs are confined to ~/Claude/skills`);
1023
+ write('✓ Claude Desktop GitHub MCP process and tool catalog verified.');
1024
+ }
1025
+ if (tools.code?.installed) {
1026
+ completed.push(`Claude Code ${tools.code.message}`);
1027
+ write(`✓ Claude Code ${tools.code.message}.`);
1028
+ }
1029
+ else if (tools.code) {
1030
+ write(`! ${tools.code.message}`);
1031
+ }
1032
+ if (!completed.length)
1033
+ throw new Error('Claude Desktop and Claude Code were not detected, so no GitHub tools were installed.');
1034
+ return `✅ Successfully completed: ${completed.join('; ')}. Restart Claude Desktop before using the new MCP tools. No unrestricted shell or filesystem access was granted.`;
1035
+ }
985
1036
  const scope = requestedProviderScope(cleanQuestion, target);
986
1037
  const scopedModels = actionModels(connection, scope);
987
1038
  const selected = target === 'all'