@manturhub/cli 0.9.8 → 0.9.10-dev.20260810.5

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 CHANGED
@@ -15,14 +15,25 @@ manturhub login
15
15
  printf '%s' "$YOUR_MANTURHUB_KEY" | manturhub login --key-stdin
16
16
  ```
17
17
 
18
- CLI 默认连接生产站 `https://hub.mantur.ai`,Key 也必须在该生产站创建。`hub.mantur.cn` 是独立测试环境,其 Key 不能用于生产站。
18
+ CLI 默认连接生产站 `https://hub.mantur.ai`,Key 也必须在该生产站创建。
19
+
20
+ 开发和测试环境使用开发网关:
21
+
22
+ ```bash
23
+ MANTURHUB_BASE=http://gateway-dev.guiyi.cn manturhub login
24
+ MANTURHUB_BASE=http://gateway-dev.guiyi.cn manturhub ls
25
+ ```
26
+
27
+ 开发网关是唯一允许使用远程 HTTP 的例外;生产站和其他远程部署必须使用 HTTPS。开发网关与生产站的 API Key 不通用。
28
+ 算子列表和详情通过 `/api/openapi/v1/operators` 获取,只返回当前 Key 已授权的算子。
29
+ 文件先通过 `POST /api/openapi/v1/files/upload` 提交 `files: [{ fileName, contentType, size }]`,从 `data.files[0]` 获取 `uploadUrl` 和 `downloadUrl`,再以 PUT 将文件流式上传到 `uploadUrl`;上传成功后命令输出 `downloadUrl`。API Key 只发送给 ManturHub 网关,不会发送给对象存储。
19
30
 
20
31
  需要 Node.js ≥ 18。也可免安装运行:`npx -y @manturhub/cli <命令>`。
21
32
 
22
33
  ## 快速开始
23
34
 
