@manturhub/cli 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ManturHub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -42,7 +42,7 @@ CLI 会在付费调用前按算子实时 schema 校验未知字段、必填项
42
42
  | `manturhub run <算子ID> --json-file params.json` | 从文件读取参数,适合长提示词和自动化 |
43
43
  | `manturhub upload <本地文件>` | 流式上传图片、音频或视频并输出公网 URL |
44
44
  | `manturhub status <poll_url>` | 查询 `run --no-wait` 返回的异步任务 |
45
- | `manturhub balance [--json]` | 查询余额及人民币等值(1 馒头 = ¥0.1) |
45
+ | `manturhub balance [--json]` | 查询余额及美元等值(1 馒头 = $0.01 USD) |
46
46
  | `manturhub skill ls [--json]` / `skill add <slug> --client codex` | 浏览和安装业务 Skill |
47
47
  | `manturhub recipe [关键词]` / `recipe get <ID>` | 搜索并查看已验证配方 |
48
48
  | `manturhub suite ls [--json]` / `suite install <slug>` | 安装多角色 Agent 套件,不复制 API Key |
package/bin/cli.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { getBaseUrl, saveConfig, loadConfig } from "../lib/config.js";
3
3
  import { apiFetch, pollJob } from "../lib/api.js";
4
- import { runMcpBridge } from "../lib/mcp.js";
5
4
  import { runInit } from "../lib/setup.js";
6
5
  import { skillLs, skillAdd } from "../lib/skill-install.js";
7
6
  import { suiteLs, suiteInstall } from "../lib/suite-install.js";
@@ -79,9 +78,9 @@ function assertRunControlFlags(tokens) {
79
78
  }
80
79
  }
81
80
 
