@manturhub/cli 0.6.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/bin/cli.js CHANGED
@@ -1,14 +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, runMcpInstall } from "../lib/setup.js";
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
+ import { loginViaBrowser } from "../lib/login-link.js";
8
9
  import { maybeNotifyUpdate } from "../lib/update-check.js";
9
- import { readFileSync } from "node:fs";
10
+ import { createReadStream, readFileSync, statSync } from "node:fs";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { dirname, join, basename, extname } from "node:path";
13
+ import { parseDynamicParams, validateParams } from "../lib/params.js";
12
14
 
13
15
  // 本地文件 → MIME(presign 只接受 image/audio/video)
14
16
  const MIME_BY_EXT = {
@@ -30,62 +32,138 @@ const args = process.argv.slice(2);
30
32
  const cmd = args[0];
31
33
 
32
34
  function getFlag(name, def) {
35
+ const inline = args.find((arg) => arg.startsWith(`--${name}=`));
36
+ if (inline) return inline.slice(name.length + 3);
33
37
  const i = args.indexOf(`--${name}`);
34
- return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def;
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` : "-";
35
85
  }
36
86
 
37
87
  const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
38
88
 
39
89
  用法:
40
- manturhub login --key sk-xxx 配置 API Key(存 ~/.manturhub/config.json)
41
- manturhub ls [--cat <分类>] 列出上线算子(分类: text/image/video/audio/data
42
- manturhub describe <算子ID> 查看算子入参字段(喂参数前先看)
90
+ manturhub login 浏览器授权登录(生成链接→登录创建 Key→自动导入,推荐)
91
+ manturhub login --key sk-xxx 手动配置 API Key(存 ~/.manturhub/config.json
92
+ manturhub login --key-stdin 从 stdin 安全读取 API Key
93
+ manturhub ls [--cat <分类>] [--json] 列出上线算子(无需登录)
94
+ manturhub describe <算子ID> [--json] 查看算子入参字段(无需登录)
95
+ manturhub quote <算子ID> 查询实时计费公式(不要使用 Skill 内的历史价格)
43
96
  manturhub run <算子ID> --json '{}' 调用算子(异步算子自动轮询到出结果;--no-wait 只拿 job_id)
97
+ manturhub run <算子ID> --json-file x.json 从文件读参数(prompt 来自配方/用户时更安全)
44
98
  manturhub upload <本地文件> 上传图片/音频/视频 → 公网 URL(喂算子前先转换本地文件)
45
99
  manturhub status <poll_url> 查异步任务状态(配合 run --no-wait)
46
100
  manturhub balance 查询馒头余额
47
101
  manturhub init 装「ManturHub 使用 skill」+ 写 agent 引导(推荐:让 agent 会用算子)
48
102
  manturhub skill ls 列出平台 Skill(业务成品流程,如爆款复刻)
49
- manturhub skill add <slug> 安装 Skill 到 ~/.claude/skills/<slug>/
103
+ manturhub skill add <slug> [--client claude-code|codex|all]
104
+ 安装 Skill 到指定 Agent
50
105
  manturhub suite ls 列出 Agent 套件(多角色团队工作区,给 Agent 配一整个团队)
51
- manturhub suite install <slug> 安装套件为工作目录 ./<slug>/(自动写入 API Key,任意 Agent 打开即用)
106
+ manturhub suite install <slug> 安装套件为工作目录 ./<slug>/(不复制 API Key
52
107
  manturhub recipe [关键词] [--cat x] 搜配方(已验证创作的效果+可复现参数;分类 video/image/script)
53
108
  manturhub recipe get <配方ID> 看配方提示词模板与调用参数(换掉 {占位符} 即可复刻)
54
- manturhub mcp [--scope <域>] 启动 stdio MCP server(可选,给 MCP 原生客户端)
55
- manturhub mcp-install [--client x] 把 MCP 接进客户端(可选:claude-code/codex/cursor/claude-desktop/all)
56
109
  manturhub help | --version
57
110
 
58
111
  环境变量:
59
112
  MANTURHUB_KEY API Key(优先于配置文件)
60
- MANTURHUB_BASE 网关地址(默认 https://hub.mantur.cn
113
+ MANTURHUB_BASE 网关地址(默认 ${getBaseUrl()}
61
114
 
62
- 推荐接入(CLI + skill,最稳,无需 MCP):
115
+ 推荐接入(CLI + Skill):
63
116
  npm i -g @manturhub/cli
64
- manturhub login --key sk-xxx
117
+ manturhub login
65
118
  manturhub init # 装使用 skill + 写引导,之后 agent 直接用 manturhub run/ls/upload 调算子
66
119
  `;
67
120
 
68
121
  async function main() {
69
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
+ }
70
127
  switch (cmd) {
71
128
  case "login": {
72
- const key = getFlag("key");
73
- if (!key) {
74
- console.error("用法: manturhub login --key sk-xxx");
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 只能使用一个");
75
148
  process.exit(1);
76
149
  }
77
- const cfg = loadConfig();
78
- cfg.key = key;
79
- saveConfig(cfg);
150
+ const key = keyFromFlag || keyFromStdin;
151
+ if (!key) {
152
+ // 无 --key → 浏览器授权流:生成链接,登录创建 key 后自动导入
153
+ await loginViaBrowser();
154
+ break;
155
+ }
80
156
  const r = await apiFetch("/api/v1/me", { key });
81
157
  if (r.ok) {
158
+ const cfg = loadConfig();
159
+ cfg.key = key;
160
+ saveConfig(cfg);
82
161
  console.log(
83
- `✓ Key 已保存。账号: ${r.json.email || "-"} 余额: ${r.json.balance ?? "-"} 馒头`
162
+ `✓ Key 已验证并保存。账号: ${r.json.email || "-"} 余额: ${rmbFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头)`
84
163
  );
85
164
  } else {
86
- console.log(
87
- `✓ Key 已保存,但验证失败(HTTP ${r.status})。请确认 key 是否正确、是否已激活。`
88
- );
165
+ console.error(`Key 验证失败(HTTP ${r.status}),未修改本地配置。请确认 key 是否正确、是否已激活。`);
166
+ process.exit(1);
89
167
  }
90
168
  break;
91
169
  }
@@ -100,20 +178,30 @@ async function main() {
100
178
  break;
101
179
  }
102
180
 
103
- case "mcp-install": {
104
- runMcpInstall(getFlag("client"));
105
- break;
106
- }
107
-
108
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
+ }
109
188
  const cat = getFlag("cat");
110
- const r = await apiFetch("/api/v1/operators?status=online");
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" });
111
195
  if (!r.ok) {
112
196
  console.error(`列表获取失败(HTTP ${r.status})`);
113
197
  process.exit(1);
114
198
  }
115
199
  let ops = r.json.operators || r.json || [];
116
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
+ }
117
205
  console.log(`ManturHub 上线算子(${ops.length} 个):\n`);
118
206
  for (const o of ops) {
119
207
  console.log(` ${o.id.padEnd(26)} ${o.name} [${o.cat}]`);
@@ -129,12 +217,22 @@ async function main() {
129
217
  console.error("用法: manturhub describe <算子ID> (查看入参字段)");
130
218
  process.exit(1);
131
219
  }
132
- const r = await apiFetch(`/api/v1/operators/${encodeURIComponent(op)}`);
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" });
133
227
  if (!r.ok) {
134
228
  console.error(`获取失败(HTTP ${r.status}): ${op}`);
135
229
  process.exit(1);
136
230
  }
137
231
  const o = r.json.operator || r.json;
232
+ if (hasFlag("json")) {
233
+ console.log(JSON.stringify(o, null, 2));
234
+ break;
235
+ }
138
236
  console.log(`\n${o.id} ${o.name || ""} [${o.cat || "-"}] · ${o.status || "-"}`);
139
237
  if (o.description) console.log(o.description);
140
238
  const ps = o.params_schema || (o.meta && o.meta.params_schema);
@@ -147,12 +245,45 @@ async function main() {
147
245
  }
148
246
  if (ps.async) console.log(`\n异步算子:run 默认自动轮询到出结果(--no-wait 只拿 task_id)`);
149
247
  } else {
150
- console.log(`\n(该算子未声明入参 schema,详见 https://hub.mantur.cn/marketplace/${o.id})`);
248
+ console.log(`\n(该算子未声明入参 schema,详见 ${getBaseUrl()}/marketplace/${o.id})`);
151
249
  }
152
250
  console.log(`\n调用: manturhub run ${o.id} --json '{...}'`);
153
251
  break;
154
252
  }