24
35
  ```bash
25
- # 发现与查看算子无需登录
36
+ # 发现与查看当前 Key 已授权的算子
26
37
  manturhub ls --cat text
27
38
  manturhub describe 电商文案生成
28
39
 
@@ -35,7 +46,7 @@ CLI 会在付费调用前按算子实时 schema 校验未知字段、必填项
35
46
 
36
47
  面向用户的列表、详情、报价、确认和结算统一显示中文算子名,也可直接用中文名调用。英文算子 ID 仅保留在 `--json` 机器输出和内部 API 请求中,供 Agent 与脚本稳定使用。
37
48
 
38
- 在 Agent 或脚本等非交互环境中,第一次运行只返回报价和 `quote_id`,不会调用或扣费;Agent 向用户确认后,使用提示中的 `--confirm <quote_id>` 执行。报价 5 分钟内有效且只能使用一次。
49
+ 在 Agent 或脚本等非交互环境中,第一次运行只返回报价和 `confirmation_token`,不会调用或扣费;Agent 向用户确认后,使用提示中的 `--confirm <confirmation_token>` 执行。确认标识绑定算子、入参、价格版本和幂等 ID:内容或价格变化时拒绝调用,同一次调用重试时复用幂等 ID,避免重复扣费。
39
50
 
40
51
  ## 主要命令
41
52
 
@@ -44,9 +55,9 @@ CLI 会在付费调用前按算子实时 schema 校验未知字段、必填项
44
55
  | `manturhub ls [--cat image\|video\|audio\|text\|data] [--json]` | 实时列出上线算子 |
45
56
  | `manturhub describe <中文算子名> [--json]` | 查看精确入参与异步属性 |
46
57
  | `manturhub quote <中文算子名> [--json]` | 查询 Java API 返回的实时计费公式 |
47
- | `manturhub run <中文算子名> --json '{}' [--confirm <quote_id>] [--no-wait]` | 调用算子;非交互确认传报价 ID,异步任务默认轮询到终态 |
58
+ | `manturhub run <中文算子名> --json '{}' [--confirm <confirmation_token>] [--no-wait]` | 调用算子;非交互确认传确认标识,异步任务默认轮询到终态 |
48
59
  | `manturhub run <中文算子名> --json-file params.json` | 从文件读取参数,适合长提示词和自动化 |
49
- | `manturhub upload <本地文件>` | 流式上传图片、音频或视频并输出公网 URL |
60
+ | `manturhub upload <本地文件>` | 通过预签名 PUT 流式上传图片、音频或视频,并输出 `downloadUrl` |
50
61
  | `manturhub status <poll_url>` | 查询 `run --no-wait` 返回的异步任务 |
51
62
  | `manturhub balance [--json]` | 查询余额及美元等值(1 馒头 = $0.01 USD) |
52
63
  | `manturhub skill ls [--json]` / `skill add <slug> [--client codex]` | 浏览并从当前 ManturHub 站点安装业务 Skill |
package/bin/cli.js CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { getBaseUrl, saveConfig, loadConfig } from "../lib/config.js";
3
- import { apiFetch, pollJob } from "../lib/api.js";
2
+ import { getBaseUrl, isAllowedHttpUrl, saveConfig, loadConfig } from "../lib/config.js";
3
+ import {
4
+ apiFetch,
5
+ BALANCE_PATH,
6
+ fetchPublicCatalog,
7
+ parseBalanceResponse,
8
+ pollJob,
9
+ PUBLIC_RECIPES_PATH,
10
+ unwrapGatewayData,
11
+ } from "../lib/api.js";
4
12
  import { runInit } from "../lib/setup.js";
5
13
  import { skillLs, skillAdd, skillOutdated, skillUpdate } from "../lib/skill-install.js";
6
14
  import { suiteLs, suiteInstall } from "../lib/suite-install.js";
@@ -8,6 +16,7 @@ import { loginViaBrowser } from "../lib/login-link.js";
8
16
  import { maybeNotifyUpdate } from "../lib/update-check.js";
9
17
  import { maybeNotifySkillUpdates } from "../lib/skill-update-check.js";
10
18
  import { createReadStream, readFileSync, statSync } from "node:fs";
19
+ import { createHash, randomUUID } from "node:crypto";
11
20
  import { fileURLToPath } from "node:url";
12
21
  import { dirname, join, basename, extname } from "node:path";
13
22
  import { parseDynamicParams, validateParams } from "../lib/params.js";
@@ -26,6 +35,38 @@ function mimeFromFile(f) {
26
35
  return MIME_BY_EXT[extname(f).toLowerCase()] || null;
27
36
  }
28
37
 
38
+ function assertSecureUploadUrl(value) {
39
+ let url;
40
+ try {
41
+ url = new URL(value);
42
+ } catch {
43
+ throw new Error("上传接口返回的 uploadUrl 不合法");
44
+ }
45
+ if (url.protocol !== "https:" && !isAllowedHttpUrl(url)) {
46
+ throw new Error("uploadUrl 必须使用 HTTPS");
47
+ }
48
+ return url.href;
49
+ }
50
+
51
+ async function putFileToUploadUrl(file, mime, size, uploadUrl) {
52
+ const response = await fetch(assertSecureUploadUrl(uploadUrl), {
53
+ method: "PUT",
54
+ headers: {
55
+ "Content-Type": mime,
56
+ "Content-Length": String(size),
57
+ },
58
+ body: createReadStream(file),
59
+ duplex: "half",
60
+ redirect: "manual",
61
+ signal: AbortSignal.timeout(10 * 60 * 1000),
62
+ });
63
+ if (!response.ok) {
64
+ await response.body?.cancel();
65
+ throw new Error(`文件上传失败(HTTP ${response.status})`);
66
+ }
67
+ await response.body?.cancel();
68
+ }
69
+
29
70
  const VERSION = JSON.parse(
30
71
  readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf8")
31
72
  ).version;
@@ -93,29 +134,117 @@ const CAT_LABELS = {
93
134
  data: "数据",
94
135
  };
95
136
 
137
+ const OPERATORS_PATH = "/api/openapi/v1/operators";
138
+ const FILE_UPLOAD_PATH = "/api/openapi/v1/files/upload";
139
+
96
140
  function catLabel(cat) {
97
141
  return CAT_LABELS[cat] || cat || "其他";
98
142
  }
99
143
 
144
+ function operatorId(operator) {
145
+ return operator?.apiCode || operator?.code || operator?.id;
146
+ }
147
+
148
+ function operatorCat(operator) {
149
+ return operator?.category || operator?.cat;
150
+ }
151
+
152
+ function operatorParamsSchema(operator) {
153
+ const declared = operator?.params_schema || operator?.meta?.params_schema;
154
+ if (declared) return declared;
155
+ const inputSchema = operator?.inputSchema;
156
+ if (!inputSchema || inputSchema.type !== "object" || !inputSchema.properties) return null;
157
+ const required = new Set(Array.isArray(inputSchema.required) ? inputSchema.required : []);
158
+ return {
159
+ fields: Object.entries(inputSchema.properties).map(([name, field]) => ({
160
+ name,
161
+ type: field.type,
162
+ required: required.has(name),
163
+ enum: field.enum,
164
+ minLength: field.minLength,
165
+ maxLength: field.maxLength,
166
+ minimum: field.minimum,
167
+ maximum: field.maximum,
168
+ desc: field.description,
169
+ })),
170
+ };
171
+ }
172
+
173
+ function canonicalJson(value) {
174
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
175
+ if (value && typeof value === "object") {
176
+ const entries = Object.keys(value)
177
+ .sort()
178
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
179
+ return `{${entries.join(",")}}`;
180
+ }
181
+ return JSON.stringify(value);
182
+ }
183
+
184
+ function confirmationDigest(operator, input) {
185
+ return createHash("sha256").update(canonicalJson({ operator, input })).digest("base64url");
186
+ }
187
+
188
+ function encodeConfirmationToken(priceVersionTag, idempotencyKey, operator, input) {
189
+ const inputDigest = confirmationDigest(operator, input);
190
+ return Buffer.from(
191
+ JSON.stringify({ priceVersionTag, idempotencyKey, inputDigest })
192
+ ).toString("base64url");
193
+ }
194
+
195
+ function decodeConfirmationToken(value) {
196
+ try {
197
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
198
+ if (
199
+ typeof parsed.priceVersionTag !== "string"
200
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
201
+ .test(parsed.idempotencyKey || "")
202
+ || !/^[A-Za-z0-9_-]{43}$/.test(parsed.inputDigest || "")
203
+ ) return null;
204
+ return parsed;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ async function listOperators() {
211
+ const pageSize = 100;
212
+ const first = await apiFetch(`${OPERATORS_PATH}?pageNum=1&pageSize=${pageSize}`);
213
+ if (!first.ok) throw new Error(`算子列表获取失败(HTTP ${first.status})`);
214
+ const firstPage = unwrapGatewayData(first.json) || {};
215
+ let records = null;
216
+ if (Array.isArray(firstPage.records)) records = [...firstPage.records];
217
+ else if (Array.isArray(firstPage.operators)) records = [...firstPage.operators];
218
+ else if (Array.isArray(firstPage)) records = [...firstPage];
219
+ if (!records) throw new Error("算子列表响应缺少 records");
220
+ const pages = Number(firstPage.pages);
221
+ if (!Number.isFinite(pages) || pages <= 1) return records;
222
+
223
+ for (let pageNum = 2; pageNum <= pages; pageNum++) {
224
+ const response = await apiFetch(
225
+ `${OPERATORS_PATH}?pageNum=${pageNum}&pageSize=${pageSize}`
226
+ );
227
+ if (!response.ok) throw new Error(`算子列表第 ${pageNum} 页获取失败(HTTP ${response.status})`);
228
+ const page = unwrapGatewayData(response.json) || {};
229
+ if (Array.isArray(page.records)) records.push(...page.records);
230
+ }
231
+ return records;
232
+ }
233
+
234
+ async function fetchOperator(id) {
235
+ const response = await apiFetch(`${OPERATORS_PATH}/${encodeURIComponent(id)}`);
236
+ if (!response.ok) throw new Error(`算子信息获取失败(HTTP ${response.status})`);
237
+ return unwrapGatewayData(response.json);
238
+ }
239
+
100
240
  // Human commands accept either the stable operator ID or its Chinese display name.
101
241
  // Machine-facing --json responses keep IDs unchanged for automation compatibility.
102
242
  async function resolveOperator(ref) {
103
- if (String(ref).startsWith("op.")) {
104
- const direct = await apiFetch(`/api/v1/operators/${encodeURIComponent(ref)}`, { auth: "optional" });
105
- if (direct.ok) return direct.json.operator || direct.json;
106
- if (direct.status !== 404) throw new Error(`算子信息获取失败(HTTP ${direct.status})`);
107
- }
108
-
109
- const listed = await apiFetch("/api/v1/operators?status=online", { auth: "optional" });
110
- if (!listed.ok) throw new Error(`算子列表获取失败(HTTP ${listed.status})`);
111
- const operators = listed.json.operators || listed.json || [];
112
- const matches = operators.filter((item) => item?.name === ref || item?.id === ref);
243
+ const operators = await listOperators();
244
+ const matches = operators.filter((item) => item?.name === ref || operatorId(item) === ref);
113
245
  if (matches.length === 0) throw new Error(`未找到算子“${ref}”`);
114
246
  if (matches.length > 1) throw new Error(`算子名称“${ref}”不唯一,请联系平台处理`);
115
-
116
- const detail = await apiFetch(`/api/v1/operators/${encodeURIComponent(matches[0].id)}`, { auth: "optional" });
117
- if (!detail.ok) throw new Error(`算子信息获取失败(HTTP ${detail.status})`);
118
- return detail.json.operator || detail.json;
247
+ return fetchOperator(operatorId(matches[0]));
119
248
  }
120
249
 
121
250
  const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
@@ -124,12 +253,13 @@ const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
124
253
  manturhub login 浏览器授权登录(生成链接→登录创建 Key→自动导入,推荐)
125
254
  manturhub login --key sk-xxx 手动配置 API Key(存 ~/.manturhub/config.json)
126
255
  manturhub login --key-stdin 从 stdin 安全读取 API Key
127
- manturhub ls [--cat <分类>] [--json] 列出上线算子(无需登录)
128
- manturhub describe <中文算子名> [--json] 查看算子入参字段(无需登录)
256
+ manturhub ls [--cat <分类>] [--json] 列出当前 Key 已授权的上线算子
257
+ manturhub describe <中文算子名> [--json] 查看已授权算子的入参字段
129
258
  manturhub quote <中文算子名> 查询实时计费公式(不要使用 Skill 内的历史价格)
130
259
  manturhub run <中文算子名> --json '{}' 试算并确认费用后调用(异步算子自动轮询到出结果)
131
260
  manturhub run <中文算子名> --json-file x.json 从文件读参数(prompt 来自配方/用户时更安全)
132
- manturhub run <中文算子名> ... --confirm <quote_id> 使用非交互试算返回的报价确认 ID 调用
261
+ manturhub run <中文算子名> ... --confirm <confirmation_token>
262
+ 使用非交互试算返回的确认标识调用
133
263
  manturhub run <中文算子名> ... --no-wait 提交异步任务后立即返回 poll_url,不自动轮询
134
264
  manturhub upload <本地文件> 上传图片/音频/视频 → 公网 URL(喂算子前先转换本地文件)
135
265
  manturhub status <poll_url> 查异步任务状态(配合 run --no-wait)
@@ -193,21 +323,32 @@ async function main() {
193
323
  await loginViaBrowser();
194
324
  break;
195
325
  }
196
- const r = await apiFetch("/api/v1/me", { key });
326
+ const r = await apiFetch(BALANCE_PATH, { key });
327
+ let verifiedBalance;
328
+ let verificationError;
197
329
  if (r.ok) {
330
+ try {
331
+ verifiedBalance = parseBalanceResponse(r.json);
332
+ } catch (error) {
333
+ verificationError = error.message;
334
+ }
335
+ }
336
+ if (r.ok && verifiedBalance) {
198
337
  const cfg = loadConfig();
199
338
  cfg.key = key;
200
339
  saveConfig(cfg);
201
340
  console.log(
202
- `✓ Key 已验证并保存。账号: ${r.json.email || "-"} 余额: ${usdFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头)`
341
+ `✓ Key 已验证并保存。余额: ${usdFor(verifiedBalance.totalBalance)}`
342
+ + `(${verifiedBalance.totalBalance} 馒头)`
203
343
  );
204
344
  } else {
205
345
  const base = getBaseUrl();
206
346
  const productionHint = new URL(base).hostname === "hub.mantur.ai"
207
- ? " 对外用户请在 https://hub.mantur.ai 创建生产 Key;hub.mantur.cn 的测试 Key 不能用于生产。"
347
+ ? " 对外用户请在 https://hub.mantur.ai 创建生产 Key;开发测试网关的 Key 不能用于生产。"
208
348
  : "";
349
+ const reason = r.ok ? verificationError : `HTTP ${r.status}`;
209
350
  console.error(
210
- `Key 验证失败(HTTP ${r.status}),未修改本地配置。当前连接:${base}。` +
351
+ `Key 验证失败(${reason}),未修改本地配置。当前连接:${base}。` +
211
352
  `Key 必须由这个站点创建,请确认 Key 是否正确、是否已激活。${productionHint}`
212
353
  );
213
354
  process.exit(1);
@@ -239,20 +380,21 @@ async function main() {
239
380
  console.error(`未知分类: ${cat}(可选: ${[...cats].join(" | ")})`);
240
381
  process.exit(1);
241
382
  }
242
- const r = await apiFetch("/api/v1/operators?status=online", { auth: "optional" });
243
- if (!r.ok) {
244
- console.error(`列表获取失败(HTTP ${r.status})`);
383
+ let ops;
384
+ try {
385
+ ops = await listOperators();
386
+ } catch (error) {
387
+ console.error(error.message);
245
388
  process.exit(1);
246
389
  }
247
- let ops = r.json.operators || r.json || [];
248
- if (cat) ops = ops.filter((o) => o.cat === cat);
390
+ if (cat) ops = ops.filter((o) => operatorCat(o) === cat);
249
391
  if (hasFlag("json")) {
250
392
  console.log(JSON.stringify({ operators: ops }, null, 2));
251
393
  break;
252
394
  }
253
395
  console.log(`ManturHub 上线算子(${ops.length} 个):\n`);
254
396
  for (const o of ops) {
255
- console.log(` ${o.name} [${catLabel(o.cat)}]`);
397
+ console.log(` ${o.name} [${catLabel(operatorCat(o))}]`);
256
398
  }
257
399
  console.log(`\n用 \`manturhub describe <中文算子名>\` 查入参,\`manturhub run <中文算子名> --json '{...}'\` 调用`);