82
- function rmbFor(dumplings) {
81
+ function usdFor(dumplings) {
83
82
  const value = Number(dumplings);
84
- return Number.isFinite(value) ? `¥${(value * 0.1).toFixed(2)} RMB` : "-";
83
+ return Number.isFinite(value) ? `$${(value * 0.01).toFixed(2)} USD` : "-";
85
84
  }
86
85
 
87
86
  const HELP = `manturhub — ManturHub 算子广场 CLI v${VERSION}
@@ -159,7 +158,7 @@ async function main() {
159
158
  cfg.key = key;
160
159
  saveConfig(cfg);
161
160
  console.log(
162
- `✓ Key 已验证并保存。账号: ${r.json.email || "-"} 余额: ${rmbFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头)`
161
+ `✓ Key 已验证并保存。账号: ${r.json.email || "-"} 余额: ${usdFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头)`
163
162
  );
164
163
  } else {
165
164
  console.error(`Key 验证失败(HTTP ${r.status}),未修改本地配置。请确认 key 是否正确、是否已激活。`);
@@ -168,11 +167,6 @@ async function main() {
168
167
  break;
169
168
  }
170
169
 
171
- case "mcp": {
172
- await runMcpBridge({ scope: getFlag("scope", "manturhub") });
173
- break;
174
- }
175
-
176
170
  case "init": {
177
171
  runInit();
178
172
  break;
@@ -279,7 +273,7 @@ async function main() {
279
273
  );
280
274
  } else {
281
275
  console.log(`${r.json.operatorId || op}: ${r.json.formula || "详见算子页"}`);
282
- if (r.json.floor !== undefined) console.log(`最低扣费: ${rmbFor(r.json.floor)}(${r.json.floor} 馒头)`);
276
+ if (r.json.floor !== undefined) console.log(`最低扣费: ${usdFor(r.json.floor)}(${r.json.floor} 馒头)`);
283
277
  }
284
278
  break;
285
279
  }
@@ -452,8 +446,8 @@ async function main() {
452
446
  }
453
447
  console.log(
454
448
  hasFlag("json")
455
- ? JSON.stringify({ ...r.json, balance_rmb: Number(r.json.balance) * 0.1 }, null, 2)
456
- : `余额: ${rmbFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头) 账号: ${r.json.email || "-"}`
449
+ ? JSON.stringify({ ...r.json, balance_usd: Number(r.json.balance) * 0.01 }, null, 2)
450
+ : `余额: ${usdFor(r.json.balance)}(${r.json.balance ?? "-"} 馒头) 账号: ${r.json.email || "-"}`
457
451
  );
458
452
  break;
459
453
  }
package/lib/archive.js CHANGED
@@ -11,6 +11,7 @@ import { basename, join, posix } from "node:path";
11
11
  import { tmpdir } from "node:os";
12
12
 
13
13
  const MAX_FILES = 2000;
14
+ const MAX_UNPACKED_BYTES = 500 * 1024 * 1024;
14
15
 
15
16
  export function validateSlug(slug, label = "ID") {
16
17
  if (!slug || !/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) {
@@ -21,24 +22,28 @@ export function validateSlug(slug, label = "ID") {
21
22
 
22
23
  function listArchive(zipPath) {
23
24
  const commands = [
24
- {
25
- command: "tar",
26
- list: ["-tf", zipPath],
27
- verbose: ["-tvf", zipPath],
28
- extract: (dest) => ["-xf", zipPath, "-C", dest],
29
- },
30
25
  {
31
26
  command: "unzip",
32
27
  list: ["-Z1", zipPath],
33
28
  verbose: ["-Z", "-l", zipPath],
29
+ summary: ["-Z", "-t", zipPath],
34
30
  extract: (dest) => ["-o", "-q", zipPath, "-d", dest],
35
31
  },
32
+ {
33
+ command: "tar",
34
+ list: ["-tf", zipPath],
35
+ verbose: ["-tvf", zipPath],
36
+ extract: (dest) => ["-xf", zipPath, "-C", dest],
37
+ },
36
38
  ];
37
39
  for (const tool of commands) {
38
40
  try {
39
41
  const output = execFileSync(tool.command, tool.list, { encoding: "utf8" });
40
42
  const verbose = execFileSync(tool.command, tool.verbose, { encoding: "utf8" });
41
- return { tool, entries: output.split(/\r?\n/).filter(Boolean), verbose };
43
+ const summary = tool.summary
44
+ ? execFileSync(tool.command, tool.summary, { encoding: "utf8" })
45
+ : "";
46
+ return { tool, entries: output.split(/\r?\n/).filter(Boolean), verbose, summary };
42
47
  } catch {
43
48
  // Try the next installed extractor.
44
49
  }
@@ -46,9 +51,31 @@ function listArchive(zipPath) {
46
51
  throw new Error("解压失败(需系统 tar 或 unzip 命令)");
47
52
  }
48
53
 
49
- export function validateArchiveEntries(entries, verbose = "") {
54
+ function declaredUnpackedBytes(tool, verbose, summary) {
55
+ if (tool.command === "unzip") {
56
+ const match = summary.match(/([\d,]+)\s+bytes? uncompressed/i);
57
+ return match ? Number(match[1].replaceAll(",", "")) : Number.NaN;
58
+ }
59
+ let total = 0;
60
+ for (const line of verbose.split(/\r?\n/).filter(Boolean)) {
61
+ const bsd = line.match(/^\S+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+/);
62
+ const gnu = line.match(/^\S+\s+\S+\s+(\d+)\s+/);
63
+ const size = Number((bsd || gnu)?.[1]);
64
+ if (!Number.isFinite(size)) return Number.NaN;
65
+ total += size;
66
+ }
67
+ return total;
68
+ }
69
+
70
+ export function validateArchiveEntries(entries, verbose = "", unpackedBytes = 0) {
50
71
  if (!entries.length) throw new Error("安装包为空");
51
72
  if (entries.length > MAX_FILES) throw new Error(`安装包文件过多(最多 ${MAX_FILES} 个)`);
73
+ if (!Number.isFinite(unpackedBytes) || unpackedBytes < 0) {
74
+ throw new Error("无法确认安装包解压后大小");
75
+ }
76
+ if (unpackedBytes > MAX_UNPACKED_BYTES) {
77
+ throw new Error(`安装包解压后过大(最大 ${MAX_UNPACKED_BYTES / 1024 / 1024} MB)`);
78
+ }
52
79
  if (/^\s*[lh][rwx-]{9}\s/m.test(verbose)) {
53
80
  throw new Error("安装包不得包含符号链接或硬链接");
54
81
  }
@@ -57,6 +84,7 @@ export function validateArchiveEntries(entries, verbose = "") {
57
84
  const normalized = posix.normalize(name);
58
85
  if (
59
86
  name.includes("\0") ||
87
+ /[\x00-\x1f\x7f]/.test(name) ||
60
88
  name.startsWith("/") ||
61
89
  /^[a-z]:\//i.test(name) ||
62
90
  normalized === ".." ||
@@ -67,23 +95,48 @@ export function validateArchiveEntries(entries, verbose = "") {
67
95
  }
68
96
  }
69
97
 
70
- function rejectLinks(dir) {
98
+ function inspectExtracted(dir, state = { files: 0, bytes: 0 }) {
71
99
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
100
+ if (/[\x00-\x1f\x7f]/.test(entry.name)) {
101
+ throw new Error(`安装包包含不安全文件名: ${JSON.stringify(entry.name)}`);
102
+ }
72
103
  const path = join(dir, entry.name);
73
104
  const stat = lstatSync(path);
74
105
  if (stat.isSymbolicLink()) throw new Error(`安装包不得包含符号链接: ${entry.name}`);
75
- if (stat.isDirectory()) rejectLinks(path);
106
+ if (stat.isDirectory()) inspectExtracted(path, state);
107
+ else if (stat.isFile()) {
108
+ state.files++;
109
+ state.bytes += stat.size;
110
+ if (state.files > MAX_FILES) throw new Error(`安装包文件过多(最多 ${MAX_FILES} 个)`);
111
+ if (state.bytes > MAX_UNPACKED_BYTES) {
112
+ throw new Error(`安装包解压后过大(最大 ${MAX_UNPACKED_BYTES / 1024 / 1024} MB)`);
113
+ }
114
+ } else {
115
+ throw new Error(`安装包包含不支持的文件类型: ${entry.name}`);
116
+ }
117
+ }
118
+ return state;
119
+ }
120
+
121
+ function rejectDestinationLinks(dir) {
122
+ if (!lstatSync(dir).isDirectory()) throw new Error(`安装目标不是目录: ${dir}`);
123
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
124
+ const path = join(dir, entry.name);
125
+ const stat = lstatSync(path);
126
+ if (stat.isSymbolicLink()) throw new Error(`安装目标包含符号链接: ${path}`);
127
+ if (stat.isDirectory()) rejectDestinationLinks(path);
76
128
  }
77
129
  }
78
130
 
79
131
  export function extractZipSafely(zipPath, dest) {
80
- const { tool, entries, verbose } = listArchive(zipPath);
81
- validateArchiveEntries(entries, verbose);
132
+ const { tool, entries, verbose, summary } = listArchive(zipPath);
133
+ validateArchiveEntries(entries, verbose, declaredUnpackedBytes(tool, verbose, summary));
82
134
  const staging = mkdtempSync(join(tmpdir(), "manturhub-extract-"));
83
135
  try {
84
136
  execFileSync(tool.command, tool.extract(staging), { stdio: ["ignore", "ignore", "ignore"] });
85
- rejectLinks(staging);
137
+ inspectExtracted(staging);
86
138
  mkdirSync(dest, { recursive: true });
139
+ rejectDestinationLinks(dest);
87
140
  for (const entry of readdirSync(staging)) {
88
141
  cpSync(join(staging, entry), join(dest, basename(entry)), { recursive: true, force: true });
89
142
  }
package/lib/config.js CHANGED
@@ -42,9 +42,24 @@ export function getKey() {
42
42
  return process.env.MANTURHUB_KEY || loadConfig().key || null;
43
43
  }
44
44
 
45
+ function validateBaseUrl(value) {
46
+ let url;
47
+ try {
48
+ url = new URL(value);
49
+ } catch {
50
+ throw new Error(`ManturHub 网关地址不合法: ${value}`);
51
+ }
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)");
55
+ }
56
+ if (url.username || url.password) throw new Error("MANTURHUB_BASE 不得包含用户名或密码");
57
+ return url.href.replace(/\/$/, "");
58
+ }
59
+
45
60
  export function getBaseUrl() {
46
- if (process.env.MANTURHUB_BASE) return process.env.MANTURHUB_BASE.replace(/\/$/, "");
61
+ if (process.env.MANTURHUB_BASE) return validateBaseUrl(process.env.MANTURHUB_BASE);
47
62
  const storedBase = loadConfig().baseUrl?.replace(/\/$/, "");
48
- if (storedBase && !LEGACY_DEFAULT_BASES.has(storedBase)) return storedBase;
63
+ if (storedBase && !LEGACY_DEFAULT_BASES.has(storedBase)) return validateBaseUrl(storedBase);
49
64
  return DEFAULT_BASE;
50
65
  }
@@ -0,0 +1,45 @@
1
+ import { createWriteStream, rmSync } from "node:fs";
2
+ import { Readable, Transform } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+
5
+ export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024;
6
+
7
+ export function assertSecureDownloadUrl(value) {
8
+ let url;
9
+ try {
10
+ url = new URL(value);
11
+ } catch {
12
+ throw new Error("下载重定向地址不合法");
13
+ }
14
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
15
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
16
+ throw new Error("安装包下载地址必须使用 HTTPS");
17
+ }
18
+ return url.href;
19
+ }
20
+
21
+ export async function downloadResponseToFile(response, target, maxBytes = MAX_PACKAGE_BYTES) {
22
+ if (!response.ok) throw new Error(`安装包下载失败(HTTP ${response.status})`);
23
+ if (!response.body) throw new Error("安装包响应为空");
24
+ const declared = Number(response.headers.get("content-length"));
25
+ if (Number.isFinite(declared) && declared > maxBytes) {
26
+ await response.body.cancel();
27
+ throw new Error(`安装包过大(最大 ${Math.floor(maxBytes / 1024 / 1024)} MB)`);
28
+ }
29
+
30
+ let received = 0;
31
+ const limit = new Transform({
32
+ transform(chunk, _encoding, callback) {
33
+ received += chunk.length;
34
+ if (received > maxBytes) callback(new Error(`安装包过大(最大 ${Math.floor(maxBytes / 1024 / 1024)} MB)`));
35
+ else callback(null, chunk);
36
+ },
37
+ });
38
+ try {
39
+ await pipeline(Readable.fromWeb(response.body), limit, createWriteStream(target, { flags: "wx" }));
40
+ return received;
41
+ } catch (error) {
42
+ rmSync(target, { force: true });
43
+ throw error;
44
+ }
45
+ }
@@ -1,9 +1,10 @@
1
1
  import { homedir, tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { writeFileSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { getKey, getBaseUrl } from "./config.js";
5
5
  import { extractZipSafely, validateSlug } from "./archive.js";
6
6
  import { apiFetch } from "./api.js";
7
+ import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
7
8
 
8
9
  // `manturhub skill ls` — 列出平台上线 Skill(公开元数据,无需 key;
9
10
  // 配了 key 则带上——管理员租户的 key 能看到 admin 专属 Skill)。
@@ -76,30 +77,25 @@ export async function skillAdd(slug, client = "claude-code") {
76
77
  process.exit(1);
77
78
  }
78
79
 
79
- let zipBuf;
80
+ let packageResponse;
80
81
  if (res.status >= 300 && res.status < 400) {
81
82
  const loc = res.headers.get("location");
82
83
  if (!loc) {
83
84
  console.error("下载重定向缺少 Location 头");
84
85
  process.exit(1);
85
86
  }
86
- const zres = await fetch(loc, { signal: AbortSignal.timeout(120000) });
87
- if (!zres.ok) {
88
- console.error(`安装包下载失败(HTTP ${zres.status})`);
87
+ try {
88
+ packageResponse = await fetch(assertSecureDownloadUrl(loc), { signal: AbortSignal.timeout(120000) });
89
+ } catch (error) {
90
+ console.error(`安装包下载失败: ${error.message}`);
89
91
  process.exit(1);
90
92
  }
91
- zipBuf = Buffer.from(await zres.arrayBuffer());
92
93
  } else if (res.ok) {
93
- zipBuf = Buffer.from(await res.arrayBuffer());
94
+ packageResponse = res;
94
95
  } else {
95
96
  console.error(`下载失败(HTTP ${res.status})`);
96
97
  process.exit(1);
97
98
  }
98
- if (zipBuf.length > 100 * 1024 * 1024) {
99
- console.error("安装包过大(最大 100 MB)");
100
- process.exit(1);
101
- }
102
-
103
99
  const supported = new Set(["claude", "claude-code", "codex", "all"]);
104
100
  if (!supported.has(client)) {
105
101
  console.error("不支持的 client。可用值: claude-code | codex | all");
@@ -112,17 +108,19 @@ export async function skillAdd(slug, client = "claude-code") {
112
108
  if (client === "codex" || client === "all") {
113
109
  destinations.push(join(homedir(), ".agents", "skills", slug));
114
110
  }
115
- const tmp = join(tmpdir(), `manturhub-skill-${slug}-${process.pid}.zip`);
116
- writeFileSync(tmp, zipBuf);
117
- for (const dest of destinations) {
118
- try {
111
+ const tempDir = mkdtempSync(join(tmpdir(), "manturhub-skill-"));
112
+ const tmp = join(tempDir, `${slug}.zip`);
113
+ try {
114
+ await downloadResponseToFile(packageResponse, tmp);
115
+ for (const dest of destinations) {
119
116
  extractZipSafely(tmp, dest);
120
- } catch (error) {
121
- console.error(`解压失败: ${error.message}。安装包已存到: ${tmp}`);
122
- process.exit(1);
123
117
  }
118
+ } catch (error) {
119
+ console.error(`安装失败: ${error.message}`);
120
+ rmSync(tempDir, { recursive: true, force: true });
121
+ process.exit(1);
124
122
  }
125
- rmSync(tmp, { force: true });
123
+ rmSync(tempDir, { recursive: true, force: true });
126
124
  for (const dest of destinations) console.log(`✓ 已安装 Skill「${slug}」→ ${dest}`);
127
125
  console.log(" 重启对应 Agent 后,用自然语言描述任务;Claude Code 也可用 /<slug> 触发。");
128
126
  }
@@ -1,9 +1,10 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { tmpdir } from "node:os";
3
- import { writeFileSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { getKey, getBaseUrl } from "./config.js";
5
5
  import { extractZipSafely, validateSlug } from "./archive.js";
6
6
  import { apiFetch } from "./api.js";
7
+ import { assertSecureDownloadUrl, downloadResponseToFile } from "./download.js";
7
8
 
8
9
  // #456 Agent 套件(团队版 Skill):与 skill 共用 /api/v1/skills 元数据与下载端点,
9
10
  // 区别在安装形态——解压为当前目录下的工作目录(非用户级 skills)。API Key 继续由
@@ -82,40 +83,37 @@ export async function suiteInstall(slug, dirArg) {
82
83
  process.exit(1);
83
84
  }
84
85
 
85
- let zipBuf;
86
+ let packageResponse;
86
87
  if (res.status >= 300 && res.status < 400) {
87
88
  const loc = res.headers.get("location");
88
89
  if (!loc) {
89
90
  console.error("下载重定向缺少 Location 头");
90
91
  process.exit(1);
91
92
  }
92
- const zres = await fetch(loc, { signal: AbortSignal.timeout(120000) });
93
- if (!zres.ok) {
94
- console.error(`安装包下载失败(HTTP ${zres.status})`);
93
+ try {
94
+ packageResponse = await fetch(assertSecureDownloadUrl(loc), { signal: AbortSignal.timeout(120000) });
95
+ } catch (error) {
96
+ console.error(`安装包下载失败: ${error.message}`);
95
97
  process.exit(1);
96
98
  }
97
- zipBuf = Buffer.from(await zres.arrayBuffer());
98
99
  } else if (res.ok) {
99
- zipBuf = Buffer.from(await res.arrayBuffer());
100
+ packageResponse = res;
100
101
  } else {
101
102
  console.error(`下载失败(HTTP ${res.status})`);
102
103
  process.exit(1);
103
104
  }
104
- if (zipBuf.length > 100 * 1024 * 1024) {
105
- console.error("安装包过大(最大 100 MB)");
106
- process.exit(1);
107
- }
108
-
109
105
  const dest = resolve(dirArg || `./${slug}`);
110
- const tmp = join(tmpdir(), `manturhub-suite-${slug}-${process.pid}.zip`);
111
- writeFileSync(tmp, zipBuf);
106
+ const tempDir = mkdtempSync(join(tmpdir(), "manturhub-suite-"));
107
+ const tmp = join(tempDir, `${slug}.zip`);
112
108
  try {
109
+ await downloadResponseToFile(packageResponse, tmp);
113
110
  extractZipSafely(tmp, dest);
114
111
  } catch (error) {
115
- console.error(`解压失败: ${error.message}。安装包已存到: ${tmp}`);
112
+ console.error(`安装失败: ${error.message}`);
113
+ rmSync(tempDir, { recursive: true, force: true });
116
114
  process.exit(1);
117
115
  }
118
- rmSync(tmp, { force: true });
116
+ rmSync(tempDir, { recursive: true, force: true });
119
117
 
120
118
  console.log(`✓ 已安装套件「${slug}」→ ${dest}`);
121
119
  console.log(" API Key 继续安全保存在 ~/.manturhub/config.json,未复制到项目目录");
@@ -18,7 +18,7 @@ function isNewer(latest, current) {
18
18
  }
19
19
 
20
20
  // 启动时调用:读本地缓存→落后则 stderr 提示;缓存超过一天→后台异步刷新(不阻塞本次)。
21
- // 永不抛错、永不写 stdout —— mcp 模式 stdout 是 JSON-RPC 流,绝不能污染。
21
+ // 永不抛错、永不写 stdout,避免污染命令的机器可读输出。
22
22
  export function maybeNotifyUpdate(currentVersion) {
23
23
  if (process.env.MANTURHUB_DISABLE_UPDATE_CHECK === "1") return;
24
24
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manturhub/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "ManturHub 算子广场 CLI:通过 REST 发现和调用 AI 算子、浏览配方,并安装 Skill 与 Agent 套件",
5
5
  "type": "module",
6
6
  "bin": {
package/lib/mcp.js DELETED
@@ -1,108 +0,0 @@
1
- import { createInterface } from "node:readline";
2
- import { getKey, getBaseUrl } from "./config.js";
3
-
4
- // `manturhub mcp` — a stdio MCP server for clients (Claude Desktop / Codex /
5
- // Cursor …) that only speak stdio or whose remote-transport support is immature.
6
- //
7
- // It does NOT reimplement the MCP server: it relays each newline-delimited
8
- // JSON-RPC message from stdin to the remote ManturHub Streamable HTTP endpoint
9
- // (already standard-compliant) and writes the HTTP response back to stdout.
10
- // The remote handles initialize / tools/list / tools/call / scope. This is an
11
- // official, controlled equivalent of mcp-remote.
12
- export async function runMcpBridge({ scope = "manturhub" } = {}) {
13
- const key = getKey();
14
- if (!key) {
15
- process.stderr.write(
16
- "[manturhub legacy bridge] 未配置 API Key。运行 `manturhub login`,或设 MANTURHUB_KEY。\n"
17
- );
18
- process.exit(1);
19
- }
20
- const url = `${getBaseUrl()}/api/v1/mcp/${scope}/mcp`;
21
- const rl = createInterface({ input: process.stdin });
22
-
23
- // Track in-flight relays so we don't exit on stdin close while a fetch is
24
- // still pending (real clients keep stdin open; piped input closes early).
25
- let pending = 0;
26
- let closed = false;
27
- const maybeExit = () => {
28
- if (closed && pending === 0) process.exit(0);
29
- };
30
-
31
- rl.on("line", (line) => {
32
- const trimmed = line.trim();
33
- if (!trimmed) return;
34
- let msg;
35
- try {
36
- msg = JSON.parse(trimmed);
37
- } catch {
38
- return; // exactly one JSON value per line; ignore noise
39
- }
40
- pending++;
41
- relay(url, key, msg).finally(() => {
42
- pending--;
43
- maybeExit();
44
- });
45
- });
46
-
47
- rl.on("close", () => {
48
- closed = true;
49
- maybeExit();
50
- });
51
- }
52
-
53
- async function relay(url, key, msg) {
54
- try {
55
- const res = await fetch(url, {
56
- method: "POST",
57
- headers: {
58
- "Content-Type": "application/json",
59
- Accept: "application/json, text/event-stream",
60
- "x-api-key": key,
61
- "MCP-Protocol-Version": "2025-06-18",
62
- },
63
- body: JSON.stringify(msg),
64
- });
65
-
66
- // 202 Accepted = notification/response acknowledged, no body to relay.
67
- if (res.status === 202) return;
68
-
69
- const ctype = res.headers.get("content-type") || "";
70
- const text = await res.text();
71
- if (!text) return;
72
-
73
- if (ctype.includes("text/event-stream")) {
74
- // Defensive: if the remote ever streams SSE, pull JSON-RPC from data: lines.
75
- for (const evt of text.split(/\n\n/)) {
76
- const data = evt
77
- .split("\n")
78
- .filter((l) => l.startsWith("data:"))
79
- .map((l) => l.slice(5).trim())
80
- .join("");
81
- if (data) writeFrame(data);
82
- }
83
- return;
84
- }
85
-
86
- // Normal case: remote returns a single application/json JSON-RPC response.
87
- writeFrame(text);
88
- } catch (err) {
89
- // Transport failure → return a JSON-RPC error for this id (requests only).
90
- if (msg.id !== undefined && msg.id !== null) {
91
- writeFrame(
92
- JSON.stringify({
93
- jsonrpc: "2.0",
94
- id: msg.id,
95
- error: {
96
- code: -32603,
97
- message: "manturhub bridge: " + (err?.message || "upstream error"),
98
- },
99
- })
100
- );
101
- }
102
- }
103
- }
104
-
105
- // One JSON value per line (stdio framing). Collapse raw newlines so framing holds.
106
- function writeFrame(jsonText) {
107
- process.stdout.write(jsonText.replace(/\r?\n/g, " ") + "\n");
108
- }