@manturhub/cli 0.8.0 → 0.8.2
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 +2 -2
- package/bin/cli.js +8 -14
- package/lib/agent-target.js +88 -0
- package/lib/archive.js +66 -13
- package/lib/config.js +17 -2
- package/lib/download.js +45 -0
- package/lib/skill-install.js +34 -30
- package/lib/suite-install.js +14 -16
- package/lib/update-check.js +1 -1
- package/package.json +4 -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,8 +42,8 @@ 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]` |
|
|
46
|
-
| `manturhub skill ls [--json]` / `skill add <slug> --client codex` | 浏览和安装业务 Skill |
|
|
45
|
+
| `manturhub balance [--json]` | 查询余额及美元等值(1 馒头 = $0.01 USD) |
|
|
46
|
+
| `manturhub skill ls [--json]` / `skill add <slug> [--client codex]` | 浏览和安装业务 Skill;默认自动识别 Agent |
|
|
47
47
|
| `manturhub recipe [关键词]` / `recipe get <ID>` | 搜索并查看已验证配方 |
|
|
48
48
|
| `manturhub suite ls [--json]` / `suite install <slug>` | 安装多角色 Agent 套件,不复制 API Key |
|
|
49
49
|
| `manturhub init` | 给当前项目写入 Agent 使用引导 |
|
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}
|
|
@@ -101,7 +100,7 @@ const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
|
|
|
101
100
|
manturhub init 装「ManturHub 使用 skill」+ 写 agent 引导(推荐:让 agent 会用算子)
|
|
102
101
|
manturhub skill ls 列出平台 Skill(业务成品流程,如爆款复刻)
|
|
103
102
|
manturhub skill add <slug> [--client claude-code|codex|all]
|
|
104
|
-
|
|
103
|
+
自动识别 Agent 并安装 Skill(可显式指定)
|
|
105
104
|
manturhub suite ls 列出 Agent 套件(多角色团队工作区,给 Agent 配一整个团队)
|
|
106
105
|
manturhub suite install <slug> 安装套件为工作目录 ./<slug>/(不复制 API Key)
|
|
107
106
|
manturhub recipe [关键词] [--cat x] 搜配方(已验证创作的效果+可复现参数;分类 video/image/script)
|
|
@@ -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
|
}
|
|
@@ -475,7 +469,7 @@ async function main() {
|
|
|
475
469
|
console.error(error.message);
|
|
476
470
|
process.exit(1);
|
|
477
471
|
}
|
|
478
|
-
await skillAdd(args[2], getFlag("client"
|
|
472
|
+
await skillAdd(args[2], getFlag("client"));
|
|
479
473
|
}
|
|
480
474
|
else {
|
|
481
475
|
console.error("用法: manturhub skill ls | manturhub skill add <slug> [--client claude-code|codex|all]");
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { determineAgent } from "@vercel/detect-agent";
|
|
6
|
+
|
|
7
|
+
const SUPPORTED_CLIENTS = new Set(["claude", "claude-code", "codex", "all"]);
|
|
8
|
+
|
|
9
|
+
export function normalizeClient(client) {
|
|
10
|
+
if (client === undefined || client === null || client === "") return null;
|
|
11
|
+
if (!SUPPORTED_CLIENTS.has(client)) {
|
|
12
|
+
throw new Error("不支持的 client。可用值: claude-code | codex | all");
|
|
13
|
+
}
|
|
14
|
+
return client === "claude" ? "claude-code" : client;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clientForAgentName(name) {
|
|
18
|
+
if (name === "claude" || name === "cowork") return "claude-code";
|
|
19
|
+
if (name === "codex") return "codex";
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function detectInstalledClients({ home = homedir(), exists = existsSync } = {}) {
|
|
24
|
+
const clients = [];
|
|
25
|
+
if (exists(join(home, ".claude"))) clients.push("claude-code");
|
|
26
|
+
if (exists(join(home, ".codex")) || exists("/etc/codex")) clients.push("codex");
|
|
27
|
+
return clients;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function displayClient(client) {
|
|
31
|
+
if (client === "all") return "Claude Code、Codex";
|
|
32
|
+
return client === "codex" ? "Codex" : "Claude Code";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function promptForClient(input, output, installedClients) {
|
|
36
|
+
const detected = installedClients.length
|
|
37
|
+
? `\n检测到: ${installedClients.map(displayClient).join("、")}`
|
|
38
|
+
: "";
|
|
39
|
+
output.write(`${detected}\n请选择 Skill 安装目标:\n 1) Codex\n 2) Claude Code\n 3) 两者\n`);
|
|
40
|
+
const rl = createInterface({ input, output });
|
|
41
|
+
try {
|
|
42
|
+
const answer = (await rl.question("请输入 1 / 2 / 3: ")).trim();
|
|
43
|
+
if (answer === "1") return "codex";
|
|
44
|
+
if (answer === "2") return "claude-code";
|
|
45
|
+
if (answer === "3") return "all";
|
|
46
|
+
throw new Error("已取消:请输入 1、2 或 3,或使用 --client 明确指定。");
|
|
47
|
+
} finally {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function resolveSkillClient(
|
|
53
|
+
client,
|
|
54
|
+
{
|
|
55
|
+
detectAgent = determineAgent,
|
|
56
|
+
installedClients,
|
|
57
|
+
input = process.stdin,
|
|
58
|
+
output = process.stdout,
|
|
59
|
+
} = {}
|
|
60
|
+
) {
|
|
61
|
+
const explicit = normalizeClient(client);
|
|
62
|
+
if (explicit) return { client: explicit, detected: false };
|
|
63
|
+
|
|
64
|
+
const agent = await detectAgent();
|
|
65
|
+
const runtimeClient = agent.isAgent ? clientForAgentName(agent.agent?.name) : null;
|
|
66
|
+
if (runtimeClient) return { client: runtimeClient, detected: true };
|
|
67
|
+
|
|
68
|
+
const installed = installedClients ?? detectInstalledClients();
|
|
69
|
+
if (installed.length === 1) return { client: installed[0], detected: true };
|
|
70
|
+
|
|
71
|
+
if (input.isTTY && output.isTTY) {
|
|
72
|
+
return {
|
|
73
|
+
client: await promptForClient(input, output, installed),
|
|
74
|
+
detected: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (installed.length > 1) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"检测到 Claude Code 和 Codex,但当前无法交互选择。请加 --client codex、--client claude-code 或 --client all。"
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
throw new Error(
|
|
84
|
+
"未识别到 Codex 或 Claude Code。请在 Agent 内重试,或加 --client codex|claude-code|all。"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { displayClient };
|
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,11 @@
|
|
|
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";
|
|
8
|
+
import { displayClient, resolveSkillClient } from "./agent-target.js";
|
|
7
9
|
|
|
8
10
|
// `manturhub skill ls` — 列出平台上线 Skill(公开元数据,无需 key;
|
|
9
11
|
// 配了 key 则带上——管理员租户的 key 能看到 admin 专属 Skill)。
|
|
@@ -33,11 +35,11 @@ export async function skillLs({ json = false } = {}) {
|
|
|
33
35
|
const ver = s.version ? `v${s.version}` : "";
|
|
34
36
|
console.log(` ${slug} ${s.name || ""} ${cat} ${ver}`.trimEnd());
|
|
35
37
|
}
|
|
36
|
-
console.log(`\n用 \`manturhub skill add <slug
|
|
38
|
+
console.log(`\n用 \`manturhub skill add <slug>\` 自动识别 Agent;也可用 \`--client\` 明确指定。`);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
// `manturhub skill add <slug>` —
|
|
40
|
-
export async function skillAdd(slug, client
|
|
41
|
+
// `manturhub skill add <slug>` — 自动识别 Agent,下载并安全解压到用户级 skills 目录。
|
|
42
|
+
export async function skillAdd(slug, client) {
|
|
41
43
|
if (!slug) {
|
|
42
44
|
console.error("用法: manturhub skill add <slug> (先 `manturhub skill ls` 看可用 Skill)");
|
|
43
45
|
process.exit(1);
|
|
@@ -48,6 +50,16 @@ export async function skillAdd(slug, client = "claude-code") {
|
|
|
48
50
|
console.error(error.message);
|
|
49
51
|
process.exit(1);
|
|
50
52
|
}
|
|
53
|
+
let target;
|
|
54
|
+
try {
|
|
55
|
+
target = await resolveSkillClient(client);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(error.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
client = target.client;
|
|
61
|
+
if (target.detected) console.log(`✓ 已识别 Agent:${displayClient(client)}`);
|
|
62
|
+
|
|
51
63
|
const key = getKey();
|
|
52
64
|
if (!key) {
|
|
53
65
|
console.error("下载 Skill 需 API Key。运行 `manturhub login`,或设置环境变量 MANTURHUB_KEY。");
|
|
@@ -76,53 +88,45 @@ export async function skillAdd(slug, client = "claude-code") {
|
|
|
76
88
|
process.exit(1);
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
let
|
|
91
|
+
let packageResponse;
|
|
80
92
|
if (res.status >= 300 && res.status < 400) {
|
|
81
93
|
const loc = res.headers.get("location");
|
|
82
94
|
if (!loc) {
|
|
83
95
|
console.error("下载重定向缺少 Location 头");
|
|
84
96
|
process.exit(1);
|
|
85
97
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
try {
|
|
99
|
+
packageResponse = await fetch(assertSecureDownloadUrl(loc), { signal: AbortSignal.timeout(120000) });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(`安装包下载失败: ${error.message}`);
|
|
89
102
|
process.exit(1);
|
|
90
103
|
}
|
|
91
|
-
zipBuf = Buffer.from(await zres.arrayBuffer());
|
|
92
104
|
} else if (res.ok) {
|
|
93
|
-
|
|
105
|
+
packageResponse = res;
|
|
94
106
|
} else {
|
|
95
107
|
console.error(`下载失败(HTTP ${res.status})`);
|
|
96
108
|
process.exit(1);
|
|
97
109
|
}
|
|
98
|
-
if (zipBuf.length > 100 * 1024 * 1024) {
|
|
99
|
-
console.error("安装包过大(最大 100 MB)");
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const supported = new Set(["claude", "claude-code", "codex", "all"]);
|
|
104
|
-
if (!supported.has(client)) {
|
|
105
|
-
console.error("不支持的 client。可用值: claude-code | codex | all");
|
|
106
|
-
process.exit(1);
|
|
107
|
-
}
|
|
108
110
|
const destinations = [];
|
|
109
|
-
if (client === "claude
|
|
111
|
+
if (client === "claude-code" || client === "all") {
|
|
110
112
|
destinations.push(join(homedir(), ".claude", "skills", slug));
|
|
111
113
|
}
|
|
112
114
|
if (client === "codex" || client === "all") {
|
|
113
115
|
destinations.push(join(homedir(), ".agents", "skills", slug));
|
|
114
116
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const tempDir = mkdtempSync(join(tmpdir(), "manturhub-skill-"));
|
|
118
|
+
const tmp = join(tempDir, `${slug}.zip`);
|
|
119
|
+
try {
|
|
120
|
+
await downloadResponseToFile(packageResponse, tmp);
|
|
121
|
+
for (const dest of destinations) {
|
|
119
122
|
extractZipSafely(tmp, dest);
|
|
120
|
-
} catch (error) {
|
|
121
|
-
console.error(`解压失败: ${error.message}。安装包已存到: ${tmp}`);
|
|
122
|
-
process.exit(1);
|
|
123
123
|
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error(`安装失败: ${error.message}`);
|
|
126
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
127
|
+
process.exit(1);
|
|
124
128
|
}
|
|
125
|
-
rmSync(
|
|
129
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
126
130
|
for (const dest of destinations) console.log(`✓ 已安装 Skill「${slug}」→ ${dest}`);
|
|
127
|
-
console.log(" 重启对应 Agent
|
|
131
|
+
console.log(" 重启对应 Agent 后,用自然语言描述任务即可使用。");
|
|
128
132
|
}
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manturhub/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "ManturHub 算子广场 CLI:通过 REST 发现和调用 AI 算子、浏览配方,并安装 Skill 与 Agent 套件",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=18"
|
|
11
11
|
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@vercel/detect-agent": "^1.2.1"
|
|
14
|
+
},
|
|
12
15
|
"scripts": {
|
|
13
16
|
"test": "node --test",
|
|
14
17
|
"prepack": "npm test"
|
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
|
-
}
|