@manturhub/cli 0.9.9 → 0.9.10-dev.20260810.6
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 +18 -6
- package/bin/cli.js +356 -117
- package/lib/api.js +72 -10
- package/lib/config.js +27 -8
- package/lib/download.js +2 -2
- package/lib/login-link.js +22 -13
- package/lib/skill-install.js +19 -19
- package/lib/skill-update-fetch.js +11 -9
- package/lib/suite-install.js +15 -21
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -15,14 +15,26 @@ manturhub login
|
|
|
15
15
|
printf '%s' "$YOUR_MANTURHUB_KEY" | manturhub login --key-stdin
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
稳定版 `@manturhub/cli@latest` 默认连接生产站 `https://hub.mantur.ai`,生产 Key 必须在该站创建。
|
|
19
|
+
|
|
20
|
+
开发版 `@manturhub/cli@dev` 默认连接开发网关,无需设置环境变量:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @manturhub/cli@dev
|
|
24
|
+
manturhub login
|
|
25
|
+
manturhub ls
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`MANTURHUB_BASE` 可显式覆盖包的默认网关。开发网关是唯一允许使用远程 HTTP 的例外;生产站和其他远程部署必须使用 HTTPS。开发网关与生产站的 API Key 不通用。
|
|
29
|
+
算子列表和详情通过 `/api/openapi/v1/operators` 获取,只返回当前 Key 已授权的算子。
|
|
30
|
+
文件先通过 `POST /api/openapi/v1/files/upload` 提交 `files: [{ fileName, contentType, size }]`,从 `data.files[0]` 获取 `uploadUrl` 和 `downloadUrl`,再以 PUT 将文件流式上传到 `uploadUrl`;上传成功后命令输出 `downloadUrl`。API Key 只发送给 ManturHub 网关,不会发送给对象存储。
|
|
19
31
|
|
|
20
32
|
需要 Node.js ≥ 18。也可免安装运行:`npx -y @manturhub/cli <命令>`。
|
|
21
33
|
|
|
22
34
|
## 快速开始
|
|
23
35
|
|
|
24
36
|
```bash
|
|
25
|
-
#
|
|
37
|
+
# 发现与查看当前 Key 已授权的算子
|
|
26
38
|
manturhub ls --cat text
|
|
27
39
|
manturhub describe 电商文案生成
|
|
28
40
|
|
|
@@ -35,7 +47,7 @@ CLI 会在付费调用前按算子实时 schema 校验未知字段、必填项
|
|
|
35
47
|
|
|
36
48
|
面向用户的列表、详情、报价、确认和结算统一显示中文算子名,也可直接用中文名调用。英文算子 ID 仅保留在 `--json` 机器输出和内部 API 请求中,供 Agent 与脚本稳定使用。
|
|
37
49
|
|
|
38
|
-
在 Agent 或脚本等非交互环境中,第一次运行只返回报价和 `
|
|
50
|
+
在 Agent 或脚本等非交互环境中,第一次运行只返回报价和 `confirmation_token`,不会调用或扣费;Agent 向用户确认后,使用提示中的 `--confirm <confirmation_token>` 执行。确认标识绑定算子、入参、价格版本和幂等 ID:内容或价格变化时拒绝调用,同一次调用重试时复用幂等 ID,避免重复扣费。
|
|
39
51
|
|
|
40
52
|
## 主要命令
|
|
41
53
|
|
|
@@ -44,9 +56,9 @@ CLI 会在付费调用前按算子实时 schema 校验未知字段、必填项
|
|
|
44
56
|
| `manturhub ls [--cat image\|video\|audio\|text\|data] [--json]` | 实时列出上线算子 |
|
|
45
57
|
| `manturhub describe <中文算子名> [--json]` | 查看精确入参与异步属性 |
|
|
46
58
|
| `manturhub quote <中文算子名> [--json]` | 查询 Java API 返回的实时计费公式 |
|
|
47
|
-
| `manturhub run <中文算子名> --json '{}' [--confirm <
|
|
59
|
+
| `manturhub run <中文算子名> --json '{}' [--confirm <confirmation_token>] [--no-wait]` | 调用算子;非交互确认传确认标识,异步任务默认轮询到终态 |
|
|
48
60
|
| `manturhub run <中文算子名> --json-file params.json` | 从文件读取参数,适合长提示词和自动化 |
|
|
49
|
-
| `manturhub upload <本地文件>` |
|
|
61
|
+
| `manturhub upload <本地文件>` | 通过预签名 PUT 流式上传图片、音频或视频,并输出 `downloadUrl` |
|
|
50
62
|
| `manturhub status <poll_url>` | 查询 `run --no-wait` 返回的异步任务 |
|
|
51
63
|
| `manturhub balance [--json]` | 查询余额及美元等值(1 馒头 = $0.01 USD) |
|
|
52
64
|
| `manturhub skill ls [--json]` / `skill add <slug> [--client codex]` | 浏览并从当前 ManturHub 站点安装业务 Skill |
|
|
@@ -57,6 +69,6 @@ CLI 会在付费调用前按算子实时 schema 校验未知字段、必填项
|
|
|
57
69
|
|
|
58
70
|
机器消费输出可在支持的查询命令上加 `--json`。进度和提示写入 stderr,JSON 结果写入 stdout。
|
|
59
71
|
|
|
60
|
-
环境变量:`MANTURHUB_KEY`(优先于配置文件) · `MANTURHUB_BASE
|
|
72
|
+
环境变量:`MANTURHUB_KEY`(优先于配置文件) · `MANTURHUB_BASE`(显式覆盖当前发布通道的默认网关)。
|
|
61
73
|
|
|
62
74
|
ManturHub 算子广场:https://hub.mantur.ai
|
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 {
|
|
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
|
-
|
|
104
|
-
|
|
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 <
|
|
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(
|
|
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
|
|
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
|
|
347
|
+
? " 对外用户请在 https://hub.mantur.ai 创建生产 Key;开发测试网关的 Key 不能用于生产。"
|
|
208
348
|
: "";
|
|
349
|
+
const reason = r.ok ? verificationError : `HTTP ${r.status}`;
|
|
209
350
|
console.error(
|
|
210
|
-
`Key
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
475
|
+
const floor = Number(quoted.floor ?? quoted.estimatedPoints);
|
|
329
476
|
console.log(
|
|
330
477
|
JSON.stringify(
|
|
331
|
-
Number.isFinite(floor) ? { ...
|
|
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
|
-
|
|
338
|
-
|
|
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
|
|
398
|
-
const schema = operator
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
634
|
+
invokePath,
|
|
440
635
|
{
|
|
441
636
|
method: "POST",
|
|
442
|
-
body,
|
|
637
|
+
body: { input: body },
|
|
443
638
|
timeoutMs: 120000,
|
|
444
|
-
headers:
|
|
639
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
445
640
|
}
|
|
446
641
|
);
|
|
447
|
-
|
|
448
|
-
const
|
|
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
|
-
`⏳ 异步任务 ${
|
|
655
|
+
`⏳ 异步任务 ${invokedJobId || ""} 已提交,轮询结果中…\n`
|
|
452
656
|
);
|
|
453
657
|
const final = await pollJob(pollUrl, {
|
|
454
|
-
initialBilling:
|
|
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
|
|
463
|
-
if (st === "failed" || st === "error" ||
|
|
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(
|
|
466
|
-
printBillingResult(
|
|
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
|
|
703
|
+
const prepared = await apiFetch(FILE_UPLOAD_PATH, {
|
|
500
704
|
method: "POST",
|
|
501
|
-
body: {
|
|
705
|
+
body: {
|
|
706
|
+
files: [
|
|
707
|
+
{
|
|
708
|
+
fileName: basename(file),
|
|
709
|
+
contentType: mime,
|
|
710
|
+
size: stat.size,
|
|
711
|
+
},
|
|
712
|
+
],
|
|
713
|
+
},
|
|
502
714
|
});
|
|
503
|
-
if (!
|
|
504
|
-
console.error(
|
|
715
|
+
if (!prepared.ok) {
|
|
716
|
+
console.error(`获取上传地址失败(HTTP ${prepared.status}): ${JSON.stringify(prepared.json)}`);
|
|
505
717
|
process.exit(1);
|
|
506
718
|
}
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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(
|
|
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({ ...
|
|
556
|
-
: `余额: ${usdFor(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
console.log(
|
|
659
|
-
if (d.
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
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
|
@@ -11,15 +11,33 @@ import {
|
|
|
11
11
|
// Local config lives at ~/.manturhub/config.json (chmod 600). Env vars win over it.
|
|
12
12
|
const DIR = join(homedir(), ".manturhub");
|
|
13
13
|
const FILE = join(DIR, "config.json");
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
14
|
+
const DEVELOPMENT_HTTP_ORIGIN = "http://gateway-dev.guiyi.cn";
|
|
15
|
+
const PRODUCTION_ORIGIN = "https://hub.mantur.ai";
|
|
16
|
+
const PACKAGE_VERSION = JSON.parse(
|
|
17
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
18
|
+
).version;
|
|
19
|
+
|
|
20
|
+
export function defaultBaseForVersion(version) {
|
|
21
|
+
return /-dev(?:\.|$)/.test(String(version)) ? DEVELOPMENT_HTTP_ORIGIN : PRODUCTION_ORIGIN;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// dev 预发布包默认连接开发网关,稳定包默认连接生产网关。MANTURHUB_BASE
|
|
25
|
+
// 仍可显式覆盖;历史配置中保存过的官方默认地址不应覆盖当前发布通道。
|
|
26
|
+
const DEFAULT_BASE = defaultBaseForVersion(PACKAGE_VERSION);
|
|
27
|
+
const KNOWN_DEFAULT_BASES = new Set([
|
|
28
|
+
DEVELOPMENT_HTTP_ORIGIN,
|
|
29
|
+
PRODUCTION_ORIGIN,
|
|
18
30
|
"https://hub.mantur.cn",
|
|
19
31
|
"https://manturhub.leisurecat.cloud",
|
|
20
32
|
"https://api.ophub.com",
|
|
21
33
|
]);
|
|
22
34
|
|
|
35
|
+
export function isAllowedHttpUrl(url) {
|
|
36
|
+
const loopback =
|
|
37
|
+
url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
38
|
+
return url.protocol === "http:" && (loopback || url.origin === DEVELOPMENT_HTTP_ORIGIN);
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
export function loadConfig() {
|
|
24
42
|
try {
|
|
25
43
|
return JSON.parse(readFileSync(FILE, "utf8"));
|
|
@@ -49,9 +67,10 @@ function validateBaseUrl(value) {
|
|
|
49
67
|
} catch {
|
|
50
68
|
throw new Error(`ManturHub 网关地址不合法: ${value}`);
|
|
51
69
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
if (url.protocol !== "https:" && !isAllowedHttpUrl(url)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"MANTURHUB_BASE 必须使用 HTTPS(仅本机地址和 http://gateway-dev.guiyi.cn 可使用 HTTP)"
|
|
73
|
+
);
|
|
55
74
|
}
|
|
56
75
|
if (url.username || url.password) throw new Error("MANTURHUB_BASE 不得包含用户名或密码");
|
|
57
76
|
return url.href.replace(/\/$/, "");
|
|
@@ -60,6 +79,6 @@ function validateBaseUrl(value) {
|
|
|
60
79
|
export function getBaseUrl() {
|
|
61
80
|
if (process.env.MANTURHUB_BASE) return validateBaseUrl(process.env.MANTURHUB_BASE);
|
|
62
81
|
const storedBase = loadConfig().baseUrl?.replace(/\/$/, "");
|
|
63
|
-
if (storedBase && !
|
|
82
|
+
if (storedBase && !KNOWN_DEFAULT_BASES.has(storedBase)) return validateBaseUrl(storedBase);
|
|
64
83
|
return DEFAULT_BASE;
|
|
65
84
|
}
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
} 余额: ${usd}(${me.json.balance ?? "-"} 馒头)\n`
|
|
110
|
+
`\n ✓ 授权成功,Key 已验证并导入 ~/.manturhub/config.json。` +
|
|
111
|
+
`余额: ${usd}(${verifiedBalance.totalBalance} 馒头)\n`
|
|
105
112
|
);
|
|
106
113
|
} else {
|
|
107
|
-
|
|
108
|
-
|
|
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
|
}
|
package/lib/skill-install.js
CHANGED
|
@@ -10,9 +10,14 @@ import {
|
|
|
10
10
|
renameSync,
|
|
11
11
|
rmSync,
|
|
12
12
|
} from "node:fs";
|
|
13
|
-
import {
|
|
13
|
+
import { getBaseUrl } from "./config.js";
|
|
14
14
|
import { extractZipSafely, validateSlug } from "./archive.js";
|
|
15
|
-
import {
|
|
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 {
|
|
@@ -27,36 +32,31 @@ import {
|
|
|
27
32
|
skillDestination,
|
|
28
33
|
} from "./skill-state.js";
|
|
29
34
|
|
|
30
|
-
const CLI_SKILL_HEADERS = { "X-ManturHub-Client": "cli" };
|
|
31
|
-
|
|
32
35
|
function fail(message) {
|
|
33
36
|
console.error(message);
|
|
34
37
|
process.exit(1);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
async function fetchOnlineSkills() {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!response.ok) throw new Error(`Skill 列表获取失败(HTTP ${response.status})`);
|
|
43
|
-
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 }));
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
async function fetchSkillDetail(slug) {
|
|
47
|
-
const response = await apiFetch(
|
|
48
|
+
const response = await apiFetch(`${PUBLIC_SKILLS_PATH}/${encodeURIComponent(slug)}`, {
|
|
48
49
|
auth: "optional",
|
|
49
|
-
headers: CLI_SKILL_HEADERS,
|
|
50
50
|
});
|
|
51
51
|
if (response.status === 404) {
|
|
52
52
|
throw new Error(`Skill 不存在: ${slug}(用 \`manturhub skill ls\` 看可用列表)`);
|
|
53
53
|
}
|
|
54
54
|
if (!response.ok) throw new Error(`Skill 信息获取失败(HTTP ${response.status})`);
|
|
55
|
-
const detail = response.json
|
|
55
|
+
const detail = unwrapGatewayData(response.json) || {};
|
|
56
56
|
if (!detail?.version) {
|
|
57
57
|
throw new Error(`线上 Skill「${slug}」缺少 version,平台需先修正后才能安装`);
|
|
58
58
|
}
|
|
59
|
-
return detail;
|
|
59
|
+
return { ...detail, slug: detail.code };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function detectExistingClient(slug) {
|
|
@@ -103,13 +103,13 @@ function assertSafeToReplace(destinations, slug, state, baseUrl, force) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
async function downloadSkillBundle(slug, target) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
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 地址`);
|
|
109
111
|
}
|
|
110
|
-
const url = `${getBaseUrl()}/api/v1/skills/${encodeURIComponent(slug)}/download`;
|
|
111
112
|
const response = await fetch(url, {
|
|
112
|
-
headers: { "x-api-key": key, ...CLI_SKILL_HEADERS },
|
|
113
113
|
redirect: "manual",
|
|
114
114
|
signal: AbortSignal.timeout(30000),
|
|
115
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,22 +17,23 @@ function readPrevious() {
|
|
|
16
17
|
|
|
17
18
|
const baseUrl = getBaseUrl();
|
|
18
19
|
try {
|
|
19
|
-
const url = new URL(
|
|
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
|
-
headers: {
|
|
23
|
-
Accept: "application/json",
|
|
24
|
-
"X-ManturHub-Client": "cli",
|
|
25
|
-
},
|
|
26
|
+
headers: { Accept: "application/json" },
|
|
26
27
|
});
|
|
27
28
|
mkdirSync(DIR, { recursive: true });
|
|
28
29
|
if (response.ok) {
|
|
29
30
|
const json = await response.json();
|
|
30
|
-
const
|
|
31
|
-
|
|
31
|
+
const page = unwrapGatewayData(json) || {};
|
|
32
|
+
const skills = (page.records || [])
|
|
33
|
+
.filter((item) => item?.kind !== "suite" && item?.code && item?.version)
|
|
32
34
|
.map((item) => ({
|
|
33
|
-
slug: item.
|
|
34
|
-
name: item.name || item.
|
|
35
|
+
slug: item.code,
|
|
36
|
+
name: item.name || item.code,
|
|
35
37
|
version: item.version,
|
|
36
38
|
kind: item.kind || "skill",
|
|
37
39
|
}));
|
package/lib/suite-install.js
CHANGED
|
@@ -1,14 +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 {
|
|
4
|
+
import { getBaseUrl } from "./config.js";
|
|
5
5
|
import { extractZipSafely, validateSlug } from "./archive.js";
|
|
6
|
-
import {
|
|
6
|
+
import { fetchPublicCatalog, PUBLIC_SKILLS_PATH } from "./api.js";
|
|
7
7
|
import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// #456 Agent 套件(团队版 Skill):与 skill 共用 /api/v1/skills 元数据与下载端点,
|
|
9
|
+
// #456 Agent 套件(团队版 Skill):与 skill 共用公共 Skill 广场列表和固定 bundle 下载接口,
|
|
12
10
|
// 区别在安装形态——解压为当前目录下的工作目录(非用户级 skills)。API Key 继续由
|
|
13
11
|
// CLI 从 ~/.manturhub/config.json 读取,绝不复制进可能被提交的项目目录。
|
|
14
12
|
|
|
@@ -16,20 +14,14 @@ const CLI_SKILL_HEADERS = { "X-ManturHub-Client": "cli" };
|
|
|
16
14
|
export async function suiteLs({ json = false } = {}) {
|
|
17
15
|
let res;
|
|
18
16
|
try {
|
|
19
|
-
res = await
|
|
20
|
-
auth: "optional",
|
|
21
|
-
headers: CLI_SKILL_HEADERS,
|
|
22
|
-
});
|
|
17
|
+
res = await fetchPublicCatalog(PUBLIC_SKILLS_PATH);
|
|
23
18
|
} catch (e) {
|
|
24
19
|
console.error(`套件列表获取失败: ${e.message}`);
|
|
25
20
|
process.exit(1);
|
|
26
21
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
const data = res.json;
|
|
32
|
-
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 }));
|
|
33
25
|
if (json) {
|
|
34
26
|
console.log(JSON.stringify({ suites }, null, 2));
|
|
35
27
|
return;
|
|
@@ -60,18 +52,20 @@ export async function suiteInstall(slug, dirArg) {
|
|
|
60
52
|
console.error(error.message);
|
|
61
53
|
process.exit(1);
|
|
62
54
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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 地址`);
|
|
66
63
|
process.exit(1);
|
|
67
64
|
}
|
|
68
|
-
|
|
69
|
-
const url = `${getBaseUrl()}/api/v1/skills/${encodeURIComponent(slug)}/download`;
|
|
70
65
|
// 手动处理 302:跟随预签名地址时不把 API Key 带去对象存储(同 skill add)。
|
|
71
66
|
let res;
|
|
72
67
|
try {
|
|
73
68
|
res = await fetch(url, {
|
|
74
|
-
headers: { "x-api-key": key, ...CLI_SKILL_HEADERS },
|
|
75
69
|
redirect: "manual",
|
|
76
70
|
signal: AbortSignal.timeout(30000),
|
|
77
71
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manturhub/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10-dev.20260810.6",
|
|
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",
|