@manturhub/cli 0.8.0 → 0.8.1
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/LICENSE +21 -0
- package/README.md +1 -1
- package/bin/cli.js +6 -12
- package/lib/archive.js +66 -13
- package/lib/config.js +17 -2
- package/lib/download.js +45 -0
- package/lib/skill-install.js +18 -20
- package/lib/suite-install.js +14 -16
- package/lib/update-check.js +1 -1
- package/package.json +1 -1
- package/lib/mcp.js +0 -108
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ManturHub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ CLI 会在付费调用前按算子实时 schema 校验未知字段、必填项
|
|
|
42
42
|
| `manturhub run <算子ID> --json-file params.json` | 从文件读取参数,适合长提示词和自动化 |
|
|
43
43
|
| `manturhub upload <本地文件>` | 流式上传图片、音频或视频并输出公网 URL |
|
|
44
44
|
| `manturhub status <poll_url>` | 查询 `run --no-wait` 返回的异步任务 |
|
|
45
|
-
| `manturhub balance [--json]` |
|
|
45
|
+
| `manturhub balance [--json]` | 查询余额及美元等值(1 馒头 = $0.01 USD) |
|
|
46
46
|
| `manturhub skill ls [--json]` / `skill add <slug> --client codex` | 浏览和安装业务 Skill |
|
|
47
47
|
| `manturhub recipe [关键词]` / `recipe get <ID>` | 搜索并查看已验证配方 |
|
|
48
48
|
| `manturhub suite ls [--json]` / `suite install <slug>` | 安装多角色 Agent 套件,不复制 API Key |
|
package/bin/cli.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { getBaseUrl, saveConfig, loadConfig } from "../lib/config.js";
|
|
3
3
|
import { apiFetch, pollJob } from "../lib/api.js";
|
|
4
|
-
import { runMcpBridge } from "../lib/mcp.js";
|
|
5
4
|
import { runInit } from "../lib/setup.js";
|
|
6
5
|
import { skillLs, skillAdd } from "../lib/skill-install.js";
|
|
7
6
|
import { suiteLs, suiteInstall } from "../lib/suite-install.js";
|
|
@@ -79,9 +78,9 @@ function assertRunControlFlags(tokens) {
|
|
|
79
78
|
}
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
function
|
|
81
|
+
function usdFor(dumplings) {
|
|
83
82
|
const value = Number(dumplings);
|
|
84
|
-
return Number.isFinite(value) ?
|
|
83
|
+
return Number.isFinite(value) ? `$${(value * 0.01).toFixed(2)} USD` : "-";
|
|
85
84
|
}
|
|
86
85
|
|
|
87
86
|
const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
|
|
@@ -159,7 +158,7 @@ async function main() {
|
|
|
159
158
|
cfg.key = key;
|
|
160
159
|
saveConfig(cfg);
|
|
161
160
|
console.log(
|
|
162
|
-
`✓ Key 已验证并保存。账号: ${r.json.email || "-"} 余额: ${
|
|
161
|
+
`✓ Key 已验证并保存。账号: ${r.json.email || "-"} 余额: ${usdFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头)`
|
|
163
162
|
);
|
|
164
163
|
} else {
|
|
165
164
|
console.error(`Key 验证失败(HTTP ${r.status}),未修改本地配置。请确认 key 是否正确、是否已激活。`);
|
|
@@ -168,11 +167,6 @@ async function main() {
|
|
|
168
167
|
break;
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
case "mcp": {
|
|
172
|
-
await runMcpBridge({ scope: getFlag("scope", "manturhub") });
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
170
|
case "init": {
|
|
177
171
|
runInit();
|
|
178
172
|
break;
|
|
@@ -279,7 +273,7 @@ async function main() {
|
|
|
279
273
|
);
|
|
280
274
|
} else {
|
|
281
275
|
console.log(`${r.json.operatorId || op}: ${r.json.formula || "详见算子页"}`);
|
|
282
|
-
if (r.json.floor !== undefined) console.log(`最低扣费: ${
|
|
276
|
+
if (r.json.floor !== undefined) console.log(`最低扣费: ${usdFor(r.json.floor)}(${r.json.floor} 馒头)`);
|
|
283
277
|
}
|
|
284
278
|
break;
|
|
285
279
|
}
|
|
@@ -452,8 +446,8 @@ async function main() {
|
|
|
452
446
|
}
|
|
453
447
|
console.log(
|
|
454
448
|
hasFlag("json")
|
|
455
|
-
? JSON.stringify({ ...r.json,
|
|
456
|
-
: `余额: ${
|
|
449
|
+
? JSON.stringify({ ...r.json, balance_usd: Number(r.json.balance) * 0.01 }, null, 2)
|
|
450
|
+
: `余额: ${usdFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头) 账号: ${r.json.email || "-"}`
|
|
457
451
|
);
|
|
458
452
|
break;
|
|
459
453
|
}
|
package/lib/archive.js
CHANGED
|
@@ -11,6 +11,7 @@ import { basename, join, posix } from "node:path";
|
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
|
|
13
13
|
const MAX_FILES = 2000;
|
|
14
|
+
const MAX_UNPACKED_BYTES = 500 * 1024 * 1024;
|
|
14
15
|
|
|
15
16
|
export function validateSlug(slug, label = "ID") {
|
|
16
17
|
if (!slug || !/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) {
|
|
@@ -21,24 +22,28 @@ export function validateSlug(slug, label = "ID") {
|
|
|
21
22
|
|
|
22
23
|
function listArchive(zipPath) {
|
|
23
24
|
const commands = [
|
|
24
|
-
{
|
|
25
|
-
command: "tar",
|
|
26
|
-
list: ["-tf", zipPath],
|
|
27
|
-
verbose: ["-tvf", zipPath],
|
|
28
|
-
extract: (dest) => ["-xf", zipPath, "-C", dest],
|
|
29
|
-
},
|
|
30
25
|
{
|
|
31
26
|
command: "unzip",
|
|
32
27
|
list: ["-Z1", zipPath],
|
|
33
28
|
verbose: ["-Z", "-l", zipPath],
|
|
29
|
+
summary: ["-Z", "-t", zipPath],
|
|
34
30
|
extract: (dest) => ["-o", "-q", zipPath, "-d", dest],
|
|
35
31
|
},
|
|
32
|
+
{
|
|
33
|
+
command: "tar",
|
|
34
|
+
list: ["-tf", zipPath],
|
|
35
|
+
verbose: ["-tvf", zipPath],
|
|
36
|
+
extract: (dest) => ["-xf", zipPath, "-C", dest],
|
|
37
|
+
},
|
|
36
38
|
];
|
|
37
39
|
for (const tool of commands) {
|
|
38
40
|
try {
|
|
39
41
|
const output = execFileSync(tool.command, tool.list, { encoding: "utf8" });
|
|
40
42
|
const verbose = execFileSync(tool.command, tool.verbose, { encoding: "utf8" });
|
|
41
|
-
|
|
43
|
+
const summary = tool.summary
|
|
44
|
+
? execFileSync(tool.command, tool.summary, { encoding: "utf8" })
|
|
45
|
+
: "";
|
|
46
|
+
return { tool, entries: output.split(/\r?\n/).filter(Boolean), verbose, summary };
|
|
42
47
|
} catch {
|
|
43
48
|
// Try the next installed extractor.
|
|
44
49
|
}
|
|
@@ -46,9 +51,31 @@ function listArchive(zipPath) {
|
|
|
46
51
|
throw new Error("解压失败(需系统 tar 或 unzip 命令)");
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
function declaredUnpackedBytes(tool, verbose, summary) {
|
|
55
|
+
if (tool.command === "unzip") {
|
|
56
|
+
const match = summary.match(/([\d,]+)\s+bytes? uncompressed/i);
|
|
57
|
+
return match ? Number(match[1].replaceAll(",", "")) : Number.NaN;
|
|
58
|
+
}
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (const line of verbose.split(/\r?\n/).filter(Boolean)) {
|
|
61
|
+
const bsd = line.match(/^\S+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+/);
|
|
62
|
+
const gnu = line.match(/^\S+\s+\S+\s+(\d+)\s+/);
|
|
63
|
+
const size = Number((bsd || gnu)?.[1]);
|
|
64
|
+
if (!Number.isFinite(size)) return Number.NaN;
|
|
65
|
+
total += size;
|
|
66
|
+
}
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function validateArchiveEntries(entries, verbose = "", unpackedBytes = 0) {
|
|
50
71
|
if (!entries.length) throw new Error("安装包为空");
|
|
51
72
|
if (entries.length > MAX_FILES) throw new Error(`安装包文件过多(最多 ${MAX_FILES} 个)`);
|
|
73
|
+
if (!Number.isFinite(unpackedBytes) || unpackedBytes < 0) {
|
|
74
|
+
throw new Error("无法确认安装包解压后大小");
|
|
75
|
+
}
|
|
76
|
+
if (unpackedBytes > MAX_UNPACKED_BYTES) {
|
|
77
|
+
throw new Error(`安装包解压后过大(最大 ${MAX_UNPACKED_BYTES / 1024 / 1024} MB)`);
|
|
78
|
+
}
|
|
52
79
|
if (/^\s*[lh][rwx-]{9}\s/m.test(verbose)) {
|
|
53
80
|
throw new Error("安装包不得包含符号链接或硬链接");
|
|
54
81
|
}
|
|
@@ -57,6 +84,7 @@ export function validateArchiveEntries(entries, verbose = "") {
|
|
|
57
84
|
const normalized = posix.normalize(name);
|
|
58
85
|
if (
|
|
59
86
|
name.includes("\0") ||
|
|
87
|
+
/[\x00-\x1f\x7f]/.test(name) ||
|
|
60
88
|
name.startsWith("/") ||
|
|
61
89
|
/^[a-z]:\//i.test(name) ||
|
|
62
90
|
normalized === ".." ||
|
|
@@ -67,23 +95,48 @@ export function validateArchiveEntries(entries, verbose = "") {
|
|
|
67
95
|
}
|
|
68
96
|
}
|
|
69
97
|
|
|
70
|
-
function
|
|
98
|
+
function inspectExtracted(dir, state = { files: 0, bytes: 0 }) {
|
|
71
99
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
100
|
+
if (/[\x00-\x1f\x7f]/.test(entry.name)) {
|
|
101
|
+
throw new Error(`安装包包含不安全文件名: ${JSON.stringify(entry.name)}`);
|
|
102
|
+
}
|
|
72
103
|
const path = join(dir, entry.name);
|
|
73
104
|
const stat = lstatSync(path);
|
|
74
105
|
if (stat.isSymbolicLink()) throw new Error(`安装包不得包含符号链接: ${entry.name}`);
|
|
75
|
-
if (stat.isDirectory())
|
|
106
|
+
if (stat.isDirectory()) inspectExtracted(path, state);
|
|
107
|
+
else if (stat.isFile()) {
|
|
108
|
+
state.files++;
|
|
109
|
+
state.bytes += stat.size;
|
|
110
|
+
if (state.files > MAX_FILES) throw new Error(`安装包文件过多(最多 ${MAX_FILES} 个)`);
|
|
111
|
+
if (state.bytes > MAX_UNPACKED_BYTES) {
|
|
112
|
+
throw new Error(`安装包解压后过大(最大 ${MAX_UNPACKED_BYTES / 1024 / 1024} MB)`);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
throw new Error(`安装包包含不支持的文件类型: ${entry.name}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function rejectDestinationLinks(dir) {
|
|
122
|
+
if (!lstatSync(dir).isDirectory()) throw new Error(`安装目标不是目录: ${dir}`);
|
|
123
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
124
|
+
const path = join(dir, entry.name);
|
|
125
|
+
const stat = lstatSync(path);
|
|
126
|
+
if (stat.isSymbolicLink()) throw new Error(`安装目标包含符号链接: ${path}`);
|
|
127
|
+
if (stat.isDirectory()) rejectDestinationLinks(path);
|
|
76
128
|
}
|
|
77
129
|
}
|
|
78
130
|
|
|
79
131
|
export function extractZipSafely(zipPath, dest) {
|
|
80
|
-
const { tool, entries, verbose } = listArchive(zipPath);
|
|
81
|
-
validateArchiveEntries(entries, verbose);
|
|
132
|
+
const { tool, entries, verbose, summary } = listArchive(zipPath);
|
|
133
|
+
validateArchiveEntries(entries, verbose, declaredUnpackedBytes(tool, verbose, summary));
|
|
82
134
|
const staging = mkdtempSync(join(tmpdir(), "manturhub-extract-"));
|
|
83
135
|
try {
|
|
84
136
|
execFileSync(tool.command, tool.extract(staging), { stdio: ["ignore", "ignore", "ignore"] });
|
|
85
|
-
|
|
137
|
+
inspectExtracted(staging);
|
|
86
138
|
mkdirSync(dest, { recursive: true });
|
|
139
|
+
rejectDestinationLinks(dest);
|
|
87
140
|
for (const entry of readdirSync(staging)) {
|
|
88
141
|
cpSync(join(staging, entry), join(dest, basename(entry)), { recursive: true, force: true });
|
|
89
142
|
}
|
package/lib/config.js
CHANGED
|
@@ -42,9 +42,24 @@ export function getKey() {
|
|
|
42
42
|
return process.env.MANTURHUB_KEY || loadConfig().key || null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function validateBaseUrl(value) {
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(value);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`ManturHub 网关地址不合法: ${value}`);
|
|
51
|
+
}
|
|
52
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
53
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
54
|
+
throw new Error("MANTURHUB_BASE 必须使用 HTTPS(仅 localhost/127.0.0.1/::1 可使用 HTTP)");
|
|
55
|
+
}
|
|
56
|
+
if (url.username || url.password) throw new Error("MANTURHUB_BASE 不得包含用户名或密码");
|
|
57
|
+
return url.href.replace(/\/$/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
export function getBaseUrl() {
|
|
46
|
-
if (process.env.MANTURHUB_BASE) return process.env.MANTURHUB_BASE
|
|
61
|
+
if (process.env.MANTURHUB_BASE) return validateBaseUrl(process.env.MANTURHUB_BASE);
|
|
47
62
|
const storedBase = loadConfig().baseUrl?.replace(/\/$/, "");
|
|
48
|
-
if (storedBase && !LEGACY_DEFAULT_BASES.has(storedBase)) return storedBase;
|
|
63
|
+
if (storedBase && !LEGACY_DEFAULT_BASES.has(storedBase)) return validateBaseUrl(storedBase);
|
|
49
64
|
return DEFAULT_BASE;
|
|
50
65
|
}
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createWriteStream, rmSync } from "node:fs";
|
|
2
|
+
import { Readable, Transform } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
|
|
5
|
+
export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export function assertSecureDownloadUrl(value) {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error("下载重定向地址不合法");
|
|
13
|
+
}
|
|
14
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
15
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
16
|
+
throw new Error("安装包下载地址必须使用 HTTPS");
|
|
17
|
+
}
|
|
18
|
+
return url.href;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function downloadResponseToFile(response, target, maxBytes = MAX_PACKAGE_BYTES) {
|
|
22
|
+
if (!response.ok) throw new Error(`安装包下载失败(HTTP ${response.status})`);
|
|
23
|
+
if (!response.body) throw new Error("安装包响应为空");
|
|
24
|
+
const declared = Number(response.headers.get("content-length"));
|
|
25
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
26
|
+
await response.body.cancel();
|
|
27
|
+
throw new Error(`安装包过大(最大 ${Math.floor(maxBytes / 1024 / 1024)} MB)`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let received = 0;
|
|
31
|
+
const limit = new Transform({
|
|
32
|
+
transform(chunk, _encoding, callback) {
|
|
33
|
+
received += chunk.length;
|
|
34
|
+
if (received > maxBytes) callback(new Error(`安装包过大(最大 ${Math.floor(maxBytes / 1024 / 1024)} MB)`));
|
|
35
|
+
else callback(null, chunk);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
await pipeline(Readable.fromWeb(response.body), limit, createWriteStream(target, { flags: "wx" }));
|
|
40
|
+
return received;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
rmSync(target, { force: true });
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/skill-install.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { homedir, tmpdir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { getKey, getBaseUrl } from "./config.js";
|
|
5
5
|
import { extractZipSafely, validateSlug } from "./archive.js";
|
|
6
6
|
import { apiFetch } from "./api.js";
|
|
7
|
+
import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
|
|
7
8
|
|
|
8
9
|
// `manturhub skill ls` — 列出平台上线 Skill(公开元数据,无需 key;
|
|
9
10
|
// 配了 key 则带上——管理员租户的 key 能看到 admin 专属 Skill)。
|
|
@@ -76,30 +77,25 @@ export async function skillAdd(slug, client = "claude-code") {
|
|
|
76
77
|
process.exit(1);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
let
|
|
80
|
+
let packageResponse;
|
|
80
81
|
if (res.status >= 300 && res.status < 400) {
|
|
81
82
|
const loc = res.headers.get("location");
|
|
82
83
|
if (!loc) {
|
|
83
84
|
console.error("下载重定向缺少 Location 头");
|
|
84
85
|
process.exit(1);
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
try {
|
|
88
|
+
packageResponse = await fetch(assertSecureDownloadUrl(loc), { signal: AbortSignal.timeout(120000) });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`安装包下载失败: ${error.message}`);
|
|
89
91
|
process.exit(1);
|
|
90
92
|
}
|
|
91
|
-
zipBuf = Buffer.from(await zres.arrayBuffer());
|
|
92
93
|
} else if (res.ok) {
|
|
93
|
-
|
|
94
|
+
packageResponse = res;
|
|
94
95
|
} else {
|
|
95
96
|
console.error(`下载失败(HTTP ${res.status})`);
|
|
96
97
|
process.exit(1);
|
|
97
98
|
}
|
|
98
|
-
if (zipBuf.length > 100 * 1024 * 1024) {
|
|
99
|
-
console.error("安装包过大(最大 100 MB)");
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
99
|
const supported = new Set(["claude", "claude-code", "codex", "all"]);
|
|
104
100
|
if (!supported.has(client)) {
|
|
105
101
|
console.error("不支持的 client。可用值: claude-code | codex | all");
|
|
@@ -112,17 +108,19 @@ export async function skillAdd(slug, client = "claude-code") {
|
|
|
112
108
|
if (client === "codex" || client === "all") {
|
|
113
109
|
destinations.push(join(homedir(), ".agents", "skills", slug));
|
|
114
110
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
111
|
+
const tempDir = mkdtempSync(join(tmpdir(), "manturhub-skill-"));
|
|
112
|
+
const tmp = join(tempDir, `${slug}.zip`);
|
|
113
|
+
try {
|
|
114
|
+
await downloadResponseToFile(packageResponse, tmp);
|
|
115
|
+
for (const dest of destinations) {
|
|
119
116
|
extractZipSafely(tmp, dest);
|
|
120
|
-
} catch (error) {
|
|
121
|
-
console.error(`解压失败: ${error.message}。安装包已存到: ${tmp}`);
|
|
122
|
-
process.exit(1);
|
|
123
117
|
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error(`安装失败: ${error.message}`);
|
|
120
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
121
|
+
process.exit(1);
|
|
124
122
|
}
|
|
125
|
-
rmSync(
|
|
123
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
126
124
|
for (const dest of destinations) console.log(`✓ 已安装 Skill「${slug}」→ ${dest}`);
|
|
127
125
|
console.log(" 重启对应 Agent 后,用自然语言描述任务;Claude Code 也可用 /<slug> 触发。");
|
|
128
126
|
}
|
package/lib/suite-install.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { getKey, getBaseUrl } from "./config.js";
|
|
5
5
|
import { extractZipSafely, validateSlug } from "./archive.js";
|
|
6
6
|
import { apiFetch } from "./api.js";
|
|
7
|
+
import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
|
|
7
8
|
|
|
8
9
|
// #456 Agent 套件(团队版 Skill):与 skill 共用 /api/v1/skills 元数据与下载端点,
|
|
9
10
|
// 区别在安装形态——解压为当前目录下的工作目录(非用户级 skills)。API Key 继续由
|
|
@@ -82,40 +83,37 @@ export async function suiteInstall(slug, dirArg) {
|
|
|
82
83
|
process.exit(1);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
let
|
|
86
|
+
let packageResponse;
|
|
86
87
|
if (res.status >= 300 && res.status < 400) {
|
|
87
88
|
const loc = res.headers.get("location");
|
|
88
89
|
if (!loc) {
|
|
89
90
|
console.error("下载重定向缺少 Location 头");
|
|
90
91
|
process.exit(1);
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
try {
|
|
94
|
+
packageResponse = await fetch(assertSecureDownloadUrl(loc), { signal: AbortSignal.timeout(120000) });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error(`安装包下载失败: ${error.message}`);
|
|
95
97
|
process.exit(1);
|
|
96
98
|
}
|
|
97
|
-
zipBuf = Buffer.from(await zres.arrayBuffer());
|
|
98
99
|
} else if (res.ok) {
|
|
99
|
-
|
|
100
|
+
packageResponse = res;
|
|
100
101
|
} else {
|
|
101
102
|
console.error(`下载失败(HTTP ${res.status})`);
|
|
102
103
|
process.exit(1);
|
|
103
104
|
}
|
|
104
|
-
if (zipBuf.length > 100 * 1024 * 1024) {
|
|
105
|
-
console.error("安装包过大(最大 100 MB)");
|
|
106
|
-
process.exit(1);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
105
|
const dest = resolve(dirArg || `./${slug}`);
|
|
110
|
-
const
|
|
111
|
-
|
|
106
|
+
const tempDir = mkdtempSync(join(tmpdir(), "manturhub-suite-"));
|
|
107
|
+
const tmp = join(tempDir, `${slug}.zip`);
|
|
112
108
|
try {
|
|
109
|
+
await downloadResponseToFile(packageResponse, tmp);
|
|
113
110
|
extractZipSafely(tmp, dest);
|
|
114
111
|
} catch (error) {
|
|
115
|
-
console.error(
|
|
112
|
+
console.error(`安装失败: ${error.message}`);
|
|
113
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
116
114
|
process.exit(1);
|
|
117
115
|
}
|
|
118
|
-
rmSync(
|
|
116
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
119
117
|
|
|
120
118
|
console.log(`✓ 已安装套件「${slug}」→ ${dest}`);
|
|
121
119
|
console.log(" API Key 继续安全保存在 ~/.manturhub/config.json,未复制到项目目录");
|
package/lib/update-check.js
CHANGED
|
@@ -18,7 +18,7 @@ function isNewer(latest, current) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// 启动时调用:读本地缓存→落后则 stderr 提示;缓存超过一天→后台异步刷新(不阻塞本次)。
|
|
21
|
-
// 永不抛错、永不写 stdout
|
|
21
|
+
// 永不抛错、永不写 stdout,避免污染命令的机器可读输出。
|
|
22
22
|
export function maybeNotifyUpdate(currentVersion) {
|
|
23
23
|
if (process.env.MANTURHUB_DISABLE_UPDATE_CHECK === "1") return;
|
|
24
24
|
try {
|
package/package.json
CHANGED
package/lib/mcp.js
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { createInterface } from "node:readline";
|
|
2
|
-
import { getKey, getBaseUrl } from "./config.js";
|
|
3
|
-
|
|
4
|
-
// `manturhub mcp` — a stdio MCP server for clients (Claude Desktop / Codex /
|
|
5
|
-
// Cursor …) that only speak stdio or whose remote-transport support is immature.
|
|
6
|
-
//
|
|
7
|
-
// It does NOT reimplement the MCP server: it relays each newline-delimited
|
|
8
|
-
// JSON-RPC message from stdin to the remote ManturHub Streamable HTTP endpoint
|
|
9
|
-
// (already standard-compliant) and writes the HTTP response back to stdout.
|
|
10
|
-
// The remote handles initialize / tools/list / tools/call / scope. This is an
|
|
11
|
-
// official, controlled equivalent of mcp-remote.
|
|
12
|
-
export async function runMcpBridge({ scope = "manturhub" } = {}) {
|
|
13
|
-
const key = getKey();
|
|
14
|
-
if (!key) {
|
|
15
|
-
process.stderr.write(
|
|
16
|
-
"[manturhub legacy bridge] 未配置 API Key。运行 `manturhub login`,或设 MANTURHUB_KEY。\n"
|
|
17
|
-
);
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
const url = `${getBaseUrl()}/api/v1/mcp/${scope}/mcp`;
|
|
21
|
-
const rl = createInterface({ input: process.stdin });
|
|
22
|
-
|
|
23
|
-
// Track in-flight relays so we don't exit on stdin close while a fetch is
|
|
24
|
-
// still pending (real clients keep stdin open; piped input closes early).
|
|
25
|
-
let pending = 0;
|
|
26
|
-
let closed = false;
|
|
27
|
-
const maybeExit = () => {
|
|
28
|
-
if (closed && pending === 0) process.exit(0);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
rl.on("line", (line) => {
|
|
32
|
-
const trimmed = line.trim();
|
|
33
|
-
if (!trimmed) return;
|
|
34
|
-
let msg;
|
|
35
|
-
try {
|
|
36
|
-
msg = JSON.parse(trimmed);
|
|
37
|
-
} catch {
|
|
38
|
-
return; // exactly one JSON value per line; ignore noise
|
|
39
|
-
}
|
|
40
|
-
pending++;
|
|
41
|
-
relay(url, key, msg).finally(() => {
|
|
42
|
-
pending--;
|
|
43
|
-
maybeExit();
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
rl.on("close", () => {
|
|
48
|
-
closed = true;
|
|
49
|
-
maybeExit();
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function relay(url, key, msg) {
|
|
54
|
-
try {
|
|
55
|
-
const res = await fetch(url, {
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: {
|
|
58
|
-
"Content-Type": "application/json",
|
|
59
|
-
Accept: "application/json, text/event-stream",
|
|
60
|
-
"x-api-key": key,
|
|
61
|
-
"MCP-Protocol-Version": "2025-06-18",
|
|
62
|
-
},
|
|
63
|
-
body: JSON.stringify(msg),
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// 202 Accepted = notification/response acknowledged, no body to relay.
|
|
67
|
-
if (res.status === 202) return;
|
|
68
|
-
|
|
69
|
-
const ctype = res.headers.get("content-type") || "";
|
|
70
|
-
const text = await res.text();
|
|
71
|
-
if (!text) return;
|
|
72
|
-
|
|
73
|
-
if (ctype.includes("text/event-stream")) {
|
|
74
|
-
// Defensive: if the remote ever streams SSE, pull JSON-RPC from data: lines.
|
|
75
|
-
for (const evt of text.split(/\n\n/)) {
|
|
76
|
-
const data = evt
|
|
77
|
-
.split("\n")
|
|
78
|
-
.filter((l) => l.startsWith("data:"))
|
|
79
|
-
.map((l) => l.slice(5).trim())
|
|
80
|
-
.join("");
|
|
81
|
-
if (data) writeFrame(data);
|
|
82
|
-
}
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Normal case: remote returns a single application/json JSON-RPC response.
|
|
87
|
-
writeFrame(text);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
// Transport failure → return a JSON-RPC error for this id (requests only).
|
|
90
|
-
if (msg.id !== undefined && msg.id !== null) {
|
|
91
|
-
writeFrame(
|
|
92
|
-
JSON.stringify({
|
|
93
|
-
jsonrpc: "2.0",
|
|
94
|
-
id: msg.id,
|
|
95
|
-
error: {
|
|
96
|
-
code: -32603,
|
|
97
|
-
message: "manturhub bridge: " + (err?.message || "upstream error"),
|
|
98
|
-
},
|
|
99
|
-
})
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// One JSON value per line (stdio framing). Collapse raw newlines so framing holds.
|
|
106
|
-
function writeFrame(jsonText) {
|
|
107
|
-
process.stdout.write(jsonText.replace(/\r?\n/g, " ") + "\n");
|
|
108
|
-
}
|