@manturhub/cli 0.7.0 → 0.8.0
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 +35 -48
- package/bin/cli.js +300 -60
- package/lib/api.js +49 -14
- package/lib/archive.js +93 -0
- package/lib/config.js +13 -3
- package/lib/login-link.js +36 -7
- package/lib/mcp.js +1 -1
- package/lib/params.js +102 -0
- package/lib/setup.js +10 -91
- package/lib/skill-install.js +49 -36
- package/lib/skill.js +31 -18
- package/lib/suite-install.js +36 -48
- package/lib/update-check.js +1 -0
- package/package.json +12 -5
package/bin/cli.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { saveConfig, loadConfig } from "../lib/config.js";
|
|
2
|
+
import { getBaseUrl, saveConfig, loadConfig } from "../lib/config.js";
|
|
3
3
|
import { apiFetch, pollJob } from "../lib/api.js";
|
|
4
4
|
import { runMcpBridge } from "../lib/mcp.js";
|
|
5
|
-
import { runInit
|
|
5
|
+
import { runInit } from "../lib/setup.js";
|
|
6
6
|
import { skillLs, skillAdd } from "../lib/skill-install.js";
|
|
7
7
|
import { suiteLs, suiteInstall } from "../lib/suite-install.js";
|
|
8
8
|
import { loginViaBrowser } from "../lib/login-link.js";
|
|
9
9
|
import { maybeNotifyUpdate } from "../lib/update-check.js";
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
10
|
+
import { createReadStream, readFileSync, statSync } from "node:fs";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { dirname, join, basename, extname } from "node:path";
|
|
13
|
+
import { parseDynamicParams, validateParams } from "../lib/params.js";
|
|
13
14
|
|
|
14
15
|
// 本地文件 → MIME(presign 只接受 image/audio/video)
|
|
15
16
|
const MIME_BY_EXT = {
|
|
@@ -31,8 +32,56 @@ const args = process.argv.slice(2);
|
|
|
31
32
|
const cmd = args[0];
|
|
32
33
|
|
|
33
34
|
function getFlag(name, def) {
|
|
35
|
+
const inline = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
36
|
+
if (inline) return inline.slice(name.length + 3);
|
|
34
37
|
const i = args.indexOf(`--${name}`);
|
|
35
|
-
return i >= 0 && args[i + 1] !== undefined
|
|
38
|
+
return i >= 0 && args[i + 1] !== undefined && !args[i + 1].startsWith("--")
|
|
39
|
+
? args[i + 1]
|
|
40
|
+
: def;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hasFlag = (name) => args.includes(`--${name}`) || args.some((arg) => arg.startsWith(`--${name}=`));
|
|
44
|
+
|
|
45
|
+
function assertFlags(tokens, { value = [], boolean = [] } = {}) {
|
|
46
|
+
const valueFlags = new Set(value);
|
|
47
|
+
const booleanFlags = new Set(boolean);
|
|
48
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
49
|
+
const token = tokens[i];
|
|
50
|
+
if (!token?.startsWith("--")) throw new Error(`无法识别的位置参数: ${token}`);
|
|
51
|
+
const equals = token.indexOf("=");
|
|
52
|
+
const name = token.slice(2, equals > 2 ? equals : undefined);
|
|
53
|
+
if (booleanFlags.has(name)) {
|
|
54
|
+
if (equals > 2) throw new Error(`选项 --${name} 不接受值`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!valueFlags.has(name)) throw new Error(`未知选项: --${name}`);
|
|
58
|
+
if (equals > 2) {
|
|
59
|
+
if (!token.slice(equals + 1)) throw new Error(`参数 --${name} 缺少值`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const next = tokens[i + 1];
|
|
63
|
+
if (next === undefined || next.startsWith("--")) throw new Error(`参数 --${name} 缺少值`);
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertRunControlFlags(tokens) {
|
|
69
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
70
|
+
const token = tokens[i];
|
|
71
|
+
if (token === "--no-wait" || token.startsWith("--json=") || token.startsWith("--json-file=")) continue;
|
|
72
|
+
if (token === "--json" || token === "--json-file") {
|
|
73
|
+
const value = tokens[i + 1];
|
|
74
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`参数 ${token} 缺少值`);
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`未知选项: ${token}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rmbFor(dumplings) {
|
|
83
|
+
const value = Number(dumplings);
|
|
84
|
+
return Number.isFinite(value) ? `¥${(value * 0.1).toFixed(2)} RMB` : "-";
|
|
36
85
|
}
|
|
37
86
|
|
|
38
87
|
const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
|
|
@@ -40,55 +89,81 @@ const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
|
|
|
40
89
|
用法:
|
|
41
90
|
manturhub login 浏览器授权登录(生成链接→登录创建 Key→自动导入,推荐)
|
|
42
91
|
manturhub login --key sk-xxx 手动配置 API Key(存 ~/.manturhub/config.json)
|
|
43
|
-
manturhub
|
|
44
|
-
manturhub
|
|
92
|
+
manturhub login --key-stdin 从 stdin 安全读取 API Key
|
|
93
|
+
manturhub ls [--cat <分类>] [--json] 列出上线算子(无需登录)
|
|
94
|
+
manturhub describe <算子ID> [--json] 查看算子入参字段(无需登录)
|
|
95
|
+
manturhub quote <算子ID> 查询实时计费公式(不要使用 Skill 内的历史价格)
|
|
45
96
|
manturhub run <算子ID> --json '{}' 调用算子(异步算子自动轮询到出结果;--no-wait 只拿 job_id)
|
|
97
|
+
manturhub run <算子ID> --json-file x.json 从文件读参数(prompt 来自配方/用户时更安全)
|
|
46
98
|
manturhub upload <本地文件> 上传图片/音频/视频 → 公网 URL(喂算子前先转换本地文件)
|
|
47
99
|
manturhub status <poll_url> 查异步任务状态(配合 run --no-wait)
|
|
48
100
|
manturhub balance 查询馒头余额
|
|
49
101
|
manturhub init 装「ManturHub 使用 skill」+ 写 agent 引导(推荐:让 agent 会用算子)
|
|
50
102
|
manturhub skill ls 列出平台 Skill(业务成品流程,如爆款复刻)
|
|
51
|
-
manturhub skill add <slug>
|
|
103
|
+
manturhub skill add <slug> [--client claude-code|codex|all]
|
|
104
|
+
安装 Skill 到指定 Agent
|
|
52
105
|
manturhub suite ls 列出 Agent 套件(多角色团队工作区,给 Agent 配一整个团队)
|
|
53
|
-
manturhub suite install <slug> 安装套件为工作目录 ./<slug
|
|
106
|
+
manturhub suite install <slug> 安装套件为工作目录 ./<slug>/(不复制 API Key)
|
|
54
107
|
manturhub recipe [关键词] [--cat x] 搜配方(已验证创作的效果+可复现参数;分类 video/image/script)
|
|
55
108
|
manturhub recipe get <配方ID> 看配方提示词模板与调用参数(换掉 {占位符} 即可复刻)
|
|
56
|
-
manturhub mcp [--scope <域>] 启动 stdio MCP server(可选,给 MCP 原生客户端)
|
|
57
|
-
manturhub mcp-install [--client x] 把 MCP 接进客户端(可选:claude-code/codex/cursor/claude-desktop/all)
|
|
58
109
|
manturhub help | --version
|
|
59
110
|
|
|
60
111
|
环境变量:
|
|
61
112
|
MANTURHUB_KEY API Key(优先于配置文件)
|
|
62
|
-
MANTURHUB_BASE 网关地址(默认
|
|
113
|
+
MANTURHUB_BASE 网关地址(默认 ${getBaseUrl()})
|
|
63
114
|
|
|
64
|
-
推荐接入(CLI +
|
|
115
|
+
推荐接入(CLI + Skill):
|
|
65
116
|
npm i -g @manturhub/cli
|
|
66
|
-
manturhub login
|
|
117
|
+
manturhub login
|
|
67
118
|
manturhub init # 装使用 skill + 写引导,之后 agent 直接用 manturhub run/ls/upload 调算子
|
|
68
119
|
`;
|
|
69
120
|
|
|
70
121
|
async function main() {
|
|
71
122
|
maybeNotifyUpdate(VERSION);
|
|
123
|
+
if (cmd && !["help", "--help", "-h"].includes(cmd) && args.slice(1).some((arg) => arg === "--help" || arg === "-h")) {
|
|
124
|
+
console.log(HELP);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
72
127
|
switch (cmd) {
|
|
73
128
|
case "login": {
|
|
74
|
-
|
|
129
|
+
try {
|
|
130
|
+
assertFlags(args.slice(1), { value: ["key"], boolean: ["key-stdin"] });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error(error.message);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
if ((hasFlag("key") && !getFlag("key")) || (hasFlag("key-stdin") && getFlag("key-stdin"))) {
|
|
136
|
+
console.error("用法: manturhub login --key sk-xxx 或 manturhub login --key-stdin");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
const keyFromFlag = getFlag("key");
|
|
140
|
+
const useKeyStdin = hasFlag("key-stdin");
|
|
141
|
+
const keyFromStdin = useKeyStdin ? readFileSync(0, "utf8").trim() : null;
|
|
142
|
+
if (useKeyStdin && !keyFromStdin) {
|
|
143
|
+
console.error("stdin 中没有 API Key");
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
if (keyFromFlag && keyFromStdin) {
|
|
147
|
+
console.error("--key 和 --key-stdin 只能使用一个");
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
const key = keyFromFlag || keyFromStdin;
|
|
75
151
|
if (!key) {
|
|
76
152
|
// 无 --key → 浏览器授权流:生成链接,登录创建 key 后自动导入
|
|
77
153
|
await loginViaBrowser();
|
|
78
154
|
break;
|
|
79
155
|
}
|
|
80
|
-
const cfg = loadConfig();
|
|
81
|
-
cfg.key = key;
|
|
82
|
-
saveConfig(cfg);
|
|
83
156
|
const r = await apiFetch("/api/v1/me", { key });
|
|
84
157
|
if (r.ok) {
|
|
158
|
+
const cfg = loadConfig();
|
|
159
|
+
cfg.key = key;
|
|
160
|
+
saveConfig(cfg);
|
|
85
161
|
console.log(
|
|
86
|
-
`✓ Key
|
|
162
|
+
`✓ Key 已验证并保存。账号: ${r.json.email || "-"} 余额: ${rmbFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头)`
|
|
87
163
|
);
|
|
88
164
|
} else {
|
|
89
|
-
console.
|
|
90
|
-
|
|
91
|
-
);
|
|
165
|
+
console.error(`Key 验证失败(HTTP ${r.status}),未修改本地配置。请确认 key 是否正确、是否已激活。`);
|
|
166
|
+
process.exit(1);
|
|
92
167
|
}
|
|
93
168
|
break;
|
|
94
169
|
}
|
|
@@ -103,20 +178,30 @@ async function main() {
|
|
|
103
178
|
break;
|
|
104
179
|
}
|
|
105
180
|
|
|
106
|
-
case "mcp-install": {
|
|
107
|
-
runMcpInstall(getFlag("client"));
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
181
|
case "ls": {
|
|
182
|
+
try {
|
|
183
|
+
assertFlags(args.slice(1), { value: ["cat"], boolean: ["json"] });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error(error.message);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
112
188
|
const cat = getFlag("cat");
|
|
113
|
-
const
|
|
189
|
+
const cats = new Set(["text", "image", "video", "audio", "data"]);
|
|
190
|
+
if (cat && !cats.has(cat)) {
|
|
191
|
+
console.error(`未知分类: ${cat}(可选: ${[...cats].join(" | ")})`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
const r = await apiFetch("/api/v1/operators?status=online", { auth: "optional" });
|
|
114
195
|
if (!r.ok) {
|
|
115
196
|
console.error(`列表获取失败(HTTP ${r.status})`);
|
|
116
197
|
process.exit(1);
|
|
117
198
|
}
|
|
118
199
|
let ops = r.json.operators || r.json || [];
|
|
119
200
|
if (cat) ops = ops.filter((o) => o.cat === cat);
|
|
201
|
+
if (hasFlag("json")) {
|
|
202
|
+
console.log(JSON.stringify({ operators: ops }, null, 2));
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
120
205
|
console.log(`ManturHub 上线算子(${ops.length} 个):\n`);
|
|
121
206
|
for (const o of ops) {
|
|
122
207
|
console.log(` ${o.id.padEnd(26)} ${o.name} [${o.cat}]`);
|
|
@@ -132,12 +217,22 @@ async function main() {
|
|
|
132
217
|
console.error("用法: manturhub describe <算子ID> (查看入参字段)");
|
|
133
218
|
process.exit(1);
|
|
134
219
|
}
|
|
135
|
-
|
|
220
|
+
try {
|
|
221
|
+
assertFlags(args.slice(2), { boolean: ["json"] });
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error(error.message);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
const r = await apiFetch(`/api/v1/operators/${encodeURIComponent(op)}`, { auth: "optional" });
|
|
136
227
|
if (!r.ok) {
|
|
137
228
|
console.error(`获取失败(HTTP ${r.status}): ${op}`);
|
|
138
229
|
process.exit(1);
|
|
139
230
|
}
|
|
140
231
|
const o = r.json.operator || r.json;
|
|
232
|
+
if (hasFlag("json")) {
|
|
233
|
+
console.log(JSON.stringify(o, null, 2));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
141
236
|
console.log(`\n${o.id} ${o.name || ""} [${o.cat || "-"}] · ${o.status || "-"}`);
|
|
142
237
|
if (o.description) console.log(o.description);
|
|
143
238
|
const ps = o.params_schema || (o.meta && o.meta.params_schema);
|
|
@@ -150,12 +245,45 @@ async function main() {
|
|
|
150
245
|
}
|
|
151
246
|
if (ps.async) console.log(`\n异步算子:run 默认自动轮询到出结果(--no-wait 只拿 task_id)`);
|
|
152
247
|
} else {
|
|
153
|
-
console.log(`\n(该算子未声明入参 schema,详见
|
|
248
|
+
console.log(`\n(该算子未声明入参 schema,详见 ${getBaseUrl()}/marketplace/${o.id})`);
|
|
154
249
|
}
|
|
155
250
|
console.log(`\n调用: manturhub run ${o.id} --json '{...}'`);
|
|
156
251
|
break;
|
|
157
252
|
}
|
|
158
253
|
|
|
254
|
+
case "quote": {
|
|
255
|
+
const op = args[1];
|
|
256
|
+
if (!op || op.startsWith("--")) {
|
|
257
|
+
console.error("用法: manturhub quote <算子ID>");
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
assertFlags(args.slice(2), { boolean: ["json"] });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.error(error.message);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
const r = await apiFetch(`/api/v1/operators/${encodeURIComponent(op)}/quote`, { auth: "optional" });
|
|
267
|
+
if (!r.ok) {
|
|
268
|
+
console.error(`查询价格失败(HTTP ${r.status}): ${JSON.stringify(r.json)}`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
if (args.includes("--json")) {
|
|
272
|
+
const floor = Number(r.json.floor);
|
|
273
|
+
console.log(
|
|
274
|
+
JSON.stringify(
|
|
275
|
+
Number.isFinite(floor) ? { ...r.json, floor_usd: floor * 0.01 } : r.json,
|
|
276
|
+
null,
|
|
277
|
+
2
|
|
278
|
+
)
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
console.log(`${r.json.operatorId || op}: ${r.json.formula || "详见算子页"}`);
|
|
282
|
+
if (r.json.floor !== undefined) console.log(`最低扣费: ${rmbFor(r.json.floor)}(${r.json.floor} 馒头)`);
|
|
283
|
+
}
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
|
|
159
287
|
case "run": {
|
|
160
288
|
const op = args[1];
|
|
161
289
|
if (!op || op.startsWith("--")) {
|
|
@@ -164,7 +292,31 @@ async function main() {
|
|
|
164
292
|
}
|
|
165
293
|
let body = {};
|
|
166
294
|
const jsonArg = getFlag("json");
|
|
167
|
-
|
|
295
|
+
const jsonFile = getFlag("json-file");
|
|
296
|
+
if ((hasFlag("json") && !jsonArg) || (hasFlag("json-file") && !jsonFile)) {
|
|
297
|
+
console.error("--json / --json-file 需要非空值");
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
if (jsonArg && jsonFile) {
|
|
301
|
+
console.error("--json 和 --json-file 只能使用一个");
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
if (jsonArg || jsonFile) {
|
|
305
|
+
try {
|
|
306
|
+
assertRunControlFlags(args.slice(2));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error(error.message);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (jsonFile) {
|
|
313
|
+
try {
|
|
314
|
+
body = JSON.parse(readFileSync(jsonFile, "utf8"));
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.error(`--json-file 读取失败或不是合法 JSON:${e.message}`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
} else if (jsonArg) {
|
|
168
320
|
try {
|
|
169
321
|
body = JSON.parse(jsonArg);
|
|
170
322
|
} catch {
|
|
@@ -172,15 +324,29 @@ async function main() {
|
|
|
172
324
|
process.exit(1);
|
|
173
325
|
}
|
|
174
326
|
} else {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
327
|
+
try {
|
|
328
|
+
body = parseDynamicParams(args.slice(2));
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(error.message);
|
|
331
|
+
process.exit(1);
|
|
179
332
|
}
|
|
180
333
|
}
|
|
334
|
+
const detail = await apiFetch(`/api/v1/operators/${encodeURIComponent(op)}`, { auth: "optional" });
|
|
335
|
+
if (!detail.ok) {
|
|
336
|
+
console.error(`参数校验前无法读取算子 schema(HTTP ${detail.status}),已停止调用避免误扣费`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
const operator = detail.json.operator || detail.json;
|
|
340
|
+
const schema = operator.params_schema || operator.meta?.params_schema;
|
|
341
|
+
try {
|
|
342
|
+
body = validateParams(body, schema, { coerceStrings: !jsonArg && !jsonFile });
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error(`参数校验失败: ${error.message}`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
181
347
|
const r = await apiFetch(
|
|
182
348
|
`/api/v1/operators/${encodeURIComponent(op)}/invoke`,
|
|
183
|
-
{ method: "POST", body }
|
|
349
|
+
{ method: "POST", body, timeoutMs: 120000 }
|
|
184
350
|
);
|
|
185
351
|
// 异步算子(返回 poll_url)默认自动轮询到出结果;--no-wait 只拿 job_id。
|
|
186
352
|
const pollUrl = r.ok && r.json && r.json.poll_url;
|
|
@@ -210,6 +376,12 @@ async function main() {
|
|
|
210
376
|
console.error("用法: manturhub upload <本地文件> (图片/音频/视频 → 公网 URL)");
|
|
211
377
|
process.exit(1);
|
|
212
378
|
}
|
|
379
|
+
try {
|
|
380
|
+
assertFlags(args.slice(2));
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.error(error.message);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
213
385
|
const mime = mimeFromFile(file);
|
|
214
386
|
if (!mime) {
|
|
215
387
|
console.error(
|
|
@@ -217,16 +389,17 @@ async function main() {
|
|
|
217
389
|
);
|
|
218
390
|
process.exit(1);
|
|
219
391
|
}
|
|
220
|
-
let
|
|
392
|
+
let stat;
|
|
221
393
|
try {
|
|
222
|
-
|
|
394
|
+
stat = statSync(file);
|
|
395
|
+
if (!stat.isFile()) throw new Error("不是普通文件");
|
|
223
396
|
} catch (e) {
|
|
224
397
|
console.error(`读不到文件: ${file}(${e.message})`);
|
|
225
398
|
process.exit(1);
|
|
226
399
|
}
|
|
227
400
|
const p = await apiFetch("/api/v1/uploads/presign", {
|
|
228
401
|
method: "POST",
|
|
229
|
-
body: { filename: basename(file), size:
|
|
402
|
+
body: { filename: basename(file), size: stat.size, mime },
|
|
230
403
|
});
|
|
231
404
|
if (!p.ok || !p.json || !p.json.put_url) {
|
|
232
405
|
console.error(`presign 失败(HTTP ${p.status}): ${JSON.stringify(p.json)}`);
|
|
@@ -234,8 +407,10 @@ async function main() {
|
|
|
234
407
|
}
|
|
235
408
|
const put = await fetch(p.json.put_url, {
|
|
236
409
|
method: "PUT",
|
|
237
|
-
headers: { "Content-Type": mime },
|
|
238
|
-
body:
|
|
410
|
+
headers: { "Content-Type": mime, "Content-Length": String(stat.size) },
|
|
411
|
+
body: createReadStream(file),
|
|
412
|
+
duplex: "half",
|
|
413
|
+
signal: AbortSignal.timeout(10 * 60 * 1000),
|
|
239
414
|
});
|
|
240
415
|
if (!put.ok) {
|
|
241
416
|
console.error(`上传到存储失败(HTTP ${put.status})`);
|
|
@@ -251,30 +426,59 @@ async function main() {
|
|
|
251
426
|
console.error("用法: manturhub status <poll_url> (poll_url 来自 run --no-wait 的返回)");
|
|
252
427
|
process.exit(1);
|
|
253
428
|
}
|
|
254
|
-
|
|
429
|
+
try {
|
|
430
|
+
assertFlags(args.slice(2));
|
|
431
|
+
} catch (error) {
|
|
432
|
+
console.error(error.message);
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
const r = await apiFetch(pu);
|
|
255
436
|
console.log(JSON.stringify(r.json, null, 2));
|
|
256
437
|
if (!r.ok) process.exit(1);
|
|
257
438
|
break;
|
|
258
439
|
}
|
|
259
440
|
|
|
260
441
|
case "balance": {
|
|
442
|
+
try {
|
|
443
|
+
assertFlags(args.slice(1), { boolean: ["json"] });
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.error(error.message);
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
261
448
|
const r = await apiFetch("/api/v1/me");
|
|
262
449
|
if (!r.ok) {
|
|
263
450
|
console.error(`查询失败(HTTP ${r.status})`);
|
|
264
451
|
process.exit(1);
|
|
265
452
|
}
|
|
266
453
|
console.log(
|
|
267
|
-
|
|
454
|
+
hasFlag("json")
|
|
455
|
+
? JSON.stringify({ ...r.json, balance_rmb: Number(r.json.balance) * 0.1 }, null, 2)
|
|
456
|
+
: `余额: ${rmbFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头) 账号: ${r.json.email || "-"}`
|
|
268
457
|
);
|
|
269
458
|
break;
|
|
270
459
|
}
|
|
271
460
|
|
|
272
461
|
case "skill": {
|
|
273
462
|
const sub = args[1];
|
|
274
|
-
if (sub === "ls" || sub === "list")
|
|
275
|
-
|
|
463
|
+
if (sub === "ls" || sub === "list") {
|
|
464
|
+
try {
|
|
465
|
+
assertFlags(args.slice(2), { boolean: ["json"] });
|
|
466
|
+
} catch (error) {
|
|
467
|
+
console.error(error.message);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
await skillLs({ json: hasFlag("json") });
|
|
471
|
+
} else if (sub === "add" || sub === "install") {
|
|
472
|
+
try {
|
|
473
|
+
assertFlags(args.slice(3), { value: ["client"] });
|
|
474
|
+
} catch (error) {
|
|
475
|
+
console.error(error.message);
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
await skillAdd(args[2], getFlag("client", "claude-code"));
|
|
479
|
+
}
|
|
276
480
|
else {
|
|
277
|
-
console.error("用法: manturhub skill ls | manturhub skill add <slug>");
|
|
481
|
+
console.error("用法: manturhub skill ls | manturhub skill add <slug> [--client claude-code|codex|all]");
|
|
278
482
|
process.exit(1);
|
|
279
483
|
}
|
|
280
484
|
break;
|
|
@@ -283,8 +487,22 @@ async function main() {
|
|
|
283
487
|
case "suite":
|
|
284
488
|
case "suites": {
|
|
285
489
|
const sub = args[1];
|
|
286
|
-
if (sub === "ls" || sub === "list" || sub === undefined)
|
|
287
|
-
|
|
490
|
+
if (sub === "ls" || sub === "list" || sub === undefined) {
|
|
491
|
+
try {
|
|
492
|
+
assertFlags(args.slice(sub === undefined ? 1 : 2), { boolean: ["json"] });
|
|
493
|
+
} catch (error) {
|
|
494
|
+
console.error(error.message);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
await suiteLs({ json: hasFlag("json") });
|
|
498
|
+
}
|
|
499
|
+
else if (sub === "install" || sub === "add") {
|
|
500
|
+
if (args.length > 4 || args[3]?.startsWith("--")) {
|
|
501
|
+
console.error("用法: manturhub suite install <slug> [目录]");
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
await suiteInstall(args[2], args[3]);
|
|
505
|
+
}
|
|
288
506
|
else {
|
|
289
507
|
console.error("用法: manturhub suite ls | manturhub suite install <slug> [目录]");
|
|
290
508
|
process.exit(1);
|
|
@@ -301,36 +519,54 @@ async function main() {
|
|
|
301
519
|
console.error("用法: manturhub recipe get <配方ID>");
|
|
302
520
|
process.exit(1);
|
|
303
521
|
}
|
|
304
|
-
|
|
522
|
+
try {
|
|
523
|
+
assertFlags(args.slice(3), { boolean: ["json"] });
|
|
524
|
+
} catch (error) {
|
|
525
|
+
console.error(error.message);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
const r = await apiFetch(`/api/v1/recipes/${encodeURIComponent(slug)}`, { auth: "optional" });
|
|
305
529
|
if (!r.ok) {
|
|
306
530
|
console.error(`获取失败(HTTP ${r.status}): ${slug}`);
|
|
307
531
|
process.exit(1);
|
|
308
532
|
}
|
|
309
533
|
const d = r.json;
|
|
534
|
+
if (args.includes("--json")) {
|
|
535
|
+
console.log(JSON.stringify(d, null, 2));
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
310
538
|
console.log(`\n${d.title} [${d.cat}] · 复刻 ${d.cost_estimate}`);
|
|
311
539
|
console.log(`${d.summary}\n`);
|
|
312
540
|
if (d.sample_url) {
|
|
313
541
|
const su = d.sample_url.startsWith("http")
|
|
314
542
|
? d.sample_url
|
|
315
|
-
:
|
|
543
|
+
: `${getBaseUrl()}${d.sample_url}`;
|
|
316
544
|
console.log(`效果样片: ${su}`);
|
|
317
545
|
}
|
|
318
|
-
console.log(`配方页:
|
|
546
|
+
console.log(`配方页: ${getBaseUrl()}/recipes/${d.slug}\n`);
|
|
319
547
|
if (d.prompt_template) console.log(`提示词模板:\n${d.prompt_template}\n`);
|
|
320
|
-
console.log("
|
|
321
|
-
console.log(
|
|
548
|
+
console.log("结构化参数(把 {占位符} 换成用户内容):");
|
|
549
|
+
console.log(JSON.stringify(d.params_json || {}, null, 2));
|
|
550
|
+
console.log("\n安全执行:将每步 params 写入 JSON 文件,再运行 `manturhub run <算子ID> --json-file <文件>`。");
|
|
322
551
|
if (d.sample_text) console.log(`\n效果节选:\n${d.sample_text}`);
|
|
323
552
|
break;
|
|
324
553
|
}
|
|
325
554
|
// manturhub recipe [ls|search] [关键词] [--cat video|image|script]
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
: args[2] && !args[2].startsWith("--")
|
|
330
|
-
? args[2]
|
|
331
|
-
: "";
|
|
555
|
+
const listArgs = args.slice(1);
|
|
556
|
+
let cursor = ["ls", "list", "search"].includes(listArgs[0]) ? 1 : 0;
|
|
557
|
+
const kwArg = listArgs[cursor] && !listArgs[cursor].startsWith("--") ? listArgs[cursor++] : "";
|
|
332
558
|
const cat = getFlag("cat");
|
|
333
|
-
|
|
559
|
+
try {
|
|
560
|
+
assertFlags(listArgs.slice(cursor), { value: ["cat"], boolean: ["json"] });
|
|
561
|
+
} catch (error) {
|
|
562
|
+
console.error(error.message);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
if (cat && !new Set(["video", "image", "script"]).has(cat)) {
|
|
566
|
+
console.error(`未知配方分类: ${cat}(可选: video | image | script)`);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
const r = await apiFetch(`/api/v1/recipes${cat ? `?cat=${encodeURIComponent(cat)}` : ""}`, { auth: "optional" });
|
|
334
570
|
if (!r.ok) {
|
|
335
571
|
console.error(`配方列表获取失败(HTTP ${r.status})`);
|
|
336
572
|
process.exit(1);
|
|
@@ -342,12 +578,16 @@ async function main() {
|
|
|
342
578
|
`${x.title}${x.summary}${(x.tags || []).join(",")}`.toLowerCase().includes(k)
|
|
343
579
|
);
|
|
344
580
|
}
|
|
581
|
+
if (hasFlag("json")) {
|
|
582
|
+
console.log(JSON.stringify({ recipes: list }, null, 2));
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
345
585
|
console.log(`ManturHub 配方(${list.length} 个):\n`);
|
|
346
586
|
for (const x of list) {
|
|
347
587
|
console.log(` ${x.slug.padEnd(32)} [${x.cat}] ${x.title} · 复刻 ${x.cost_estimate}`);
|
|
348
588
|
}
|
|
349
589
|
console.log(
|
|
350
|
-
`\n用 \`manturhub recipe get <配方ID>\` 看提示词模板与调用参数;挑选体验更好的网页版:
|
|
590
|
+
`\n用 \`manturhub recipe get <配方ID>\` 看提示词模板与调用参数;挑选体验更好的网页版: ${getBaseUrl()}/recipes`
|
|
351
591
|
);
|
|
352
592
|
break;
|
|
353
593
|
}
|
package/lib/api.js
CHANGED
|
@@ -1,21 +1,56 @@
|
|
|
1
1
|
import { getKey, getBaseUrl } from "./config.js";
|
|
2
2
|
|
|
3
|
-
// Thin REST client against the ManturHub gateway.
|
|
4
|
-
export async function apiFetch(
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// Thin REST client against the ManturHub gateway. Public discovery calls may omit auth.
|
|
4
|
+
export async function apiFetch(
|
|
5
|
+
path,
|
|
6
|
+
{ method = "GET", body, key, auth = "required", timeoutMs = 30000 } = {}
|
|
7
|
+
) {
|
|
8
|
+
const apiKey = key === undefined ? getKey() : key;
|
|
9
|
+
if (auth === "required" && !apiKey) {
|
|
7
10
|
throw new Error(
|
|
8
|
-
"未配置 API Key。运行 `manturhub login
|
|
11
|
+
"未配置 API Key。运行 `manturhub login`,或设置环境变量 MANTURHUB_KEY。"
|
|
9
12
|
);
|
|
10
13
|
}
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
const base = new URL(getBaseUrl());
|
|
15
|
+
const url = new URL(path, base.href.endsWith("/") ? base.href : base.href + "/");
|
|
16
|
+
if (url.origin !== base.origin) {
|
|
17
|
+
throw new Error(`拒绝向 ManturHub 之外的地址发送请求: ${url.origin}`);
|
|
18
|
+
}
|
|
19
|
+
const request = (includeKey) =>
|
|
20
|
+
fetch(url, {
|
|
21
|
+
method,
|
|
22
|
+
headers: {
|
|
23
|
+
...(includeKey && apiKey ? { "x-api-key": apiKey } : {}),
|
|
24
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
25
|
+
},
|
|
26
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
27
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
28
|
+
});
|
|
29
|
+
const requestWithRetry = async (includeKey) => {
|
|
30
|
+
const attempts = method === "GET" ? 2 : 1;
|
|
31
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
const response = await request(includeKey);
|
|
34
|
+
const retryable = [429, 502, 503, 504].includes(response.status);
|
|
35
|
+
if (!retryable || attempt === attempts) return response;
|
|
36
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
37
|
+
const retryAfter = retryAfterHeader === null ? Number.NaN : Number(retryAfterHeader);
|
|
38
|
+
await response.body?.cancel();
|
|
39
|
+
await new Promise((resolve) =>
|
|
40
|
+
setTimeout(resolve, Number.isFinite(retryAfter) ? Math.min(retryAfter * 1000, 10000) : 250)
|
|
41
|
+
);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (attempt === attempts) throw error;
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
let res = await requestWithRetry(true);
|
|
49
|
+
// An expired local key must not block public discovery. Retry only safe optional GETs.
|
|
50
|
+
if (auth === "optional" && method === "GET" && apiKey && res.status === 401) {
|
|
51
|
+
await res.body?.cancel();
|
|
52
|
+
res = await requestWithRetry(false);
|
|
53
|
+
}
|
|
19
54
|
const text = await res.text();
|
|
20
55
|
let json;
|
|
21
56
|
try {
|
|
@@ -33,7 +68,7 @@ export async function pollJob(pollUrl, { intervalMs = 8000, maxMs = 1200000, onT
|
|
|
33
68
|
const start = Date.now();
|
|
34
69
|
let last = null;
|
|
35
70
|
while (Date.now() - start < maxMs) {
|
|
36
|
-
const r = await apiFetch(pollUrl);
|
|
71
|
+
const r = await apiFetch(pollUrl, { timeoutMs: 30000 });
|
|
37
72
|
last = r.json;
|
|
38
73
|
const s = r.json && r.json.status;
|
|
39
74
|
if (onTick) onTick(r.json);
|