@bigbrain-work/mcp-connect 1.3.0 → 1.3.2

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
@@ -24,6 +24,12 @@ npx -y @bigbrain-work/mcp-connect status
24
24
  # 显示服务实时返回的工具
25
25
  npx -y @bigbrain-work/mcp-connect tools
26
26
 
27
+ # 每个项目最多每24小时检查一次石榴 Skill;仅有变化时定向更新
28
+ shiliu skill refresh
29
+
30
+ # 人工忽略冷却并立即重试
31
+ shiliu skill refresh --force
32
+
27
33
  # 撤销刷新令牌并清除本机凭据
28
34
  npx -y @bigbrain-work/mcp-connect logout
29
35
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigbrain-work/mcp-connect",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "石榴 AI 官方 CLI:登录、配置 Agent、检查 MCP 状态并桥接远程工具。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/arguments.js CHANGED
@@ -8,6 +8,7 @@ const COMMANDS = new Set([
8
8
  "tools",
9
9
  "mcp",
10
10
  "proxy",
11
+ "skill",
11
12
  "update",
12
13
  ]);
13
14
 
@@ -50,6 +51,10 @@ export function parseCliArguments(args) {
50
51
  type: "boolean",
51
52
  default: false,
52
53
  },
54
+ force: {
55
+ type: "boolean",
56
+ default: false,
57
+ },
53
58
  session: {
54
59
  type: "string",
55
60
  },
@@ -75,9 +80,13 @@ export function parseCliArguments(args) {
75
80
  throw new Error(`不支持的命令:${command}`);
76
81
  }
77
82
  const subcommand = parsed.positionals[1];
83
+ const validSubcommand =
84
+ (command === "login" && subcommand === "poll") ||
85
+ (command === "skill" && subcommand === "refresh");
78
86
  if (
79
87
  parsed.positionals.length > 2 ||
80
- (subcommand && !(command === "login" && subcommand === "poll"))
88
+ (subcommand && !validSubcommand) ||
89
+ (command === "skill" && subcommand !== "refresh")
81
90
  ) {
82
91
  throw new Error(`无法识别的参数:${parsed.positionals.slice(1).join(" ")}`);
83
92
  }
