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