258
400
  break;
@@ -282,9 +424,11 @@ async function main() {
282
424
  console.log(JSON.stringify(o, null, 2));
283
425
  break;
284
426
  }
285
- console.log(`\n${o.name || "未命名算子"} [${catLabel(o.cat)}] · ${o.status || "-"}`);
427
+ console.log(
428
+ `\n${o.name || "未命名算子"} [${catLabel(operatorCat(o))}] · ${o.status || "online"}`
429
+ );
286
430
  if (o.description) console.log(o.description);
287
- const ps = o.params_schema || (o.meta && o.meta.params_schema);
431
+ const ps = operatorParamsSchema(o);
288
432
  if (ps && Array.isArray(ps.fields) && ps.fields.length) {
289
433
  console.log(`\n入参:`);
290
434
  for (const f of ps.fields) {
@@ -319,23 +463,32 @@ async function main() {
319
463
  console.error(error.message);
320
464
  process.exit(1);
321
465
  }
322
- const r = await apiFetch(`/api/v1/operators/${encodeURIComponent(operator.id)}/quote`, { auth: "optional" });
466
+ const id = operatorId(operator);
467
+ const quotePath = operator.quotePath || `${OPERATORS_PATH}/${encodeURIComponent(id)}/quote`;
468
+ const r = await apiFetch(quotePath);
323
469
  if (!r.ok) {
324
470
  console.error(`查询价格失败(HTTP ${r.status}): ${JSON.stringify(r.json)}`);
325
471
  process.exit(1);
326
472
  }
473
+ const quoted = unwrapGatewayData(r.json) || {};
327
474
  if (args.includes("--json")) {
328
- const floor = Number(r.json.floor);
475
+ const floor = Number(quoted.floor ?? quoted.estimatedPoints);
329
476
  console.log(
330
477
  JSON.stringify(
331
- Number.isFinite(floor) ? { ...r.json, floor_usd: floor * 0.01 } : r.json,
478
+ Number.isFinite(floor) ? { ...quoted, floor_usd: floor * 0.01 } : quoted,
332
479
  null,
333
480
  2
334
481
  )
335
482
  );
336
483
  } else {
337
- console.log(`${operator.name}: ${r.json.formula || "详见算子页"}`);
338
- if (r.json.floor !== undefined) console.log(`最低扣费: ${usdFor(r.json.floor)}(${r.json.floor} 馒头)`);
484
+ const formulas = Array.isArray(quoted.items)
485
+ ? quoted.items.map((item) => item.formula).filter(Boolean).join(";")
486
+ : "";
487
+ console.log(
488
+ `${operator.name}: ${quoted.formula || formulas || operator.pricing?.displayText || "详见算子页"}`
489
+ );
490
+ const floor = quoted.floor ?? quoted.estimatedPoints;
491
+ if (floor !== undefined) console.log(`最低扣费: ${usdFor(floor)}(${floor} 馒头)`);
339
492
  }
340
493
  break;
341
494
  }
@@ -394,64 +547,115 @@ async function main() {
394
547
  console.error(`${error.message},已停止调用避免误扣费`);
395
548
  process.exit(1);
396
549
  }
397
- const operatorId = operator.id;
398
- const schema = operator.params_schema || operator.meta?.params_schema;
550
+ const resolvedOperatorId = operatorId(operator);
551
+ const schema = operatorParamsSchema(operator);
399
552
  try {
400
553
  body = validateParams(body, schema, { coerceStrings: !jsonArg && !jsonFile });
401
554
  } catch (error) {
402
555
  console.error(`参数校验失败: ${error.message}`);
403
556
  process.exit(1);
404
557
  }
405
- let quoteId = getFlag("confirm");
406
- if (!quoteId) {
407
- const quote = await apiFetch(`/api/v1/operators/${encodeURIComponent(operatorId)}/quote`, {
408
- method: "POST",
409
- body,
410
- });
411
- if (!quote.ok) {
412
- console.error(`本次费用试算失败(HTTP ${quote.status}): ${JSON.stringify(quote.json)}`);
558
+ const confirmationToken = getFlag("confirm");
559
+ const quotePath = operator.quotePath
560
+ || `${OPERATORS_PATH}/${encodeURIComponent(resolvedOperatorId)}/quote`;
561
+ const quoteResponse = await apiFetch(quotePath);
562
+ if (!quoteResponse.ok) {
563
+ console.error(
564
+ `本次费用试算失败(HTTP ${quoteResponse.status}): ${JSON.stringify(quoteResponse.json)}`
565
+ );
566
+ process.exit(1);
567
+ }
568
+ const quote = unwrapGatewayData(quoteResponse.json) || {};
569
+ const estimated = Number(quote.estimatedPoints);
570
+ const priceVersionTag = quote.priceVersionTag;
571
+ const formula = Array.isArray(quote.items)
572
+ ? quote.items.map((item) => item.formula).filter(Boolean).join(";")
573
+ : "";
574
+ if (quote.billable !== false && !Number.isFinite(estimated)) {
575
+ console.error("报价响应缺少有效的 estimatedPoints,已停止调用避免误扣费。");
576
+ process.exit(1);
577
+ }
578
+ if (quote.enoughBalance === false) {
579
+ console.error(`余额不足:当前 ${quote.balance ?? "-"} 馒头,预计需要 ${estimated} 馒头。`);
580
+ process.exit(1);
581
+ }
582
+ let idempotencyKey = randomUUID();
583
+ if (confirmationToken) {
584
+ const confirmation = decodeConfirmationToken(confirmationToken);
585
+ if (
586
+ !confirmation
587
+ || confirmation.priceVersionTag !== priceVersionTag
588
+ || confirmation.inputDigest !== confirmationDigest(resolvedOperatorId, body)
589
+ ) {
590
+ console.error("报价已变化或确认标识无效,请重新执行 run 获取最新报价。");
413
591
  process.exit(1);
414
592
  }
415
- const estimated = Number(quote.json?.estimated_dumplings);
416
- quoteId = quote.json?.quote_id;
417
- if (Number.isFinite(estimated) && estimated > 0) {
418
- if (process.stdin.isTTY && process.stderr.isTTY) {
419
- if (!(await confirmCharge({ ...quote.json, operator_name: operator.name }))) {
420
- console.error("已取消,未调用算子、未扣费。");
421
- process.exit(2);
422
- }
423
- } else {
424
- console.error(JSON.stringify({
425
- error: "CONFIRMATION_REQUIRED",
426
- operator_name: operator.name,
427
- message: `本次预计消耗 ${formatMantou(estimated)},请先取得用户确认`,
428
- estimated_dumplings: estimated,
429
- balance: quote.json?.balance,
430
- formula: quote.json?.formula,
431
- quote_id: quoteId,
432
- retry_with: `--confirm ${quoteId}`,
433
- }, null, 2));
434
- process.exit(3);
593
+ idempotencyKey = confirmation.idempotencyKey;
594
+ }
595
+ if (!confirmationToken && Number.isFinite(estimated) && estimated > 0) {
596
+ if (process.stdin.isTTY && process.stderr.isTTY) {
597
+ if (!(await confirmCharge({
598
+ estimated_dumplings: estimated,
599
+ balance: quote.balance,
600
+ formula,
601
+ operator_name: operator.name,
602
+ }))) {
603
+ console.error("已取消,未调用算子、未扣费。");
604
+ process.exit(2);
435
605
  }
606
+ } else {
607
+ if (!priceVersionTag) {
608
+ console.error("报价响应缺少 priceVersionTag,无法安全确认付费调用。");
609
+ process.exit(1);
610
+ }
611
+ const encodedConfirmationToken = encodeConfirmationToken(
612
+ priceVersionTag,
613
+ idempotencyKey,
614
+ resolvedOperatorId,
615
+ body
616
+ );
617
+ console.error(JSON.stringify({
618
+ error: "CONFIRMATION_REQUIRED",
619
+ operator_name: operator.name,
620
+ message: `本次预计消耗 ${formatMantou(estimated)},请先取得用户确认`,
621
+ estimated_dumplings: estimated,
622
+ balance: quote.balance,
623
+ formula,
624
+ price_version_tag: priceVersionTag,
625
+ confirmation_token: encodedConfirmationToken,
626
+ retry_with: `--confirm ${encodedConfirmationToken}`,
627
+ }, null, 2));
628
+ process.exit(3);
436
629
  }
437
630
  }
631
+ const invokePath = operator.invokePath
632
+ || `${OPERATORS_PATH}/${encodeURIComponent(resolvedOperatorId)}/invoke`;
438
633
  const r = await apiFetch(
439
- `/api/v1/operators/${encodeURIComponent(operatorId)}/invoke`,
634
+ invokePath,
440
635
  {
441
636
  method: "POST",
442
- body,
637
+ body: { input: body },
443
638
  timeoutMs: 120000,
444
- headers: quoteId ? { "X-Mantur-Quote-Id": quoteId } : {},
639
+ headers: { "Idempotency-Key": idempotencyKey },
445
640
  }
446
641
  );
447
- // 异步算子(返回 poll_url)默认自动轮询到出结果;--no-wait 只拿 job_id。
448
- const pollUrl = r.ok && r.json && r.json.poll_url;
642
+ let invoked = r.ok ? unwrapGatewayData(r.json) : r.json;
643
+ const invokedJobId = invoked?.jobId || invoked?.job_id;
644
+ if (r.ok && invokedJobId) {
645
+ // OpenAPI 作业必须通过当前网关查询,不能把 Agent Key 发送到响应中的其他域名。
646
+ invoked = {
647
+ ...invoked,
648
+ pollUrl: `/api/openapi/v1/jobs/${encodeURIComponent(invokedJobId)}`,
649
+ };
650
+ }
651
+ // 异步算子默认自动轮询到出结果;--no-wait 只返回提交结果。
652
+ const pollUrl = r.ok && (invoked?.pollUrl || invoked?.poll_url);
449
653
  if (pollUrl && !args.includes("--no-wait")) {
450
654
  process.stderr.write(
451
- `⏳ 异步任务 ${r.json.job_id || ""} 已提交,轮询结果中(预计 ${r.json.estimated_seconds || "?"}s;加 --no-wait 可只拿 job_id)…\n`
655
+ `⏳ 异步任务 ${invokedJobId || ""} 已提交,轮询结果中…\n`
452
656
  );
453
657
  const final = await pollJob(pollUrl, {
454
- initialBilling: r.json._billing,
658
+ initialBilling: invoked._billing,
455
659
  onTick: (j) =>
456
660
  process.stderr.write(
457
661
  ` ${j.status || "?"}${j.elapsed_ms ? " " + Math.round(j.elapsed_ms / 1000) + "s" : ""}\n`
@@ -459,11 +663,11 @@ async function main() {
459
663
  });
460
664
  console.log(JSON.stringify(final, null, 2));
461
665
  printBillingResult(final, process.stderr, operator.name);
462
- const st = final && final.status;
463
- if (st === "failed" || st === "error" || (final && final._timeout)) process.exit(1);
666
+ const st = String(final?.status || "").toLowerCase();
667
+ if (st === "failed" || st === "error" || final?._timeout) process.exit(1);
464
668
  } else {
465
- console.log(JSON.stringify(r.json, null, 2));
466
- printBillingResult(r.json, process.stderr, operator.name);
669
+ console.log(JSON.stringify(invoked, null, 2));
670
+ printBillingResult(invoked, process.stderr, operator.name);
467
671
  if (!r.ok) process.exit(1);
468
672
  }
469
673
  break;
@@ -496,26 +700,35 @@ async function main() {
496
700
  console.error(`读不到文件: ${file}(${e.message})`);
497
701
  process.exit(1);
498
702
  }
499
- const p = await apiFetch("/api/v1/uploads/presign", {
703
+ const prepared = await apiFetch(FILE_UPLOAD_PATH, {
500
704
  method: "POST",
501
- body: { filename: basename(file), size: stat.size, mime },
705
+ body: {
706
+ files: [
707
+ {
708
+ fileName: basename(file),
709
+ contentType: mime,
710
+ size: stat.size,
711
+ },
712
+ ],
713
+ },
502
714
  });
503
- if (!p.ok || !p.json || !p.json.put_url) {
504
- console.error(`presign 失败(HTTP ${p.status}): ${JSON.stringify(p.json)}`);
715
+ if (!prepared.ok) {
716
+ console.error(`获取上传地址失败(HTTP ${prepared.status}): ${JSON.stringify(prepared.json)}`);
505
717
  process.exit(1);
506
718
  }
507
- const put = await fetch(p.json.put_url, {
508
- method: "PUT",
509
- headers: { "Content-Type": mime, "Content-Length": String(stat.size) },
510
- body: createReadStream(file),
511
- duplex: "half",
512
- signal: AbortSignal.timeout(10 * 60 * 1000),
513
- });
514
- if (!put.ok) {
515
- console.error(`上传到存储失败(HTTP ${put.status})`);
719
+ const result = unwrapGatewayData(prepared.json) || {};
720
+ const upload = Array.isArray(result.files) ? result.files[0] : null;
721
+ if (!upload?.uploadUrl || !upload?.downloadUrl) {
722
+ console.error("上传接口响应的 files[0] 缺少 uploadUrl 或 downloadUrl");
516
723
  process.exit(1);
517
724
  }
518
- console.log(p.json.access_url); // 公网 URL,直接喂给算子
725
+ try {
726
+ await putFileToUploadUrl(file, mime, stat.size, upload.uploadUrl);
727
+ } catch (error) {
728
+ console.error(error.message);
729
+ process.exit(1);
730
+ }
731
+ console.log(upload.downloadUrl); // 上传成功后输出可直接喂给算子的下载地址
519
732
  break;
520
733
  }
521
734
 
@@ -532,8 +745,9 @@ async function main() {
532
745
  process.exit(1);
533
746
  }
534
747
  const r = await apiFetch(pu);
535
- console.log(JSON.stringify(r.json, null, 2));
536
- printBillingResult(r.json);
748
+ const status = r.ok ? unwrapGatewayData(r.json) : r.json;
749
+ console.log(JSON.stringify(status, null, 2));
750
+ printBillingResult(status);
537
751
  if (!r.ok) process.exit(1);
538
752
  break;
539
753
  }
@@ -545,15 +759,23 @@ async function main() {
545
759
  console.error(error.message);
546
760
  process.exit(1);
547
761
  }
548
- const r = await apiFetch("/api/v1/me");
762
+ const r = await apiFetch(BALANCE_PATH);
549
763
  if (!r.ok) {
550
764
  console.error(`查询失败(HTTP ${r.status})`);
551
765
  process.exit(1);
552
766
  }
767
+ let parsedBalance;
768
+ try {
769
+ parsedBalance = parseBalanceResponse(r.json);
770
+ } catch (error) {
771
+ console.error(error.message);
772
+ process.exit(1);
773
+ }
774
+ const { balance, totalBalance: balancePoints } = parsedBalance;
553
775
  console.log(
554
776
  hasFlag("json")
555
- ? JSON.stringify({ ...r.json, balance_usd: Number(r.json.balance) * 0.01 }, null, 2)
556
- : `余额: ${usdFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头) 账号: ${r.json.email || "-"}`
777
+ ? JSON.stringify({ ...balance, balance_usd: balancePoints * 0.01 }, null, 2)
778
+ : `余额: ${usdFor(balancePoints)}(${balancePoints} 馒头)`
557
779
  );
558
780
  break;
559
781
  }
@@ -644,30 +866,41 @@ async function main() {
644
866
  console.error(error.message);
645
867
  process.exit(1);
646
868
  }
647
- const r = await apiFetch(`/api/v1/recipes/${encodeURIComponent(slug)}`, { auth: "optional" });
869
+ const r = await apiFetch(
870
+ `${PUBLIC_RECIPES_PATH}/${encodeURIComponent(slug)}`,
871
+ { auth: "optional" }
872
+ );
648
873
  if (!r.ok) {
649
874
  console.error(`获取失败(HTTP ${r.status}): ${slug}`);
650
875
  process.exit(1);
651
876
  }
652
- const d = r.json;
877
+ let d;
878
+ try {
879
+ d = unwrapGatewayData(r.json) || {};
880
+ } catch (error) {
881
+ console.error(`获取失败: ${error.message}`);
882
+ process.exit(1);
883
+ }
653
884
  if (args.includes("--json")) {
654
885
  console.log(JSON.stringify(d, null, 2));
655
886
  break;
656
887
  }
657
- console.log(`\n${d.title} [${d.cat}] · 复刻 ${d.cost_estimate}`);
658
- console.log(`${d.summary}\n`);
659
- if (d.sample_url) {
660
- const su = d.sample_url.startsWith("http")
661
- ? d.sample_url
662
- : `${getBaseUrl()}${d.sample_url}`;
663
- console.log(`效果样片: ${su}`);
888
+ const recipeCode = d.code || slug;
889
+ console.log(`\n${d.name || recipeCode} [${d.category || "其他"}]${d.version ? ` · ${d.version}` : ""}`);
890
+ if (d.description) console.log(`${d.description}\n`);
891
+ if (d.sampleUrl) {
892
+ console.log(`效果样片: ${d.sampleUrl}`);
893
+ }
894
+ console.log(`配方页: ${getBaseUrl()}/recipes/${recipeCode}\n`);
895
+ if (d.usage) console.log(`执行说明:\n${d.usage}\n`);
896
+ if (d.inputSchema) {
897
+ console.log("输入结构:");
898
+ console.log(JSON.stringify(d.inputSchema, null, 2));
899
+ }
900
+ if (Array.isArray(d.steps) && d.steps.length) {
901
+ console.log("\n执行步骤:");
902
+ console.log(JSON.stringify(d.steps, null, 2));
664
903
  }
665
- console.log(`配方页: ${getBaseUrl()}/recipes/${d.slug}\n`);
666
- if (d.prompt_template) console.log(`提示词模板:\n${d.prompt_template}\n`);
667
- console.log("结构化参数(把 {占位符} 换成用户内容):");
668
- console.log(JSON.stringify(d.params_json || {}, null, 2));
669
- console.log("\n安全执行:将每步 params 写入 JSON 文件,再运行 `manturhub run <算子ID> --json-file <文件>`。");
670
- if (d.sample_text) console.log(`\n效果节选:\n${d.sample_text}`);
671
904
  break;
672
905
  }
673
906
  // manturhub recipe [ls|search] [关键词] [--cat video|image|script]
@@ -685,16 +918,20 @@ async function main() {
685
918
  console.error(`未知配方分类: ${cat}(可选: video | image | script)`);
686
919
  process.exit(1);
687
920
  }
688
- const r = await apiFetch(`/api/v1/recipes${cat ? `?cat=${encodeURIComponent(cat)}` : ""}`, { auth: "optional" });
689
- if (!r.ok) {
690
- console.error(`配方列表获取失败(HTTP ${r.status})`);
921
+ let list;
922
+ try {
923
+ list = await fetchPublicCatalog(PUBLIC_RECIPES_PATH);
924
+ } catch (error) {
925
+ console.error(`配方列表获取失败(${error.message})`);
691
926
  process.exit(1);
692
927
  }
693
- let list = r.json.recipes || [];
928
+ if (cat) list = list.filter((item) => item.category === cat);
694
929
  if (kwArg) {
695
930
  const k = kwArg.toLowerCase();
696
931
  list = list.filter((x) =>
697
- `${x.title}${x.summary}${(x.tags || []).join(",")}`.toLowerCase().includes(k)
932
+ `${x.name || ""}${x.summary || ""}${(x.tags || []).join(",")}`
933
+ .toLowerCase()
934
+ .includes(k)
698
935
  );
699
936
  }
700
937
  if (hasFlag("json")) {
@@ -703,7 +940,9 @@ async function main() {
703
940
  }
704
941
  console.log(`ManturHub 配方(${list.length} 个):\n`);
705
942
  for (const x of list) {
706
- console.log(` ${x.slug.padEnd(32)} [${x.cat}] ${x.title} · 复刻 ${x.cost_estimate}`);
943
+ console.log(
944
+ ` ${String(x.code || "").padEnd(32)} [${x.category || "其他"}] ${x.name || ""}`
945
+ );
707
946
  }
708
947
  console.log(
709
948
  `\n用 \`manturhub recipe get <配方ID>\` 看提示词模板与调用参数;挑选体验更好的网页版: ${getBaseUrl()}/recipes`
package/lib/api.js CHANGED
@@ -1,10 +1,70 @@
1
1
  import { getKey, getBaseUrl } from "./config.js";
2
2
 
3
+ export const BALANCE_PATH = "/api/openapi/v1/credits/balance";
4
+ export const PUBLIC_RECIPES_PATH = "/api/public/agent/v1/recipes";
5
+ export const PUBLIC_SKILLS_PATH = "/api/public/agent/v1/skills";
6
+
7
+ export function unwrapGatewayData(json) {
8
+ if (json?.success === false || (typeof json?.code === "number" && json.code !== 0)) {
9
+ throw new Error(json?.message || "ManturHub Gateway 请求失败");
10
+ }
11
+ return json && Object.hasOwn(json, "data") ? json.data : json;
12
+ }
13
+
14
+ export function parseBalanceResponse(json) {
15
+ const balance = unwrapGatewayData(json);
16
+ const totalBalance = Number(balance?.totalBalance);
17
+ if (!Number.isFinite(totalBalance)) {
18
+ throw new Error("余额接口响应缺少有效的 totalBalance");
19
+ }
20
+ return { balance, totalBalance };
21
+ }
22
+
23
+ export async function fetchPublicCatalog(path, { pageSize = 100 } = {}) {
24
+ const fetchPage = async (pageNum) => {
25
+ const response = await apiFetch(
26
+ `${path}?pageNum=${pageNum}&pageSize=${pageSize}`,
27
+ { auth: "optional" }
28
+ );
29
+ if (!response.ok) {
30
+ const error = new Error(`HTTP ${response.status}`);
31
+ error.status = response.status;
32
+ throw error;
33
+ }
34
+ const page = unwrapGatewayData(response.json) || {};
35
+ if (!Array.isArray(page.records)) {
36
+ throw new Error("公共广场响应缺少 records");
37
+ }
38
+ return page;
39
+ };
40
+
41
+ const first = await fetchPage(1);
42
+ const records = [...first.records];
43
+ const pages = Number(first.pages);
44
+ if (!Number.isFinite(pages) || pages <= 1) return records;
45
+ for (let pageNum = 2; pageNum <= pages; pageNum++) {
46
+ const page = await fetchPage(pageNum);
47
+ records.push(...page.records);
48
+ }
49
+ return records;
50
+ }
51
+
3
52
  // Thin REST client against the ManturHub gateway. Public discovery calls may omit auth.
4
53
  export async function apiFetch(
5
54
  path,
6
- { method = "GET", body, key, auth = "required", timeoutMs = 30000, headers = {} } = {}
55
+ {
56
+ method = "GET",
57
+ body,
58
+ rawBody,
59
+ key,
60
+ auth = "required",
61
+ timeoutMs = 30000,
62
+ headers = {},
63
+ } = {}
7
64
  ) {
65
+ if (body !== undefined && rawBody !== undefined) {
66
+ throw new Error("body 和 rawBody 不能同时使用");
67
+ }
8
68
  const apiKey = key === undefined ? getKey() : key;
9
69
  if (auth === "required" && !apiKey) {
10
70
  throw new Error(
@@ -21,10 +81,11 @@ export async function apiFetch(
21
81
  method,
22
82
  headers: {
23
83
  ...(includeKey && apiKey ? { "x-api-key": apiKey } : {}),
24
- ...(body ? { "Content-Type": "application/json" } : {}),
84
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
25
85
  ...headers,
26
86
  },
27
- body: body ? JSON.stringify(body) : undefined,
87
+ body: rawBody ?? (body !== undefined ? JSON.stringify(body) : undefined),
88
+ ...(rawBody !== undefined ? { duplex: "half" } : {}),
28
89
  signal: AbortSignal.timeout(timeoutMs),
29
90
  });
30
91
  const requestWithRetry = async (includeKey) => {
@@ -64,7 +125,7 @@ export async function apiFetch(
64
125
 
65
126
  // 异步算子:轮询 invoke 返回的 poll_url 直到任务出终态(succeeded/failed/…),返回最终 json。
66
127
  // 未知 shape(无 status 字段)按终态处理,直接返回让上层打印。
67
- const ACTIVE = new Set(["queued", "running", "pending", "processing", "in_progress"]);
128
+ const ACTIVE = new Set(["created", "queued", "running", "pending", "processing", "in_progress"]);
68
129
  export function retainBilling(result, fallbackBilling) {
69
130
  if (!fallbackBilling || !result || typeof result !== "object" || Array.isArray(result)) return result;
70
131
  return result._billing ? result : { ...result, _billing: fallbackBilling };
@@ -79,12 +140,13 @@ export async function pollJob(
79
140
  let lastBilling = initialBilling || null;
80
141
  while (Date.now() - start < maxMs) {
81
142
  const r = await apiFetch(pollUrl, { timeoutMs: 30000 });
82
- last = r.json;
83
- if (r.json?._billing) lastBilling = r.json._billing;
84
- const s = r.json && r.json.status;
85
- if (onTick) onTick(r.json);
86
- if (!r.ok || !s || !ACTIVE.has(s)) {
87
- return retainBilling(r.json, lastBilling); // 终态(或报错/未知 shape) 返回
143
+ const response = r.json && Object.hasOwn(r.json, "data") ? r.json.data : r.json;
144
+ last = response;
145
+ if (response?._billing) lastBilling = response._billing;
146
+ const s = response?.status;
147
+ if (onTick) onTick(response);
148
+ if (!r.ok || !s || !ACTIVE.has(String(s).toLowerCase())) {
149
+ return retainBilling(response, lastBilling); // 终态(或报错/未知 shape)→ 返回
88
150
  }
89
151
  await new Promise((res) => setTimeout(res, intervalMs));
90
152
  }
package/lib/config.js CHANGED
@@ -14,12 +14,19 @@ const FILE = join(DIR, "config.json");
14
14
  // Published CLI defaults to production; test/private deployments override with
15
15
  // MANTURHUB_BASE or config.json. All generated links must go through getBaseUrl().
16
16
  const DEFAULT_BASE = "https://hub.mantur.ai";
17
+ const DEVELOPMENT_HTTP_ORIGIN = "http://gateway-dev.guiyi.cn";
17
18
  const LEGACY_DEFAULT_BASES = new Set([
18
19
  "https://hub.mantur.cn",
19
20
  "https://manturhub.leisurecat.cloud",
20
21
  "https://api.ophub.com",
21
22
  ]);
22
23
 
24
+ export function isAllowedHttpUrl(url) {
25
+ const loopback =
26
+ url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
27
+ return url.protocol === "http:" && (loopback || url.origin === DEVELOPMENT_HTTP_ORIGIN);
28
+ }
29
+
23
30
  export function loadConfig() {
24
31
  try {
25
32
  return JSON.parse(readFileSync(FILE, "utf8"));
@@ -49,9 +56,10 @@ function validateBaseUrl(value) {
49
56
  } catch {
50
57
  throw new Error(`ManturHub 网关地址不合法: ${value}`);
51
58
  }
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)");
59
+ if (url.protocol !== "https:" && !isAllowedHttpUrl(url)) {
60
+ throw new Error(
61
+ "MANTURHUB_BASE 必须使用 HTTPS(仅本机地址和 http://gateway-dev.guiyi.cn 可使用 HTTP)"
62
+ );
55
63
  }
56
64
  if (url.username || url.password) throw new Error("MANTURHUB_BASE 不得包含用户名或密码");
57
65
  return url.href.replace(/\/$/, "");
package/lib/download.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createWriteStream, rmSync } from "node:fs";
2
2
  import { Readable, Transform } from "node:stream";
3
3
  import { pipeline } from "node:stream/promises";
4
+ import { isAllowedHttpUrl } from "./config.js";
4
5
 
5
6
  export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024;
6
7
 
@@ -11,8 +12,7 @@ export function assertSecureDownloadUrl(value) {
11
12
  } catch {
12
13
  throw new Error("下载重定向地址不合法");
13
14
  }
14
- const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
15
- if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
15
+ if (url.protocol !== "https:" && !isAllowedHttpUrl(url)) {
16
16
  throw new Error("安装包下载地址必须使用 HTTPS");
17
17
  }
18
18
  return url.href;
package/lib/login-link.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // key 只经后端 poll(device_code)下发,绝不进浏览器 URL。
4
4
  import { spawn } from "node:child_process";
5
5
  import { getBaseUrl, loadConfig, saveConfig } from "./config.js";
6
- import { apiFetch } from "./api.js";
6
+ import { apiFetch, BALANCE_PATH, parseBalanceResponse } from "./api.js";
7
7
 
8
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
9
 
@@ -91,22 +91,31 @@ export async function loginViaBrowser() {
91
91
  process.exit(1);
92
92
  }
93
93
  if (poll.status === "ready" && poll.key) {
94
- const cfg = loadConfig();
95
- cfg.key = poll.key;
96
- saveConfig(cfg);
97
- const me = await apiFetch("/api/v1/me", { key: poll.key });
98
- if (me.ok) {
99
- const balance = Number(me.json.balance);
100
- const usd = Number.isFinite(balance) ? `$${(balance * 0.01).toFixed(2)} USD` : "-";
94
+ const verification = await apiFetch(BALANCE_PATH, { key: poll.key });
95
+ let verifiedBalance;
96
+ let verificationError;
97
+ if (verification.ok) {
98
+ try {
99
+ verifiedBalance = parseBalanceResponse(verification.json);
100
+ } catch (error) {
101
+ verificationError = error.message;
102
+ }
103
+ }
104
+ if (verification.ok && verifiedBalance) {
105
+ const cfg = loadConfig();
106
+ cfg.key = poll.key;
107
+ saveConfig(cfg);
108
+ const usd = `$${(verifiedBalance.totalBalance * 0.01).toFixed(2)} USD`;
101
109
  console.log(
102
- `\n ✓ 授权成功,Key 已导入 ~/.manturhub/config.json。账号: ${
103
- me.json.email || "-"
104
- } 余额: ${usd}(${me.json.balance ?? "-"} 馒头)\n`
110
+ `\n ✓ 授权成功,Key 已验证并导入 ~/.manturhub/config.json。` +
111
+ `余额: ${usd}(${verifiedBalance.totalBalance} 馒头)\n`
105
112
  );
106
113
  } else {
107
- console.log(
108
- `\n ✓ Key 已导入,但验证返回 HTTP ${me.status}(稍后可用 manturhub balance 再确认)。\n`
114
+ const reason = verification.ok ? verificationError : `HTTP ${verification.status}`;
115
+ console.error(
116
+ `\n Key 余额验证失败(${reason}),未修改本地配置。请重新运行 manturhub login。\n`
109
117
  );
118
+ process.exit(1);
110
119
  }
111
120
  return;
112
121
  }
@@ -10,9 +10,14 @@ import {
10
10
  renameSync,
11
11
  rmSync,
12
12
  } from "node:fs";
13
- import { getKey, getBaseUrl } from "./config.js";
13
+ import { getBaseUrl } from "./config.js";
14
14
  import { extractZipSafely, validateSlug } from "./archive.js";
15
- import { apiFetch } from "./api.js";
15
+ import {
16
+ apiFetch,
17
+ fetchPublicCatalog,
18
+ PUBLIC_SKILLS_PATH,
19
+ unwrapGatewayData,
20
+ } from "./api.js";
16
21
  import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
17
22
  import { displayClient, resolveSkillClient } from "./agent-target.js";
18
23
  import {
@@ -33,24 +38,25 @@ function fail(message) {
33
38
  }
34
39
 
35
40
  async function fetchOnlineSkills() {
36
- const response = await apiFetch("/api/v1/skills", { auth: "optional" });
37
- if (!response.ok) throw new Error(`Skill 列表获取失败(HTTP ${response.status})`);
38
- return (response.json.skills || response.json || []).filter((item) => item.kind !== "suite");
41
+ const records = await fetchPublicCatalog(PUBLIC_SKILLS_PATH);
42
+ return records
43
+ .filter((item) => item.kind !== "suite")
44
+ .map((item) => ({ ...item, slug: item.code }));
39
45
  }
40
46
 
41
47
  async function fetchSkillDetail(slug) {
42
- const response = await apiFetch(`/api/v1/skills/${encodeURIComponent(slug)}`, {
48
+ const response = await apiFetch(`${PUBLIC_SKILLS_PATH}/${encodeURIComponent(slug)}`, {
43
49
  auth: "optional",
44
50
  });
45
51
  if (response.status === 404) {
46
52
  throw new Error(`Skill 不存在: ${slug}(用 \`manturhub skill ls\` 看可用列表)`);
47
53
  }
48
54
  if (!response.ok) throw new Error(`Skill 信息获取失败(HTTP ${response.status})`);
49
- const detail = response.json.skill || response.json;
55
+ const detail = unwrapGatewayData(response.json) || {};
50
56
  if (!detail?.version) {
51
57
  throw new Error(`线上 Skill「${slug}」缺少 version,平台需先修正后才能安装`);
52
58
  }
53
- return detail;
59
+ return { ...detail, slug: detail.code };
54
60
  }
55
61
 
56
62
  function detectExistingClient(slug) {
@@ -97,13 +103,13 @@ function assertSafeToReplace(destinations, slug, state, baseUrl, force) {
97
103
  }
98
104
 
99
105
  async function downloadSkillBundle(slug, target) {
100
- const key = getKey();
101
- if (!key) {
102
- throw new Error("下载 Skill API Key。运行 `manturhub login`,或设置环境变量 MANTURHUB_KEY。");
106
+ const base = new URL(getBaseUrl());
107
+ const bundlePath = `${PUBLIC_SKILLS_PATH}/${encodeURIComponent(slug)}/bundle`;
108
+ const url = new URL(bundlePath, base.href.endsWith("/") ? base.href : `${base.href}/`);
109
+ if (url.origin !== base.origin) {
110
+ throw new Error(`Skill「${slug}」的下载地址指向非 ManturHub 地址`);
103
111
  }
104
- const url = `${getBaseUrl()}/api/v1/skills/${encodeURIComponent(slug)}/download`;
105
112
  const response = await fetch(url, {
106
- headers: { "x-api-key": key },
107
113
  redirect: "manual",
108
114
  signal: AbortSignal.timeout(30000),
109
115
  });
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { getBaseUrl } from "./config.js";
5
+ import { PUBLIC_SKILLS_PATH, unwrapGatewayData } from "./api.js";
5
6
 
6
7
  const DIR = join(homedir(), ".manturhub");
7
8
  const CACHE = join(DIR, "skill-update-check.json");
@@ -16,7 +17,10 @@ function readPrevious() {
16
17
 
17
18
  const baseUrl = getBaseUrl();
18
19
  try {
19
- const url = new URL("/api/v1/skills", `${baseUrl}/`);
20
+ const url = new URL(
21
+ `${PUBLIC_SKILLS_PATH}?pageNum=1&pageSize=100`,
22
+ `${baseUrl}/`
23
+ );
20
24
  const response = await fetch(url, {
21
25
  signal: AbortSignal.timeout(8000),
22
26
  headers: { Accept: "application/json" },
@@ -24,11 +28,12 @@ try {
24
28
  mkdirSync(DIR, { recursive: true });
25
29
  if (response.ok) {
26
30
  const json = await response.json();
27
- const skills = (json.skills || json || [])
28
- .filter((item) => item?.kind !== "suite" && item?.slug && item?.version)
31
+ const page = unwrapGatewayData(json) || {};
32
+ const skills = (page.records || [])
33
+ .filter((item) => item?.kind !== "suite" && item?.code && item?.version)
29
34
  .map((item) => ({
30
- slug: item.slug,
31
- name: item.name || item.slug,
35
+ slug: item.code,
36
+ name: item.name || item.code,
32
37
  version: item.version,
33
38
  kind: item.kind || "skill",
34
39
  }));
@@ -1,12 +1,12 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { tmpdir } from "node:os";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
- import { getKey, getBaseUrl } from "./config.js";
4
+ import { getBaseUrl } from "./config.js";
5
5
  import { extractZipSafely, validateSlug } from "./archive.js";
6
- import { apiFetch } from "./api.js";
6
+ import { fetchPublicCatalog, PUBLIC_SKILLS_PATH } from "./api.js";
7
7
  import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
8
8
 
9
- // #456 Agent 套件(团队版 Skill):与 skill 共用 /api/v1/skills 元数据与下载端点,
9
+ // #456 Agent 套件(团队版 Skill):与 skill 共用公共 Skill 广场列表和固定 bundle 下载接口,
10
10
  // 区别在安装形态——解压为当前目录下的工作目录(非用户级 skills)。API Key 继续由
11
11
  // CLI 从 ~/.manturhub/config.json 读取,绝不复制进可能被提交的项目目录。
12
12
 
@@ -14,17 +14,14 @@ import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
14
14
  export async function suiteLs({ json = false } = {}) {
15
15
  let res;
16
16
  try {
17
- res = await apiFetch("/api/v1/skills", { auth: "optional" });
17
+ res = await fetchPublicCatalog(PUBLIC_SKILLS_PATH);
18
18
  } catch (e) {
19
19
  console.error(`套件列表获取失败: ${e.message}`);
20
20
  process.exit(1);
21
21
  }
22
- if (!res.ok) {
23
- console.error(`套件列表获取失败(HTTP ${res.status})`);
24
- process.exit(1);
25
- }
26
- const data = res.json;
27
- const suites = (data.skills || []).filter((s) => s.kind === "suite");
22
+ const suites = res
23
+ .filter((item) => item.kind === "suite")
24
+ .map((item) => ({ ...item, slug: item.code }));
28
25
  if (json) {
29
26
  console.log(JSON.stringify({ suites }, null, 2));
30
27
  return;
@@ -55,18 +52,20 @@ export async function suiteInstall(slug, dirArg) {
55
52
  console.error(error.message);
56
53
  process.exit(1);
57
54
  }
58
- const key = getKey();
59
- if (!key) {
60
- console.error("下载套件需 API Key。运行 `manturhub login`,或设置环境变量 MANTURHUB_KEY。");
55
+ const base = new URL(getBaseUrl());
56
+ const bundlePath = `${PUBLIC_SKILLS_PATH}/${encodeURIComponent(slug)}/bundle`;
57
+ const url = new URL(
58
+ bundlePath,
59
+ base.href.endsWith("/") ? base.href : `${base.href}/`
60
+ );
61
+ if (url.origin !== base.origin) {
62
+ console.error(`套件「${slug}」的下载地址指向非 ManturHub 地址`);
61
63
  process.exit(1);
62
64
  }
63
-
64
- const url = `${getBaseUrl()}/api/v1/skills/${encodeURIComponent(slug)}/download`;
65
65
  // 手动处理 302:跟随预签名地址时不把 API Key 带去对象存储(同 skill add)。
66
66
  let res;
67
67
  try {
68
68
  res = await fetch(url, {
69
- headers: { "x-api-key": key },
70
69
  redirect: "manual",
71
70
  signal: AbortSignal.timeout(30000),
72
71
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manturhub/cli",
3
- "version": "0.9.8",
3
+ "version": "0.9.10-dev.20260810.5",
4
4
  "description": "ManturHub 算子广场 CLI:通过 REST 发现和调用 AI 算子、浏览配方,并安装 Skill 与 Agent 套件",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,9 @@
14
14
  },
15
15
  "scripts": {
16
16
  "test": "node --test",
17
- "prepack": "npm test"
17
+ "prepack": "npm test",
18
+ "release:dev": "npm version prerelease --preid=dev --no-git-tag-version && npm publish --access public --tag dev",
19
+ "release:prod": "npm version patch --no-git-tag-version && npm publish --access public --tag latest"
18
20
  },
19
21
  "files": [
20
22
  "bin",