155
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
+
156
287
  case "run": {
157
288
  const op = args[1];
158
289
  if (!op || op.startsWith("--")) {
@@ -161,7 +292,31 @@ async function main() {
161
292
  }
162
293
  let body = {};
163
294
  const jsonArg = getFlag("json");
164
- if (jsonArg) {
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) {
165
320
  try {
166
321
  body = JSON.parse(jsonArg);
167
322
  } catch {
@@ -169,15 +324,29 @@ async function main() {
169
324
  process.exit(1);
170
325
  }
171
326
  } else {
172
- for (let i = 2; i < args.length; i += 2) {
173
- if (args[i] && args[i].startsWith("--") && args[i + 1] !== undefined) {
174
- body[args[i].slice(2)] = args[i + 1];
175
- }
327
+ try {
328
+ body = parseDynamicParams(args.slice(2));
329
+ } catch (error) {
330
+ console.error(error.message);
331
+ process.exit(1);
176
332
  }
177
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
+ }
178
347
  const r = await apiFetch(
179
348
  `/api/v1/operators/${encodeURIComponent(op)}/invoke`,
180
- { method: "POST", body }
349
+ { method: "POST", body, timeoutMs: 120000 }
181
350
  );
182
351
  // 异步算子(返回 poll_url)默认自动轮询到出结果;--no-wait 只拿 job_id。
183
352
  const pollUrl = r.ok && r.json && r.json.poll_url;
@@ -207,6 +376,12 @@ async function main() {
207
376
  console.error("用法: manturhub upload <本地文件> (图片/音频/视频 → 公网 URL)");
208
377
  process.exit(1);
209
378
  }
379
+ try {
380
+ assertFlags(args.slice(2));
381
+ } catch (error) {
382
+ console.error(error.message);
383
+ process.exit(1);
384
+ }
210
385
  const mime = mimeFromFile(file);
211
386
  if (!mime) {
212
387
  console.error(
@@ -214,16 +389,17 @@ async function main() {
214
389
  );
215
390
  process.exit(1);
216
391
  }
217
- let buf;
392
+ let stat;
218
393
  try {
219
- buf = readFileSync(file);
394
+ stat = statSync(file);
395
+ if (!stat.isFile()) throw new Error("不是普通文件");
220
396
  } catch (e) {
221
397
  console.error(`读不到文件: ${file}(${e.message})`);
222
398
  process.exit(1);
223
399
  }
224
400
  const p = await apiFetch("/api/v1/uploads/presign", {
225
401
  method: "POST",
226
- body: { filename: basename(file), size: buf.length, mime },
402
+ body: { filename: basename(file), size: stat.size, mime },
227
403
  });
228
404
  if (!p.ok || !p.json || !p.json.put_url) {
229
405
  console.error(`presign 失败(HTTP ${p.status}): ${JSON.stringify(p.json)}`);
@@ -231,8 +407,10 @@ async function main() {
231
407
  }
232
408
  const put = await fetch(p.json.put_url, {
233
409
  method: "PUT",
234
- headers: { "Content-Type": mime },
235
- body: buf,
410
+ headers: { "Content-Type": mime, "Content-Length": String(stat.size) },
411
+ body: createReadStream(file),
412
+ duplex: "half",
413
+ signal: AbortSignal.timeout(10 * 60 * 1000),
236
414
  });
237
415
  if (!put.ok) {
238
416
  console.error(`上传到存储失败(HTTP ${put.status})`);
@@ -248,30 +426,59 @@ async function main() {
248
426
  console.error("用法: manturhub status <poll_url> (poll_url 来自 run --no-wait 的返回)");
249
427
  process.exit(1);
250
428
  }
251
- const r = await apiFetch(pu.startsWith("/") ? pu : "/" + pu);
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);
252
436
  console.log(JSON.stringify(r.json, null, 2));
253
437
  if (!r.ok) process.exit(1);
254
438
  break;
255
439
  }
256
440
 
257
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
+ }
258
448
  const r = await apiFetch("/api/v1/me");
259
449
  if (!r.ok) {
260
450
  console.error(`查询失败(HTTP ${r.status})`);
261
451
  process.exit(1);
262
452
  }
263
453
  console.log(
264
- `馒头余额: ${r.json.balance ?? "-"} 账号: ${r.json.email || "-"}`
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 || "-"}`
265
457
  );
266
458
  break;
267
459
  }
268
460
 
269
461
  case "skill": {
270
462
  const sub = args[1];
271
- if (sub === "ls" || sub === "list") await skillLs();
272
- else if (sub === "add" || sub === "install") await skillAdd(args[2]);
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
+ }
273
480
  else {
274
- console.error("用法: manturhub skill ls | manturhub skill add <slug>");
481
+ console.error("用法: manturhub skill ls | manturhub skill add <slug> [--client claude-code|codex|all]");
275
482
  process.exit(1);
276
483
  }
277
484
  break;
@@ -280,8 +487,22 @@ async function main() {
280
487
  case "suite":
281
488
  case "suites": {
282
489
  const sub = args[1];
283
- if (sub === "ls" || sub === "list" || sub === undefined) await suiteLs();
284
- else if (sub === "install" || sub === "add") await suiteInstall(args[2], args[3]);
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
+ }
285
506
  else {
286
507
  console.error("用法: manturhub suite ls | manturhub suite install <slug> [目录]");
287
508
  process.exit(1);
@@ -298,36 +519,54 @@ async function main() {
298
519
  console.error("用法: manturhub recipe get <配方ID>");
299
520
  process.exit(1);
300
521
  }
301
- const r = await apiFetch(`/api/v1/recipes/${encodeURIComponent(slug)}`);
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" });
302
529
  if (!r.ok) {
303
530
  console.error(`获取失败(HTTP ${r.status}): ${slug}`);
304
531
  process.exit(1);
305
532
  }
306
533
  const d = r.json;
534
+ if (args.includes("--json")) {
535
+ console.log(JSON.stringify(d, null, 2));
536
+ break;
537
+ }
307
538
  console.log(`\n${d.title} [${d.cat}] · 复刻 ${d.cost_estimate}`);
308
539
  console.log(`${d.summary}\n`);
309
540
  if (d.sample_url) {
310
541
  const su = d.sample_url.startsWith("http")
311
542
  ? d.sample_url
312
- : `https://hub.mantur.cn${d.sample_url}`;
543
+ : `${getBaseUrl()}${d.sample_url}`;
313
544
  console.log(`效果样片: ${su}`);
314
545
  }
315
- console.log(`配方页: https://hub.mantur.cn/recipes/${d.slug}\n`);
546
+ console.log(`配方页: ${getBaseUrl()}/recipes/${d.slug}\n`);
316
547
  if (d.prompt_template) console.log(`提示词模板:\n${d.prompt_template}\n`);
317
- console.log("调用(把 {占位符} 换成用户内容后执行):");
318
- console.log(`manturhub run ${d.operator_id} --json '${JSON.stringify(d.params_json || {})}'`);
548
+ console.log("结构化参数(把 {占位符} 换成用户内容):");
549
+ console.log(JSON.stringify(d.params_json || {}, null, 2));
550
+ console.log("\n安全执行:将每步 params 写入 JSON 文件,再运行 `manturhub run <算子ID> --json-file <文件>`。");
319
551
  if (d.sample_text) console.log(`\n效果节选:\n${d.sample_text}`);
320
552
  break;
321
553
  }
322
554
  // manturhub recipe [ls|search] [关键词] [--cat video|image|script]
323
- const kwArg =
324
- sub && !["ls", "list", "search"].includes(sub) && !sub.startsWith("--")
325
- ? sub
326
- : args[2] && !args[2].startsWith("--")
327
- ? args[2]
328
- : "";
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++] : "";
329
558
  const cat = getFlag("cat");
330
- const r = await apiFetch(`/api/v1/recipes${cat ? `?cat=${encodeURIComponent(cat)}` : ""}`);
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" });
331
570
  if (!r.ok) {
332
571
  console.error(`配方列表获取失败(HTTP ${r.status})`);
333
572
  process.exit(1);
@@ -339,12 +578,16 @@ async function main() {
339
578
  `${x.title}${x.summary}${(x.tags || []).join(",")}`.toLowerCase().includes(k)
340
579
  );
341
580
  }
581
+ if (hasFlag("json")) {
582
+ console.log(JSON.stringify({ recipes: list }, null, 2));
583
+ break;
584
+ }
342
585
  console.log(`ManturHub 配方(${list.length} 个):\n`);
343
586
  for (const x of list) {
344
587
  console.log(` ${x.slug.padEnd(32)} [${x.cat}] ${x.title} · 复刻 ${x.cost_estimate}`);
345
588
  }
346
589
  console.log(
347
- `\n用 \`manturhub recipe get <配方ID>\` 看提示词模板与调用参数;挑选体验更好的网页版: https://hub.mantur.cn/recipes`
590
+ `\n用 \`manturhub recipe get <配方ID>\` 看提示词模板与调用参数;挑选体验更好的网页版: ${getBaseUrl()}/recipes`
348
591
  );
349
592
  break;
350
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. 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);