@@ -103,6 +112,7 @@ export function parseCliArguments(args) {
103
112
  legacyApiKey: parsed.values["legacy-api-key"],
104
113
  noWait: parsed.values["no-wait"],
105
114
  wait: parsed.values.wait,
115
+ force: parsed.values.force,
106
116
  session,
107
117
  json: parsed.values.json,
108
118
  help: parsed.values.help,
package/src/cli.js CHANGED
@@ -22,6 +22,8 @@ import { detectInstalledAgents } from "./detection.js";
22
22
  import {
23
23
  DeviceAuthClient,
24
24
  DeviceAuthError,
25
+ createLoginQrCode,
26
+ removeLoginQrCode,
25
27
  renderQrCode,
26
28
  waitForDeviceAuthorization,
27
29
  } from "./device-auth-client.js";
@@ -33,6 +35,7 @@ import {
33
35
  validateMcpUrl,
34
36
  } from "./security.js";
35
37
  import { printStatus, printTools, probeMcp } from "./status.js";
38
+ import { printSkillRefresh } from "./skill-refresh.js";
36
39
  import { PendingLoginStore, TokenStore } from "./token-store.js";
37
40
  import { printUpdateStatus } from "./updater.js";
38
41
 
@@ -46,6 +49,7 @@ function printHelp() {
46
49
  shiliu install [--agent <name>]
47
50
  shiliu status [--json]
48
51
  shiliu tools [--json]
52
+ shiliu skill refresh [--force] [--json]
49
53
  shiliu update
50
54
  shiliu logout
51
55
  shiliu mcp
@@ -64,6 +68,7 @@ function printHelp() {
64
68
  --legacy-api-key 使用旧 API Key 兼容登录
65
69
  --no-wait 创建登录会话后立即返回
66
70
  --wait 等待指定登录会话完成
71
+ --force 忽略24小时冷却并立即检查石榴 Skill
67
72
  --session <id> 指定待继续的登录会话
68
73
  --json 以 JSON 输出 status/tools
69
74
  -h, --help 显示帮助
@@ -107,7 +112,7 @@ async function legacyLogin({ home, dryRun, url }) {
107
112
  );
108
113
  }
109
114
 
110
- function buildPendingLogin(start, authUrl, allowLocalhost) {
115
+ function buildPendingLogin(start, authUrl, allowLocalhost, qrCodePath) {
111
116
  return {
112
117
  sessionId: normalizeDeviceUserCode(start.user_code),
113
118
  deviceCode: start.device_code,
@@ -115,10 +120,14 @@ function buildPendingLogin(start, authUrl, allowLocalhost) {
115
120
  interval: Number(start.interval) || 5,
116
121
  authUrl,
117
122
  allowLocalhost,
123
+ qrCodePath,
118
124
  };
119
125
  }
120
126
 
121
- export function buildLoginInstructions(start, { allowLocalhost = false } = {}) {
127
+ export function buildLoginInstructions(
128
+ start,
129
+ { allowLocalhost = false, qrCodePath } = {},
130
+ ) {
122
131
  const sessionId = normalizeDeviceUserCode(start.user_code);
123
132
  const localhostOption = allowLocalhost ? " --allow-localhost" : "";
124
133
  return {
@@ -129,10 +138,12 @@ export function buildLoginInstructions(start, { allowLocalhost = false } = {}) {
129
138
  start.verification_uri_complete ||
130
139
  start.qr_code_uri ||
131
140
  start.verification_uri,
141
+ qr_code_path: qrCodePath,
142
+ qr_code_mime_type: "image/png",
132
143
  expires_in: Number(start.expires_in),
133
144
  poll_command: `shiliu login poll --session ${sessionId} --wait --json${localhostOption}`,
134
145
  next_action_hint:
135
- "请让用户打开 verification_uri 完成微信授权;用户确认后运行 poll_command。",
146
+ "二维码已由石榴 CLI 生成。请直接向用户展示 qr_code_path 指向的图片,让用户使用微信扫码授权;不要自行生成二维码,也不要把 verification_uri 当作普通网页打开。用户确认后运行 poll_command。",
136
147
  };
137
148
  }
138
149
 
@@ -148,22 +159,41 @@ async function startPendingDeviceLogin({
148
159
  }) {
149
160
  const client = new DeviceAuthClient({ baseUrl: authUrl });
150
161
  const start = await client.start();
151
- await pendingLoginStore.save(
152
- buildPendingLogin(start, authUrl, allowLocalhost),
153
- );
154
- const instructions = buildLoginInstructions(start, { allowLocalhost });
162
+ const sessionId = normalizeDeviceUserCode(start.user_code);
163
+ const qrCodeValue =
164
+ start.qr_code_uri ||
165
+ start.verification_uri_complete ||
166
+ start.verification_uri;
167
+ const qrCodePath = await createLoginQrCode(qrCodeValue, sessionId);
168
+ try {
169
+ await pendingLoginStore.save(
170
+ buildPendingLogin(start, authUrl, allowLocalhost, qrCodePath),
171
+ );
172
+ } catch (error) {
173
+ await removeLoginQrCode(qrCodePath);
174
+ throw error;
175
+ }
176
+ const instructions = buildLoginInstructions(start, {
177
+ allowLocalhost,
178
+ qrCodePath,
179
+ });
155
180
  if (json) {
156
181
  printJson(instructions);
157
182
  return;
158
183
  }
159
184
  console.log(`请使用微信扫描二维码完成登录(验证码 ${start.user_code}):`);
160
- console.log(await renderQrCode(start.qr_code_uri));
161
- console.log(`也可以打开:${instructions.verification_uri}`);
185
+ console.log(await renderQrCode(qrCodeValue));
186
+ console.log(`二维码图片:${instructions.qr_code_path}`);
162
187
  console.log(
163
188
  `完成后运行:${instructions.poll_command.replace(" --json", "")}`,
164
189
  );
165
190
  }
166
191
 
192
+ async function clearPendingLogin(pendingLoginStore, pending) {
193
+ await pendingLoginStore.clear();
194
+ await removeLoginQrCode(pending?.qrCodePath);
195
+ }
196
+
167
197
  async function pollPendingDeviceLogin({
168
198
  session,
169
199
  wait,
@@ -178,7 +208,7 @@ async function pollPendingDeviceLogin({
178
208
  throw new Error("未找到对应的待处理登录会话,请重新运行 shiliu login");
179
209
  }
180
210
  if (pending.expiresAt <= Date.now()) {
181
- await pendingLoginStore.clear();
211
+ await clearPendingLogin(pendingLoginStore, pending);
182
212
  throw new Error("登录会话已过期,请重新运行 shiliu login");
183
213
  }
184
214
 
@@ -219,8 +249,13 @@ async function pollPendingDeviceLogin({
219
249
  else console.log("登录尚未完成,请授权后重新运行并加上 --wait。");
220
250
  return;
221
251
  }
222
- if (error instanceof DeviceAuthError && error.code === "expired_token") {
223
- await pendingLoginStore.clear();
252
+ if (
253
+ error instanceof DeviceAuthError &&
254
+ ["expired_token", "access_denied", "authorization_declined"].includes(
255
+ error.code,
256
+ )
257
+ ) {
258
+ await clearPendingLogin(pendingLoginStore, pending);
224
259
  }
225
260
  throw error;
226
261
  }
@@ -231,7 +266,7 @@ async function pollPendingDeviceLogin({
231
266
  tokenStore,
232
267
  url,
233
268
  });
234
- await pendingLoginStore.clear();
269
+ await clearPendingLogin(pendingLoginStore, pending);
235
270
  const result = {
236
271
  status: "success",
237
272
  message: "登录成功",
@@ -338,7 +373,8 @@ export async function logout({
338
373
  }
339
374
 
340
375
  await tokenStore.clear();
341
- await pendingLoginStore.clear();
376
+ const pending = await pendingLoginStore.load();
377
+ await clearPendingLogin(pendingLoginStore, pending);
342
378
  await clearLegacyApiKey({ home, dryRun: false });
343
379
  console.log("已退出登录,并从系统凭据库清除令牌和旧版兼容凭据。");
344
380
  }
@@ -402,6 +438,14 @@ export async function runCli(argv = process.argv.slice(2)) {
402
438
  }
403
439
 
404
440
  const home = path.resolve(options.home || os.homedir());
441
+ if (options.command === "skill") {
442
+ await printSkillRefresh({
443
+ home,
444
+ force: options.force,
445
+ json: options.json,
446
+ });
447
+ return;
448
+ }
405
449
  const url = validateMcpUrl(options.url || MCP_URL, {
406
450
  allowLocalhost: options.allowLocalhost,
407
451
  });
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PACKAGE_NAME = "@bigbrain-work/mcp-connect";
2
- export const PACKAGE_VERSION = "1.3.0";
2
+ export const PACKAGE_VERSION = "1.3.1";
3
3
  export const SERVER_NAME = "shiliu_mcp";
4
4
  export const MCP_URL = "https://api.bigbrain.work/shiliu/mcp";
5
5
  export const AUTH_URL = "https://api.bigbrain.work/shiliu/auth/device";
@@ -1,3 +1,7 @@
1
+ import { chmod, mkdir, readdir, stat, unlink } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
1
5
  import QRCode from "qrcode";
2
6
 
3
7
  import { AUTH_URL } from "./constants.js";
@@ -97,6 +101,77 @@ export async function renderQrCode(value) {
97
101
  });
98
102
  }
99
103
 
104
+ const QR_CODE_DIRECTORY = "shiliu-ai";
105
+ const QR_CODE_PREFIX = "shiliu-login-";
106
+ const QR_CODE_MAX_AGE_MS = 15 * 60 * 1000;
107
+
108
+ function qrCodeDirectory(temporaryDirectory = os.tmpdir()) {
109
+ return path.resolve(temporaryDirectory, QR_CODE_DIRECTORY);
110
+ }
111
+
112
+ function isManagedQrCodePath(filePath, temporaryDirectory = os.tmpdir()) {
113
+ if (typeof filePath !== "string" || filePath.length === 0) return false;
114
+ const resolvedPath = path.resolve(filePath);
115
+ return (
116
+ path.dirname(resolvedPath) === qrCodeDirectory(temporaryDirectory) &&
117
+ path.basename(resolvedPath).startsWith(QR_CODE_PREFIX) &&
118
+ path.extname(resolvedPath).toLowerCase() === ".png"
119
+ );
120
+ }
121
+
122
+ async function removeStaleQrCodes(directory, now) {
123
+ const entries = await readdir(directory, { withFileTypes: true });
124
+ await Promise.all(
125
+ entries
126
+ .filter(
127
+ (entry) =>
128
+ entry.isFile() &&
129
+ entry.name.startsWith(QR_CODE_PREFIX) &&
130
+ entry.name.endsWith(".png"),
131
+ )
132
+ .map(async (entry) => {
133
+ const filePath = path.join(directory, entry.name);
134
+ const metadata = await stat(filePath);
135
+ if (now() - metadata.mtimeMs > QR_CODE_MAX_AGE_MS) {
136
+ await unlink(filePath).catch(() => {});
137
+ }
138
+ }),
139
+ );
140
+ }
141
+
142
+ export async function createLoginQrCode(
143
+ value,
144
+ sessionId,
145
+ { temporaryDirectory = os.tmpdir(), now = Date.now } = {},
146
+ ) {
147
+ if (!/^[A-Z0-9-]{4,64}$/u.test(sessionId)) {
148
+ throw new Error("登录会话编号格式无效");
149
+ }
150
+ const directory = qrCodeDirectory(temporaryDirectory);
151
+ await mkdir(directory, { recursive: true, mode: 0o700 });
152
+ await removeStaleQrCodes(directory, now);
153
+ const filePath = path.join(directory, `${QR_CODE_PREFIX}${sessionId}.png`);
154
+ await QRCode.toFile(filePath, value, {
155
+ type: "png",
156
+ errorCorrectionLevel: "M",
157
+ margin: 2,
158
+ width: 480,
159
+ });
160
+ await chmod(filePath, 0o600).catch(() => {});
161
+ return path.resolve(filePath);
162
+ }
163
+
164
+ export async function removeLoginQrCode(
165
+ filePath,
166
+ { temporaryDirectory = os.tmpdir() } = {},
167
+ ) {
168
+ if (!isManagedQrCodePath(filePath, temporaryDirectory)) return false;
169
+ await unlink(filePath).catch((error) => {
170
+ if (error?.code !== "ENOENT") throw error;
171
+ });
172
+ return true;
173
+ }
174
+
100
175
  function defaultSleep(milliseconds) {
101
176
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
102
177
  }
package/src/proxy.js CHANGED
@@ -20,12 +20,14 @@ export async function runProxy({
20
20
  authUrl = AUTH_URL,
21
21
  platform = process.platform,
22
22
  env = process.env,
23
+ tokenStore,
23
24
  } = {}) {
24
25
  const authorization = await resolveAuthorization({
25
26
  home,
26
27
  platform,
27
28
  env,
28
29
  authUrl,
30
+ tokenStore,
29
31
  });
30
32
  if (!authorization.token) {
31
33
  throw new Error(
@@ -0,0 +1,202 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ mkdir,
4
+ open,
5
+ readFile,
6
+ stat,
7
+ unlink,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import path from "node:path";
11
+
12
+ export const SKILL_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
+ export const SHILIU_SKILL_NAME = "shiliu-ai-mcp";
14
+ const STALE_LOCK_MS = 15 * 60 * 1000;
15
+
16
+ function stateFile(home) {
17
+ return path.join(home, ".shiliu-ai", "skill-refresh.json");
18
+ }
19
+
20
+ function lockFile(home) {
21
+ return path.join(home, ".shiliu-ai", "skill-refresh.lock");
22
+ }
23
+
24
+ async function acquireLock(home, retried = false) {
25
+ await mkdir(path.dirname(lockFile(home)), { recursive: true });
26
+ let handle;
27
+ try {
28
+ handle = await open(lockFile(home), "wx", 0o600);
29
+ await handle.writeFile(`${process.pid}\n`, "utf8");
30
+ } catch (error) {
31
+ await handle?.close();
32
+ if (error.code === "EEXIST") {
33
+ if (!retried) {
34
+ try {
35
+ const lockStat = await stat(lockFile(home));
36
+ if (Date.now() - lockStat.mtimeMs >= STALE_LOCK_MS) {
37
+ await unlink(lockFile(home));
38
+ return acquireLock(home, true);
39
+ }
40
+ } catch (lockError) {
41
+ if (lockError.code === "ENOENT") return acquireLock(home, true);
42
+ throw lockError;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ throw error;
48
+ }
49
+ await handle.close();
50
+ return async () => {
51
+ try {
52
+ await unlink(lockFile(home));
53
+ } catch (error) {
54
+ if (error.code !== "ENOENT") throw error;
55
+ }
56
+ };
57
+ }
58
+
59
+ async function readState(home) {
60
+ try {
61
+ const value = JSON.parse(await readFile(stateFile(home), "utf8"));
62
+ if (value?.version === 1 && value.scopes && typeof value.scopes === "object") {
63
+ return value;
64
+ }
65
+ } catch (error) {
66
+ if (error.code !== "ENOENT" && error.name !== "SyntaxError") throw error;
67
+ }
68
+ return { version: 1, scopes: {} };
69
+ }
70
+
71
+ async function writeState(home, state) {
72
+ const file = stateFile(home);
73
+ await mkdir(path.dirname(file), { recursive: true });
74
+ await writeFile(file, `${JSON.stringify(state, null, 2)}\n`, {
75
+ encoding: "utf8",
76
+ mode: 0o600,
77
+ });
78
+ }
79
+
80
+ function defaultRunUpdate({ cwd, platform = process.platform }) {
81
+ const command = platform === "win32" ? "npx.cmd" : "npx";
82
+ const result = spawnSync(
83
+ command,
84
+ ["-y", "skills", "update", SHILIU_SKILL_NAME, "-y"],
85
+ {
86
+ cwd,
87
+ encoding: "utf8",
88
+ windowsHide: true,
89
+ shell: false,
90
+ maxBuffer: 1024 * 1024,
91
+ },
92
+ );
93
+ if (result.error) throw result.error;
94
+ if (result.status !== 0) {
95
+ throw new Error(
96
+ result.stderr?.trim() ||
97
+ result.stdout?.trim() ||
98
+ `skills update 返回退出码 ${result.status}`,
99
+ );
100
+ }
101
+ }
102
+
103
+ function validTimestamp(value) {
104
+ const timestamp = Date.parse(value || "");
105
+ return Number.isFinite(timestamp) ? timestamp : null;
106
+ }
107
+
108
+ export async function refreshShiliuSkill({
109
+ home,
110
+ cwd = process.cwd(),
111
+ now = Date.now(),
112
+ force = false,
113
+ intervalMs = SKILL_REFRESH_INTERVAL_MS,
114
+ runUpdate = defaultRunUpdate,
115
+ } = {}) {
116
+ if (!home) throw new Error("缺少用户目录,无法记录 Skill 检查时间");
117
+ const scope = path.resolve(cwd);
118
+ const state = await readState(home);
119
+ const previous = state.scopes[scope] || {};
120
+ const lastAttemptAt = validTimestamp(previous.lastAttemptAt);
121
+ if (!force && lastAttemptAt !== null && now - lastAttemptAt < intervalMs) {
122
+ return {
123
+ status: "skipped",
124
+ reason: "within_interval",
125
+ nextCheckAt: new Date(lastAttemptAt + intervalMs).toISOString(),
126
+ };
127
+ }
128
+
129
+ const releaseLock = await acquireLock(home);
130
+ if (!releaseLock) {
131
+ return {
132
+ status: "skipped",
133
+ reason: "refresh_in_progress",
134
+ };
135
+ }
136
+
137
+ try {
138
+ const latestState = await readState(home);
139
+ const latestPrevious = latestState.scopes[scope] || {};
140
+ const latestAttemptAt = validTimestamp(latestPrevious.lastAttemptAt);
141
+ if (
142
+ !force &&
143
+ latestAttemptAt !== null &&
144
+ now - latestAttemptAt < intervalMs
145
+ ) {
146
+ return {
147
+ status: "skipped",
148
+ reason: "within_interval",
149
+ nextCheckAt: new Date(latestAttemptAt + intervalMs).toISOString(),
150
+ };
151
+ }
152
+
153
+ const attemptedAt = new Date(now).toISOString();
154
+ latestState.scopes[scope] = {
155
+ ...latestPrevious,
156
+ lastAttemptAt: attemptedAt,
157
+ };
158
+ await writeState(home, latestState);
159
+
160
+ try {
161
+ await runUpdate({ cwd: scope });
162
+ latestState.scopes[scope].lastSuccessAt = attemptedAt;
163
+ await writeState(home, latestState);
164
+ return {
165
+ status: "checked",
166
+ checkedAt: attemptedAt,
167
+ nextCheckAt: new Date(now + intervalMs).toISOString(),
168
+ };
169
+ } catch (error) {
170
+ return {
171
+ status: "failed",
172
+ checkedAt: attemptedAt,
173
+ nextCheckAt: new Date(now + intervalMs).toISOString(),
174
+ message: error.message,
175
+ };
176
+ }
177
+ } finally {
178
+ await releaseLock();
179
+ }
180
+ }
181
+
182
+ export async function printSkillRefresh(options = {}) {
183
+ const result = await refreshShiliuSkill(options);
184
+ if (options.json) {
185
+ console.log(JSON.stringify(result, null, 2));
186
+ return result;
187
+ }
188
+ if (result.status === "skipped") {
189
+ console.log(
190
+ result.reason === "refresh_in_progress"
191
+ ? "另一个石榴 Skill 检查正在进行,本次继续使用已安装 Skill。"
192
+ : `石榴 Skill 在24小时内已检查,下次检查时间:${result.nextCheckAt}`,
193
+ );
194
+ } else if (result.status === "checked") {
195
+ console.log(`石榴 Skill 检查完成,下次检查时间:${result.nextCheckAt}`);
196
+ } else {
197
+ console.warn(
198
+ `石榴 Skill 检查失败:${result.message}。已进入24小时冷却,本次继续使用已安装 Skill。`,
199
+ );
200
+ }
201
+ return result;
202
+ }
@@ -84,6 +84,7 @@ function validatePendingLogin(value) {
84
84
  typeof value.expiresAt !== "number" ||
85
85
  typeof value.interval !== "number" ||
86
86
  typeof value.authUrl !== "string" ||
87
+ (value.qrCodePath !== undefined && typeof value.qrCodePath !== "string") ||
87
88
  (value.allowLocalhost !== undefined &&
88
89
  typeof value.allowLocalhost !== "boolean")
89
90
  ) {