scalemax 0.1.24 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/assets/claude-github-tools.mjs +319 -0
- package/dist/commands/claude-tools.d.ts +1 -0
- package/dist/commands/claude-tools.js +16 -0
- package/dist/commands/helper-bot.js +31 -0
- package/dist/commands/helper-docs.js +6 -0
- package/dist/configure/claude-tools.d.ts +34 -0
- package/dist/configure/claude-tools.js +283 -0
- package/dist/configure/claude.js +82 -5
- package/dist/configure/models.js +0 -1
- package/dist/configure/templates.js +1 -1
- package/dist/configure/trae-solo.js +1 -1
- package/dist/index.js +6 -1
- package/package.json +1 -1
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,6 +122,9 @@ 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
|
|
|
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
|
+
|
|
124
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 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
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.
|
|
@@ -148,7 +152,7 @@ ScaleMax configures the official Codex custom-provider interface in `~/.codex/co
|
|
|
148
152
|
|
|
149
153
|
### Claude Code
|
|
150
154
|
|
|
151
|
-
ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`, gateway model discovery, and a cached `apiKeyHelper` that reads a private `~/.claude/scalemax.key` file. The raw key is not stored in the settings JSON. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings, selects a live main model from the authenticated catalog (preferring `claude-opus-4-8`), pins authenticated Haiku for lightweight background work when available, and sets `CLAUDE_CODE_DISABLE_THINKING=1` because third-party Anthropic-compatible providers do not support Claude Code's proprietary extended-thinking control envelope. It verifies a one-token Messages request and, if verification fails, removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the gateway.
|
|
155
|
+
ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`, gateway model discovery, and a cached `apiKeyHelper` that reads a private `~/.claude/scalemax.key` file. The raw key is not stored in the settings JSON. Because Claude Code's gateway discovery only accepts IDs beginning with `claude` or `anthropic`, a catalog that includes Fable also receives a managed `ANTHROPIC_CUSTOM_MODEL_OPTION=fable-5` entry (with a friendly name and description), making `/model fable-5` selectable; any prior custom picker values are restored when ScaleMax configuration is removed. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings, selects a live main model from the authenticated catalog (preferring `claude-opus-4-8`), pins authenticated Haiku for lightweight background work when available, and sets `CLAUDE_CODE_DISABLE_THINKING=1` because third-party Anthropic-compatible providers do not support Claude Code's proprietary extended-thinking control envelope. It verifies a one-token Messages request and, if verification fails, removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the gateway.
|
|
152
156
|
|
|
153
157
|
### OpenCode
|
|
154
158
|
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -770,6 +772,10 @@ const HELPER_CLIENT_PATTERNS = [
|
|
|
770
772
|
{ id: 'claude', pattern: /\bclaude(?:\s+code|\s+cli)?\b/i }
|
|
771
773
|
];
|
|
772
774
|
function helperActionKind(question) {
|
|
775
|
+
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))
|
|
776
|
+
return 'repair';
|
|
777
|
+
if (CLAUDE_GITHUB_TOOLS_PATTERN.test(question) && /\b(?:enable|grant|provide|add)\b/i.test(question))
|
|
778
|
+
return 'configure';
|
|
773
779
|
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
780
|
return 'repair';
|
|
775
781
|
if (HELPER_MUTATION_PATTERN.test(question))
|
|
@@ -779,6 +785,8 @@ function helperActionKind(question) {
|
|
|
779
785
|
return undefined;
|
|
780
786
|
}
|
|
781
787
|
function helperActionTarget(question) {
|
|
788
|
+
if (/\bclaude\b/i.test(question) && CLAUDE_GITHUB_TOOLS_PATTERN.test(question))
|
|
789
|
+
return 'claude-tools';
|
|
782
790
|
if (CLAUDE_DESKTOP_PATTERN.test(question))
|
|
783
791
|
return undefined;
|
|
784
792
|
if (ALL_CLIENTS_PATTERN.test(question) || /\b(?:all|every)\b.{0,30}\bproviders?\b/i.test(question))
|
|
@@ -982,6 +990,29 @@ export async function executeHelperAction(question, connection, home, clientCata
|
|
|
982
990
|
const target = helperActionTarget(cleanQuestion);
|
|
983
991
|
if (!kind || !target)
|
|
984
992
|
return undefined;
|
|
993
|
+
if (target === 'claude-tools') {
|
|
994
|
+
const toolTarget = CLAUDE_DESKTOP_PATTERN.test(cleanQuestion)
|
|
995
|
+
? 'desktop'
|
|
996
|
+
: /\bclaude\s+(?:code|cli|terminal)\b/i.test(cleanQuestion) ? 'code' : 'both';
|
|
997
|
+
write(`✓ Stored ${helperBillingMode(connection)} key is authenticated; secrets remain hidden.`);
|
|
998
|
+
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'}…`);
|
|
999
|
+
const tools = await installClaudeGithubTools(toolTarget, home, connection.platform || process.platform, process.env);
|
|
1000
|
+
const completed = [];
|
|
1001
|
+
if (tools.desktop) {
|
|
1002
|
+
completed.push(`Claude Desktop MCP exposes github_fetch, github_tree, and github_install_skill; installs are confined to ~/Claude/skills`);
|
|
1003
|
+
write('✓ Claude Desktop GitHub MCP process and tool catalog verified.');
|
|
1004
|
+
}
|
|
1005
|
+
if (tools.code?.installed) {
|
|
1006
|
+
completed.push(`Claude Code ${tools.code.message}`);
|
|
1007
|
+
write(`✓ Claude Code ${tools.code.message}.`);
|
|
1008
|
+
}
|
|
1009
|
+
else if (tools.code) {
|
|
1010
|
+
write(`! ${tools.code.message}`);
|
|
1011
|
+
}
|
|
1012
|
+
if (!completed.length)
|
|
1013
|
+
throw new Error('Claude Desktop and Claude Code were not detected, so no GitHub tools were installed.');
|
|
1014
|
+
return `✅ Successfully completed: ${completed.join('; ')}. Restart Claude Desktop before using the new MCP tools. No unrestricted shell or filesystem access was granted.`;
|
|
1015
|
+
}
|
|
985
1016
|
const scope = requestedProviderScope(cleanQuestion, target);
|
|
986
1017
|
const scopedModels = actionModels(connection, scope);
|
|
987
1018
|
const selected = target === 'all'
|
|
@@ -29,6 +29,12 @@ export const HELPER_DOC_SECTIONS = [
|
|
|
29
29
|
keywords: ['codex', 'cursor', 'claude code', 'opencode', 'openclaw', 'hermes', 'cherry studio', 'update', 'doctor'],
|
|
30
30
|
content: `scalemax update detects supported clients and configures them from the stored key. scalemax doctor validates the stored key and performs a minimal ScaleMax request. Supported clients include Codex CLI, Cursor, Claude Code, OpenCode, OpenClaw, Hermes, Cherry Studio, Roo Code, and API Code. Recognized helper-bot setup and verification requests are executed by the CLI controller before any model fallback is used.`
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
id: 'claude-github-tools',
|
|
34
|
+
title: 'Claude GitHub skills and tools',
|
|
35
|
+
keywords: ['claude desktop', 'claude code', 'github', 'skill', 'skills', 'tool', 'tools', 'mcp', 'download'],
|
|
36
|
+
content: `Run scalemax claude-tools, or ask helper-bot to install GitHub skill tools for Claude. Claude Code receives Anthropic's official example-skills plugin and bounded public-GitHub read permissions. Claude Desktop and Cowork receive the ScaleMax GitHub MCP with github_fetch, github_tree, and github_install_skill. That MCP accepts only public GitHub HTTPS hosts, refuses overwrite, and installs only beneath ~/Claude/skills; it exposes no general shell or unrestricted filesystem. Restart Claude Desktop after setup and use Cowork or Code because the normal Chat tab does not expose local tools.`
|
|
37
|
+
},
|
|
32
38
|
{
|
|
33
39
|
id: 'troubleshooting',
|
|
34
40
|
title: 'Common troubleshooting',
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface CommandResult {
|
|
2
|
+
exitCode: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
all: string;
|
|
6
|
+
}
|
|
7
|
+
export type ClaudeToolsRunner = (command: string, args: string[], options: {
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
input?: string;
|
|
10
|
+
}) => Promise<CommandResult>;
|
|
11
|
+
export interface ClaudeDesktopToolsResult {
|
|
12
|
+
scriptPath: string;
|
|
13
|
+
configPaths: string[];
|
|
14
|
+
managedConfigPaths: string[];
|
|
15
|
+
changed: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ClaudeCodeToolsResult {
|
|
18
|
+
installed: boolean;
|
|
19
|
+
changed: boolean;
|
|
20
|
+
settingsPath?: string;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ClaudeToolsResult {
|
|
24
|
+
desktop?: ClaudeDesktopToolsResult;
|
|
25
|
+
code?: ClaudeCodeToolsResult;
|
|
26
|
+
}
|
|
27
|
+
export declare function claudeDesktopConfigPath(root: string): string;
|
|
28
|
+
export declare function claudeGithubToolPath(home: string): string;
|
|
29
|
+
/** Install and verify the bounded public-GitHub MCP server for Claude Desktop/Cowork. */
|
|
30
|
+
export declare function installClaudeDesktopGithubTools(home: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, runner?: ClaudeToolsRunner, nodePath?: string, assetPath?: string): Promise<ClaudeDesktopToolsResult>;
|
|
31
|
+
/** Install Anthropic's official skills plugin and safe GitHub-read permissions for Claude Code. */
|
|
32
|
+
export declare function installClaudeCodeGithubSkills(home: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, runner?: ClaudeToolsRunner): Promise<ClaudeCodeToolsResult>;
|
|
33
|
+
export declare function installClaudeGithubTools(target: 'desktop' | 'code' | 'both', home: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, runner?: ClaudeToolsRunner): Promise<ClaudeToolsResult>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { execa } from 'execa';
|
|
5
|
+
import { atomicWrite, backupFile } from '../utils/fs.js';
|
|
6
|
+
import { configRoot } from '../utils/paths.js';
|
|
7
|
+
const MCP_SERVER_NAME = 'scalemax-github';
|
|
8
|
+
const MCP_ASSET = fileURLToPath(new URL('../assets/claude-github-tools.mjs', import.meta.url));
|
|
9
|
+
const CLAUDE_SKILLS_REPO = 'anthropics/skills';
|
|
10
|
+
const CLAUDE_SKILLS_MARKETPLACE = 'anthropic-agent-skills';
|
|
11
|
+
const CLAUDE_SKILLS_PLUGIN = 'example-skills@anthropic-agent-skills';
|
|
12
|
+
const CODE_GITHUB_PERMISSIONS = [
|
|
13
|
+
'Bash(git ls-remote https://github.com/*)',
|
|
14
|
+
'WebFetch(domain:github.com)',
|
|
15
|
+
'WebFetch(domain:raw.githubusercontent.com)',
|
|
16
|
+
];
|
|
17
|
+
const defaultRunner = async (command, args, options) => {
|
|
18
|
+
const result = await execa(command, args, {
|
|
19
|
+
env: options.env,
|
|
20
|
+
input: options.input,
|
|
21
|
+
all: true,
|
|
22
|
+
reject: false,
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
exitCode: result.exitCode ?? 1,
|
|
26
|
+
stdout: result.stdout || '',
|
|
27
|
+
stderr: result.stderr || '',
|
|
28
|
+
all: result.all || '',
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
function asRecord(value) {
|
|
32
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
33
|
+
}
|
|
34
|
+
function desktopConfigRoots(home, platform, env) {
|
|
35
|
+
const root = configRoot(home, platform, env);
|
|
36
|
+
return [path.join(root, 'Claude'), path.join(root, 'Claude-3p')];
|
|
37
|
+
}
|
|
38
|
+
export function claudeDesktopConfigPath(root) {
|
|
39
|
+
return path.join(root, 'claude_desktop_config.json');
|
|
40
|
+
}
|
|
41
|
+
export function claudeGithubToolPath(home) {
|
|
42
|
+
return path.join(home, '.scalemax', 'claude-github-tools.mjs');
|
|
43
|
+
}
|
|
44
|
+
async function existingDesktopRoots(home, platform, env) {
|
|
45
|
+
const roots = desktopConfigRoots(home, platform, env);
|
|
46
|
+
const existing = [];
|
|
47
|
+
for (const root of roots)
|
|
48
|
+
if (await fs.pathExists(root))
|
|
49
|
+
existing.push(root);
|
|
50
|
+
// A fresh install should use Claude's standard documented config location.
|
|
51
|
+
return existing.length ? existing : [roots[0]];
|
|
52
|
+
}
|
|
53
|
+
async function parseJsonObject(file) {
|
|
54
|
+
if (!(await fs.pathExists(file)))
|
|
55
|
+
return {};
|
|
56
|
+
let value;
|
|
57
|
+
try {
|
|
58
|
+
value = await fs.readJson(file);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
throw new Error(`Refusing to overwrite invalid Claude JSON at ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
64
|
+
throw new Error(`Refusing to overwrite non-object Claude JSON at ${file}.`);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function isExpectedServer(value, nodePath, scriptPath) {
|
|
68
|
+
const server = asRecord(value);
|
|
69
|
+
return server.command === nodePath && Array.isArray(server.args) && server.args.length === 1 && server.args[0] === scriptPath;
|
|
70
|
+
}
|
|
71
|
+
function isUpgradeSafeServer(value, scriptPath) {
|
|
72
|
+
const server = asRecord(value);
|
|
73
|
+
if (!Array.isArray(server.args) || server.args.length !== 1 || server.args[0] !== scriptPath || typeof server.command !== 'string')
|
|
74
|
+
return false;
|
|
75
|
+
return /^node(?:\.exe)?$/i.test(path.basename(server.command));
|
|
76
|
+
}
|
|
77
|
+
async function stableNodeCommand(nodePath, env, platform) {
|
|
78
|
+
const delimiter = platform === 'win32' ? ';' : ':';
|
|
79
|
+
const executable = platform === 'win32' ? 'node.exe' : 'node';
|
|
80
|
+
const expected = await fs.realpath(nodePath).catch(() => nodePath);
|
|
81
|
+
for (const directory of (env.PATH || env.Path || env.path || '').split(delimiter).filter(Boolean)) {
|
|
82
|
+
const candidate = path.join(directory, executable);
|
|
83
|
+
const resolved = await fs.realpath(candidate).catch(() => undefined);
|
|
84
|
+
if (resolved === expected)
|
|
85
|
+
return candidate;
|
|
86
|
+
}
|
|
87
|
+
return nodePath;
|
|
88
|
+
}
|
|
89
|
+
function isScaleMaxManagedGateway(value) {
|
|
90
|
+
if (value.inferenceProvider !== 'gateway' || typeof value.inferenceGatewayBaseUrl !== 'string')
|
|
91
|
+
return false;
|
|
92
|
+
try {
|
|
93
|
+
const hostname = new URL(value.inferenceGatewayBaseUrl).hostname.toLowerCase();
|
|
94
|
+
return hostname === 'api.scalemax.pro' || hostname.endsWith('.scalemax.pro');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function managedThirdPartyConfigs(roots) {
|
|
101
|
+
const files = [];
|
|
102
|
+
for (const root of roots) {
|
|
103
|
+
const library = path.join(root, 'configLibrary');
|
|
104
|
+
if (!(await fs.pathExists(library)))
|
|
105
|
+
continue;
|
|
106
|
+
for (const name of await fs.readdir(library)) {
|
|
107
|
+
if (/^[A-Za-z0-9._-]+\.json$/i.test(name))
|
|
108
|
+
files.push(path.join(library, name));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
113
|
+
async function verifyMcpProcess(scriptPath, nodePath, env, runner) {
|
|
114
|
+
const input = [
|
|
115
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'scalemax-cli', version: '1' } } }),
|
|
116
|
+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
|
|
117
|
+
'',
|
|
118
|
+
].join('\n');
|
|
119
|
+
const probe = await runner(nodePath, [scriptPath], { env, input });
|
|
120
|
+
if (probe.exitCode !== 0)
|
|
121
|
+
throw new Error(`Claude GitHub MCP verification failed: ${probe.all.trim() || `Node exited with ${probe.exitCode}`}`);
|
|
122
|
+
const responses = probe.stdout.split(/\r?\n/).filter(Boolean).map(line => {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(line);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}).filter(Boolean);
|
|
130
|
+
const names = responses.find(response => response.id === 2)?.result?.tools?.map((tool) => tool.name) || [];
|
|
131
|
+
for (const expected of ['github_fetch', 'github_tree', 'github_install_skill']) {
|
|
132
|
+
if (!names.includes(expected))
|
|
133
|
+
throw new Error(`Claude GitHub MCP verification did not expose ${expected}.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Install and verify the bounded public-GitHub MCP server for Claude Desktop/Cowork. */
|
|
137
|
+
export async function installClaudeDesktopGithubTools(home, platform = process.platform, env = process.env, runner = defaultRunner, nodePath = process.execPath, assetPath = MCP_ASSET) {
|
|
138
|
+
const roots = await existingDesktopRoots(home, platform, env);
|
|
139
|
+
const configPaths = roots.map(claudeDesktopConfigPath);
|
|
140
|
+
const scriptPath = claudeGithubToolPath(home);
|
|
141
|
+
const nodeCommand = await stableNodeCommand(nodePath, env, platform);
|
|
142
|
+
const source = await fs.readFile(assetPath, 'utf8');
|
|
143
|
+
const configs = [];
|
|
144
|
+
for (const file of configPaths) {
|
|
145
|
+
const current = await parseJsonObject(file);
|
|
146
|
+
const servers = asRecord(current.mcpServers);
|
|
147
|
+
if (servers[MCP_SERVER_NAME] !== undefined && !isExpectedServer(servers[MCP_SERVER_NAME], nodeCommand, scriptPath) && !isUpgradeSafeServer(servers[MCP_SERVER_NAME], scriptPath)) {
|
|
148
|
+
throw new Error(`Refusing to replace an unmanaged ${MCP_SERVER_NAME} MCP server in ${file}.`);
|
|
149
|
+
}
|
|
150
|
+
configs.push({
|
|
151
|
+
file,
|
|
152
|
+
current,
|
|
153
|
+
next: {
|
|
154
|
+
...current,
|
|
155
|
+
mcpServers: {
|
|
156
|
+
...servers,
|
|
157
|
+
[MCP_SERVER_NAME]: { command: nodeCommand, args: [scriptPath] },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const managedConfigs = [];
|
|
163
|
+
for (const file of await managedThirdPartyConfigs(roots)) {
|
|
164
|
+
const current = await parseJsonObject(file);
|
|
165
|
+
if (!isScaleMaxManagedGateway(current))
|
|
166
|
+
continue;
|
|
167
|
+
managedConfigs.push({ file, current, next: { ...current, isLocalDevMcpEnabled: true, toolSearchEnabled: true } });
|
|
168
|
+
}
|
|
169
|
+
const oldScript = await fs.readFile(scriptPath, 'utf8').catch(() => undefined);
|
|
170
|
+
const written = [];
|
|
171
|
+
let changed = oldScript !== source;
|
|
172
|
+
try {
|
|
173
|
+
await atomicWrite(scriptPath, source, 0o700, platform);
|
|
174
|
+
await fs.chmod(scriptPath, 0o700).catch(() => undefined);
|
|
175
|
+
// Verify the protocol with the caller's normal environment. Claude Desktop
|
|
176
|
+
// launches the server with the signed-in user's OS home; forcing HOME here
|
|
177
|
+
// can change platform DNS/proxy behavior and is unnecessary for tools/list.
|
|
178
|
+
await verifyMcpProcess(scriptPath, nodeCommand, env, runner);
|
|
179
|
+
for (const item of [...configs, ...managedConfigs]) {
|
|
180
|
+
const previous = await fs.readFile(item.file, 'utf8').catch(() => undefined);
|
|
181
|
+
const next = JSON.stringify(item.next, null, 2) + '\n';
|
|
182
|
+
if (previous === next)
|
|
183
|
+
continue;
|
|
184
|
+
changed = true;
|
|
185
|
+
await backupFile(item.file, home);
|
|
186
|
+
await atomicWrite(item.file, next, 0o600, platform);
|
|
187
|
+
written.push({ file: item.file, previous });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
for (const item of written.reverse()) {
|
|
192
|
+
if (item.previous === undefined)
|
|
193
|
+
await fs.remove(item.file).catch(() => undefined);
|
|
194
|
+
else
|
|
195
|
+
await atomicWrite(item.file, item.previous, 0o600, platform).catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
if (oldScript === undefined)
|
|
198
|
+
await fs.remove(scriptPath).catch(() => undefined);
|
|
199
|
+
else
|
|
200
|
+
await atomicWrite(scriptPath, oldScript, 0o700, platform).catch(() => undefined);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
scriptPath,
|
|
205
|
+
configPaths,
|
|
206
|
+
managedConfigPaths: managedConfigs.map(item => item.file),
|
|
207
|
+
changed,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function parseJsonArray(text) {
|
|
211
|
+
try {
|
|
212
|
+
const value = JSON.parse(text);
|
|
213
|
+
return Array.isArray(value) ? value : [];
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function writeClaudeCodeGithubPermissions(home, platform) {
|
|
220
|
+
const file = path.join(home, '.claude', 'settings.local.json');
|
|
221
|
+
const current = await parseJsonObject(file);
|
|
222
|
+
const permissions = asRecord(current.permissions);
|
|
223
|
+
const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
|
|
224
|
+
const nextAllow = [...allow];
|
|
225
|
+
for (const permission of CODE_GITHUB_PERMISSIONS)
|
|
226
|
+
if (!nextAllow.includes(permission))
|
|
227
|
+
nextAllow.push(permission);
|
|
228
|
+
const next = { ...current, permissions: { ...permissions, allow: nextAllow } };
|
|
229
|
+
const content = JSON.stringify(next, null, 2) + '\n';
|
|
230
|
+
const existing = await fs.readFile(file, 'utf8').catch(() => undefined);
|
|
231
|
+
if (existing === content)
|
|
232
|
+
return { file, changed: false };
|
|
233
|
+
await backupFile(file, home);
|
|
234
|
+
await atomicWrite(file, content, 0o600, platform);
|
|
235
|
+
return { file, changed: true };
|
|
236
|
+
}
|
|
237
|
+
/** Install Anthropic's official skills plugin and safe GitHub-read permissions for Claude Code. */
|
|
238
|
+
export async function installClaudeCodeGithubSkills(home, platform = process.platform, env = process.env, runner = defaultRunner) {
|
|
239
|
+
const commandEnv = { ...env, HOME: home, USERPROFILE: home };
|
|
240
|
+
const version = await runner('claude', ['--version'], { env: commandEnv });
|
|
241
|
+
if (version.exitCode !== 0)
|
|
242
|
+
return { installed: false, changed: false, message: 'Claude Code was not detected; Desktop tools can still be used.' };
|
|
243
|
+
const permission = await writeClaudeCodeGithubPermissions(home, platform);
|
|
244
|
+
const marketplaces = await runner('claude', ['plugin', 'marketplace', 'list', '--json'], { env: commandEnv });
|
|
245
|
+
if (marketplaces.exitCode !== 0)
|
|
246
|
+
throw new Error(`Unable to inspect Claude plugin marketplaces: ${marketplaces.all.trim() || 'Claude returned an error.'}`);
|
|
247
|
+
const marketplaceInstalled = parseJsonArray(marketplaces.stdout).some(item => item?.repo === CLAUDE_SKILLS_REPO || item?.name === CLAUDE_SKILLS_MARKETPLACE);
|
|
248
|
+
let changed = permission.changed;
|
|
249
|
+
if (!marketplaceInstalled) {
|
|
250
|
+
const add = await runner('claude', ['plugin', 'marketplace', 'add', CLAUDE_SKILLS_REPO], { env: commandEnv });
|
|
251
|
+
if (add.exitCode !== 0)
|
|
252
|
+
throw new Error(`Unable to add Anthropic's skills marketplace: ${add.all.trim() || 'Claude returned an error.'}`);
|
|
253
|
+
changed = true;
|
|
254
|
+
}
|
|
255
|
+
const before = await runner('claude', ['plugin', 'list', '--json'], { env: commandEnv });
|
|
256
|
+
if (before.exitCode !== 0)
|
|
257
|
+
throw new Error(`Unable to inspect Claude plugins: ${before.all.trim() || 'Claude returned an error.'}`);
|
|
258
|
+
const pluginInstalled = parseJsonArray(before.stdout).some(item => item?.id === CLAUDE_SKILLS_PLUGIN && item?.enabled !== false);
|
|
259
|
+
if (!pluginInstalled) {
|
|
260
|
+
const install = await runner('claude', ['plugin', 'install', CLAUDE_SKILLS_PLUGIN], { env: commandEnv });
|
|
261
|
+
if (install.exitCode !== 0)
|
|
262
|
+
throw new Error(`Unable to install Anthropic's skills plugin: ${install.all.trim() || 'Claude returned an error.'}`);
|
|
263
|
+
changed = true;
|
|
264
|
+
}
|
|
265
|
+
const after = await runner('claude', ['plugin', 'list', '--json'], { env: commandEnv });
|
|
266
|
+
const verified = after.exitCode === 0 && parseJsonArray(after.stdout).some(item => item?.id === CLAUDE_SKILLS_PLUGIN && item?.enabled !== false);
|
|
267
|
+
if (!verified)
|
|
268
|
+
throw new Error(`Claude Code did not report ${CLAUDE_SKILLS_PLUGIN} as enabled after installation.`);
|
|
269
|
+
return {
|
|
270
|
+
installed: true,
|
|
271
|
+
changed,
|
|
272
|
+
settingsPath: permission.file,
|
|
273
|
+
message: `verified ${CLAUDE_SKILLS_PLUGIN} and bounded public-GitHub read permissions`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
export async function installClaudeGithubTools(target, home, platform = process.platform, env = process.env, runner = defaultRunner) {
|
|
277
|
+
const result = {};
|
|
278
|
+
if (target === 'desktop' || target === 'both')
|
|
279
|
+
result.desktop = await installClaudeDesktopGithubTools(home, platform, env, runner);
|
|
280
|
+
if (target === 'code' || target === 'both')
|
|
281
|
+
result.code = await installClaudeCodeGithubSkills(home, platform, env, runner);
|
|
282
|
+
return result;
|
|
283
|
+
}
|
package/dist/configure/claude.js
CHANGED
|
@@ -14,7 +14,30 @@ const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
|
|
|
14
14
|
const fastModelKey = 'ANTHROPIC_DEFAULT_HAIKU_MODEL';
|
|
15
15
|
const helperTtlKey = 'CLAUDE_CODE_API_KEY_HELPER_TTL_MS';
|
|
16
16
|
const disableThinkingKey = 'CLAUDE_CODE_DISABLE_THINKING';
|
|
17
|
-
|
|
17
|
+
// Claude Code's gateway discovery intentionally ignores model IDs that do not
|
|
18
|
+
// start with `claude` or `anthropic`. Fable's public ScaleMax ID is
|
|
19
|
+
// deliberately `fable-5`, so keep it selectable with the supported custom
|
|
20
|
+
// picker entry as well as the normal discovery feed.
|
|
21
|
+
const customModelOptionKey = 'ANTHROPIC_CUSTOM_MODEL_OPTION';
|
|
22
|
+
const customModelOptionNameKey = 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME';
|
|
23
|
+
const customModelOptionDescriptionKey = 'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION';
|
|
24
|
+
const fableModelId = 'fable-5';
|
|
25
|
+
const fableModelName = 'Fable 5';
|
|
26
|
+
const fableModelDescription = 'Fable 5 via ScaleMax';
|
|
27
|
+
const verificationTimeoutMs = 60_000;
|
|
28
|
+
const managedEnvKeys = [
|
|
29
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
30
|
+
'ANTHROPIC_API_KEY',
|
|
31
|
+
'ANTHROPIC_BASE_URL',
|
|
32
|
+
'ANTHROPIC_MODEL',
|
|
33
|
+
customModelOptionKey,
|
|
34
|
+
customModelOptionNameKey,
|
|
35
|
+
customModelOptionDescriptionKey,
|
|
36
|
+
discoveryKey,
|
|
37
|
+
fastModelKey,
|
|
38
|
+
helperTtlKey,
|
|
39
|
+
disableThinkingKey
|
|
40
|
+
];
|
|
18
41
|
function secretPath(home) { return path.join(home, '.claude', 'scalemax.key'); }
|
|
19
42
|
function helperCommand(home, platform) {
|
|
20
43
|
const file = secretPath(home);
|
|
@@ -25,10 +48,17 @@ function helperCommand(home, platform) {
|
|
|
25
48
|
function asRecord(value) {
|
|
26
49
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
27
50
|
}
|
|
51
|
+
function hasFableModel(ctx) {
|
|
52
|
+
return (ctx.models || []).some(model => {
|
|
53
|
+
const id = String(model.id || '').trim().toLowerCase();
|
|
54
|
+
return id === fableModelId || id === 'claude-fable-5';
|
|
55
|
+
});
|
|
56
|
+
}
|
|
28
57
|
export async function configure(ctx) {
|
|
29
58
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
30
59
|
const model = preferredModel(ctx);
|
|
31
60
|
const quick = fastModel(ctx);
|
|
61
|
+
const fable = hasFableModel(ctx);
|
|
32
62
|
const baseUrl = anthropicOrigin(ctx.baseUrl);
|
|
33
63
|
const apiKeyHelper = helperCommand(ctx.home, ctx.platform);
|
|
34
64
|
const secret = await writeClientSecret(secretPath(ctx.home), ctx);
|
|
@@ -51,6 +81,25 @@ export async function configure(ctx) {
|
|
|
51
81
|
[helperTtlKey]: '900000',
|
|
52
82
|
[disableThinkingKey]: '1'
|
|
53
83
|
};
|
|
84
|
+
if (fable) {
|
|
85
|
+
// This is a public model ID, not an upstream/provider alias. Claude
|
|
86
|
+
// Code's custom option bypasses its `claude*` discovery filter while
|
|
87
|
+
// still sending exactly `fable-5` to the configured gateway.
|
|
88
|
+
nextEnv[customModelOptionKey] = fableModelId;
|
|
89
|
+
nextEnv[customModelOptionNameKey] = fableModelName;
|
|
90
|
+
nextEnv[customModelOptionDescriptionKey] = fableModelDescription;
|
|
91
|
+
}
|
|
92
|
+
else if (oldMetadata.managedBy === 'ScaleMax CLI') {
|
|
93
|
+
// A key can be reconfigured from a Fable-enabled catalog to one that
|
|
94
|
+
// does not include Fable. Remove only the values this CLI previously
|
|
95
|
+
// managed, restoring any user values captured on the first configure.
|
|
96
|
+
for (const key of [customModelOptionKey, customModelOptionNameKey, customModelOptionDescriptionKey]) {
|
|
97
|
+
if (Object.prototype.hasOwnProperty.call(previousEnv, key))
|
|
98
|
+
nextEnv[key] = previousEnv[key];
|
|
99
|
+
else if (nextEnv[key] === fableModelId || nextEnv[key] === fableModelName || nextEnv[key] === fableModelDescription)
|
|
100
|
+
delete nextEnv[key];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
54
103
|
delete nextEnv.ANTHROPIC_AUTH_TOKEN;
|
|
55
104
|
delete nextEnv.ANTHROPIC_API_KEY;
|
|
56
105
|
if (quick)
|
|
@@ -70,6 +119,9 @@ export async function configure(ctx) {
|
|
|
70
119
|
fastModel: quick || null,
|
|
71
120
|
helperTtl: '900000',
|
|
72
121
|
disableThinking: '1',
|
|
122
|
+
customModelOption: fable ? fableModelId : null,
|
|
123
|
+
customModelOptionName: fable ? fableModelName : null,
|
|
124
|
+
customModelOptionDescription: fable ? fableModelDescription : null,
|
|
73
125
|
apiKeyHelper,
|
|
74
126
|
authTokenSha256: crypto.createHash('sha256').update(ctx.apiKey).digest('hex'),
|
|
75
127
|
previousEnv,
|
|
@@ -89,18 +141,29 @@ export async function verify(ctx) {
|
|
|
89
141
|
const settings = await fs.readJson(file);
|
|
90
142
|
const env = asRecord(settings?.env);
|
|
91
143
|
const model = String(env.ANTHROPIC_MODEL || '');
|
|
92
|
-
|
|
144
|
+
const fable = hasFableModel(ctx);
|
|
145
|
+
const customModelMatches = !fable
|
|
146
|
+
|| (env[customModelOptionKey] === fableModelId
|
|
147
|
+
&& env[customModelOptionNameKey] === fableModelName
|
|
148
|
+
&& env[customModelOptionDescriptionKey] === fableModelDescription);
|
|
149
|
+
if (!modelIds(ctx).includes(model) || !customModelMatches || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || settings.apiKeyHelper !== helperCommand(ctx.home, ctx.platform) || env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || env[discoveryKey] !== '1' || env[disableThinkingKey] !== '1') {
|
|
93
150
|
throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog.');
|
|
94
151
|
}
|
|
95
152
|
await verifyClientSecret(secretPath(ctx.home), ctx);
|
|
96
153
|
const quick = fastModel(ctx);
|
|
97
154
|
if (quick && env[fastModelKey] !== quick)
|
|
98
155
|
throw new Error('Claude Code background model does not match the fastest authenticated model.');
|
|
156
|
+
// Verification proves that the authenticated Messages route works; it does
|
|
157
|
+
// not need to warm the user's slower default Opus model. Prefer Haiku when
|
|
158
|
+
// it is present, while keeping ANTHROPIC_MODEL unchanged for real sessions.
|
|
159
|
+
// The longer fallback timeout also prevents a healthy Opus-only catalog
|
|
160
|
+
// from being rolled back merely because first-token latency exceeds 15s.
|
|
161
|
+
const verificationModel = quick || model;
|
|
99
162
|
const response = await fetch(`${anthropicOrigin(ctx.baseUrl)}/v1/messages`, {
|
|
100
163
|
method: 'POST',
|
|
101
164
|
headers: { Authorization: `Bearer ${ctx.apiKey}`, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
102
|
-
body: JSON.stringify({ model, max_tokens: 1, stream: false, messages: [{ role: 'user', content: 'K' }] }),
|
|
103
|
-
signal: AbortSignal.timeout(
|
|
165
|
+
body: JSON.stringify({ model: verificationModel, max_tokens: 1, stream: false, messages: [{ role: 'user', content: 'K' }] }),
|
|
166
|
+
signal: AbortSignal.timeout(verificationTimeoutMs)
|
|
104
167
|
});
|
|
105
168
|
if (!response.ok) {
|
|
106
169
|
let message = `HTTP ${response.status}`;
|
|
@@ -111,7 +174,15 @@ export async function verify(ctx) {
|
|
|
111
174
|
catch { }
|
|
112
175
|
throw new Error(`Claude Messages verification failed: ${redactApiKey(message, ctx.apiKey)}`);
|
|
113
176
|
}
|
|
114
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
name,
|
|
180
|
+
changed: false,
|
|
181
|
+
path: file,
|
|
182
|
+
message: verificationModel === model
|
|
183
|
+
? `verified Anthropic Messages with ${model}`
|
|
184
|
+
: `verified Anthropic Messages with ${verificationModel}; default model remains ${model}`,
|
|
185
|
+
};
|
|
115
186
|
}
|
|
116
187
|
export async function remove(ctx) {
|
|
117
188
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
@@ -143,6 +214,12 @@ export async function remove(ctx) {
|
|
|
143
214
|
restore(helperTtlKey);
|
|
144
215
|
if (env[disableThinkingKey] === managed.disableThinking)
|
|
145
216
|
restore(disableThinkingKey);
|
|
217
|
+
if (env[customModelOptionKey] === managed.customModelOption)
|
|
218
|
+
restore(customModelOptionKey);
|
|
219
|
+
if (env[customModelOptionNameKey] === managed.customModelOptionName)
|
|
220
|
+
restore(customModelOptionNameKey);
|
|
221
|
+
if (env[customModelOptionDescriptionKey] === managed.customModelOptionDescription)
|
|
222
|
+
restore(customModelOptionDescriptionKey);
|
|
146
223
|
if (!Object.prototype.hasOwnProperty.call(env, 'ANTHROPIC_AUTH_TOKEN'))
|
|
147
224
|
restore('ANTHROPIC_AUTH_TOKEN');
|
|
148
225
|
if (!Object.prototype.hasOwnProperty.call(env, 'ANTHROPIC_API_KEY'))
|
package/dist/configure/models.js
CHANGED
|
@@ -11,7 +11,7 @@ export function jsonConfig(client, apiKey, baseUrl, extra = {}) {
|
|
|
11
11
|
}, null, 2) + '\n';
|
|
12
12
|
}
|
|
13
13
|
export function continueConfig(apiKey, baseUrl) {
|
|
14
|
-
const models = ['
|
|
14
|
+
const models = ['gpt-5.6-sol', 'gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'codex-auto-review'];
|
|
15
15
|
return JSON.stringify({
|
|
16
16
|
models: models.map(model => ({
|
|
17
17
|
title: `ScaleMax ${model}`,
|
|
@@ -5,7 +5,7 @@ const id = 'trae-solo';
|
|
|
5
5
|
const name = 'TRAE SOLO';
|
|
6
6
|
export async function configure(ctx) {
|
|
7
7
|
const file = clientConfigPath(id, ctx.home, ctx.platform);
|
|
8
|
-
const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", `Request URL: ${ctx.baseUrl}/chat/completions or base URL ${ctx.baseUrl}`, "Use only a model ID returned by the authenticated catalog"], "recommendedModel": "
|
|
8
|
+
const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { "officialSetup": ["Settings > Models > Add Model", "API format: OpenAI Chat Completions", `Request URL: ${ctx.baseUrl}/chat/completions or base URL ${ctx.baseUrl}`, "Use only a model ID returned by the authenticated catalog"], "recommendedModel": "gpt-5.4-mini" });
|
|
9
9
|
const result = await writeManagedConfig(ctx, id, name, file, content);
|
|
10
10
|
return { ...result, message: 'setup guide written; enter these values in TRAE SOLO custom model settings' };
|
|
11
11
|
}
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { modelsCommand } from './commands/models.js';
|
|
|
12
12
|
import { updateCommand } from './commands/update.js';
|
|
13
13
|
import { removeCommand } from './commands/remove.js';
|
|
14
14
|
import { helperBotCommand } from './commands/helper-bot.js';
|
|
15
|
+
import { claudeToolsCommand } from './commands/claude-tools.js';
|
|
15
16
|
import { ensureInstallerApiKey } from './utils/installerConfig.js';
|
|
16
17
|
import { MIN_NODE, isNodeSupported, nodeUpgradeHint } from './utils/runtime.js';
|
|
17
18
|
import { runSystemAudit } from './utils/systemAudit.js';
|
|
@@ -37,7 +38,7 @@ function printHelp(command) {
|
|
|
37
38
|
console.log('Usage: scalemax helper-bot [--model <id>] ["question"]\n\nExecute safe ScaleMax setup and verification actions, or open the ScaleMax-only troubleshooting assistant.');
|
|
38
39
|
return;
|
|
39
40
|
}
|
|
40
|
-
const details = { update: 'Revalidate the API key and reconfigure every detected client.', remove: 'Restore backups or remove only ScaleMax-managed client configuration.', doctor: 'Validate the API key and verify detected client configuration.', models: 'List models available to the stored API key.', login: 'Store or replace the ScaleMax API key.', logout: 'Remove the locally stored ScaleMax API key.' };
|
|
41
|
+
const details = { update: 'Revalidate the API key and reconfigure every detected client.', remove: 'Restore backups or remove only ScaleMax-managed client configuration.', doctor: 'Validate the API key and verify detected client configuration.', models: 'List models available to the stored API key.', login: 'Store or replace the ScaleMax API key.', logout: 'Remove the locally stored ScaleMax API key.', 'claude-tools': 'Install and verify bounded public-GitHub skill tools for Claude Desktop/Cowork and Claude Code.' };
|
|
41
42
|
if (command && details[command]) {
|
|
42
43
|
console.log(`Usage: scalemax ${command}\n\n${details[command]}`);
|
|
43
44
|
return;
|
|
@@ -53,6 +54,7 @@ Commands:
|
|
|
53
54
|
scalemax doctor Run diagnostics
|
|
54
55
|
scalemax models List available models
|
|
55
56
|
scalemax helper-bot Execute setup actions or open the ScaleMax Helper
|
|
57
|
+
scalemax claude-tools Install bounded GitHub skill tools for Claude
|
|
56
58
|
scalemax update Reconfigure detected clients
|
|
57
59
|
scalemax remove Remove ScaleMax-generated configs
|
|
58
60
|
scalemax --version Show installed version
|
|
@@ -106,6 +108,9 @@ async function main() {
|
|
|
106
108
|
case 'helper-bot':
|
|
107
109
|
await helperBotCommand(args);
|
|
108
110
|
break;
|
|
111
|
+
case 'claude-tools':
|
|
112
|
+
await claudeToolsCommand();
|
|
113
|
+
break;
|
|
109
114
|
case 'update':
|
|
110
115
|
await updateCommand();
|
|
111
116
|
break;
|