@memoket-ai/cli 2.0.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/CHANGELOG.md +77 -0
- package/LICENSE +65 -0
- package/README.md +278 -0
- package/bin/memoket.js +18 -0
- package/config/prod.json +7 -0
- package/package.json +48 -0
- package/scripts/build.js +100 -0
- package/scripts/publish.js +227 -0
- package/src/auth-basic.js +65 -0
- package/src/cli.js +475 -0
- package/src/config.js +77 -0
- package/src/config.local.js +11 -0
- package/src/endpoints.js +112 -0
- package/src/gateway.js +128 -0
- package/src/http-integration.js +155 -0
- package/src/mcp-client.js +134 -0
- package/src/oauth.js +291 -0
- package/src/proxy.js +40 -0
- package/src/register.js +43 -0
- package/src/utils.js +83 -0
package/src/oauth.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// OAuth 2.0 + PKCE + Dynamic Client Registration.
|
|
2
|
+
// Flow: discover well-known metadata → DCR (public client, no secret) →
|
|
3
|
+
// browser auth-code → exchange + persist.
|
|
4
|
+
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { URL } from "node:url";
|
|
8
|
+
import open from "open";
|
|
9
|
+
import { defaultScope, clientName, callbackPort, credentialsPath } from "./config.js";
|
|
10
|
+
import { apiBaseFromMCPURL } from "./http-integration.js";
|
|
11
|
+
|
|
12
|
+
// authBase 是中心授权服务器(oauthserver)的对外地址。新架构里登录/发证由独立的 oauthserver
|
|
13
|
+
// 负责,与网关 API 同源;mcp-server 只是
|
|
14
|
+
// 资源服务器(RS),不再作为 CLI 的授权入口。可用 MEMOKET_AUTH_URL 覆盖。
|
|
15
|
+
function authBase(mcpURL) {
|
|
16
|
+
const explicit = process.env.MEMOKET_AUTH_URL;
|
|
17
|
+
if (explicit) return explicit.replace(/\/$/, "");
|
|
18
|
+
return apiBaseFromMCPURL(mcpURL);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function fetchWellKnown(mcpURL) {
|
|
22
|
+
const base = authBase(mcpURL);
|
|
23
|
+
const candidates = [
|
|
24
|
+
`${base}/.well-known/oauth-authorization-server`,
|
|
25
|
+
`${base}/.well-known/openid-configuration`,
|
|
26
|
+
];
|
|
27
|
+
let lastErr = null;
|
|
28
|
+
for (const c of candidates) {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(c, { signal: AbortSignal.timeout(15_000) });
|
|
31
|
+
if (res.status !== 200) {
|
|
32
|
+
lastErr = new Error(`${c} -> ${res.status}`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const wk = await res.json();
|
|
36
|
+
if (wk.authorization_endpoint && wk.token_endpoint) return wk;
|
|
37
|
+
lastErr = new Error(`${c} missing endpoints`);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
lastErr = e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`oauth discovery failed: ${lastErr?.message || lastErr}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function doDCR(regEndpoint, redirectURI) {
|
|
46
|
+
const res = await fetch(regEndpoint, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
client_name: clientName,
|
|
51
|
+
redirect_uris: [redirectURI],
|
|
52
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
53
|
+
response_types: ["code"],
|
|
54
|
+
token_endpoint_auth_method: "none",
|
|
55
|
+
scope: defaultScope,
|
|
56
|
+
}),
|
|
57
|
+
signal: AbortSignal.timeout(30_000),
|
|
58
|
+
});
|
|
59
|
+
if (res.status < 200 || res.status >= 300) {
|
|
60
|
+
const t = (await res.text()).slice(0, 200);
|
|
61
|
+
throw new Error(`dynamic client registration failed ${res.status}: ${t}`);
|
|
62
|
+
}
|
|
63
|
+
const out = await res.json();
|
|
64
|
+
if (!out.client_id) throw new Error("registration returned no client_id");
|
|
65
|
+
return out.client_id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pkce() {
|
|
69
|
+
const verifier = crypto.randomBytes(48).toString("base64url");
|
|
70
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
71
|
+
return { verifier, challenge };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function randState() {
|
|
75
|
+
return crypto.randomBytes(16).toString("base64url");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildAuthURL(authEndpoint, clientID, redirectURI, scope, state, challenge) {
|
|
79
|
+
const u = new URL(authEndpoint);
|
|
80
|
+
const sep = u.search ? "&" : "?";
|
|
81
|
+
const q = new URLSearchParams({
|
|
82
|
+
response_type: "code",
|
|
83
|
+
client_id: clientID,
|
|
84
|
+
redirect_uri: redirectURI,
|
|
85
|
+
scope,
|
|
86
|
+
state,
|
|
87
|
+
code_challenge: challenge,
|
|
88
|
+
code_challenge_method: "S256",
|
|
89
|
+
});
|
|
90
|
+
return `${authEndpoint}${sep}${q.toString()}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function postToken(tokenEndpoint, form) {
|
|
94
|
+
const res = await fetch(tokenEndpoint, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: {
|
|
97
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
98
|
+
Accept: "application/json",
|
|
99
|
+
},
|
|
100
|
+
body: new URLSearchParams(form).toString(),
|
|
101
|
+
signal: AbortSignal.timeout(30_000),
|
|
102
|
+
});
|
|
103
|
+
if (res.status < 200 || res.status >= 300) {
|
|
104
|
+
const t = (await res.text()).slice(0, 200);
|
|
105
|
+
throw new Error(`token endpoint ${res.status}: ${t}`);
|
|
106
|
+
}
|
|
107
|
+
const t = await res.json();
|
|
108
|
+
if (!t.access_token) throw new Error("no access_token in token response");
|
|
109
|
+
return t;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function awaitCallback(authURL, state) {
|
|
113
|
+
return new Promise(async (resolve, reject) => {
|
|
114
|
+
let settled = false;
|
|
115
|
+
let timer = null;
|
|
116
|
+
const finish = (fn) => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
settled = true;
|
|
119
|
+
if (timer) clearTimeout(timer);
|
|
120
|
+
fn();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const server = http.createServer((req, res) => {
|
|
124
|
+
const u = new URL(req.url, `http://127.0.0.1:${callbackPort}`);
|
|
125
|
+
if (u.pathname !== "/callback") {
|
|
126
|
+
res.writeHead(404);
|
|
127
|
+
res.end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const q = u.searchParams;
|
|
131
|
+
if (q.get("state") !== state) {
|
|
132
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
133
|
+
res.end("invalid state");
|
|
134
|
+
finish(() => {
|
|
135
|
+
server.close();
|
|
136
|
+
reject(new Error("state mismatch"));
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (q.get("error")) {
|
|
141
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
142
|
+
res.end(q.get("error"));
|
|
143
|
+
finish(() => {
|
|
144
|
+
server.close();
|
|
145
|
+
reject(new Error(`authorization error: ${q.get("error")}`));
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
150
|
+
res.end(
|
|
151
|
+
"<!doctype html><meta charset=utf-8><body style='font-family:sans-serif;padding:2rem'><h3>Memoket authorized ✓</h3><p>You can close this tab and return to the terminal.</p></body>",
|
|
152
|
+
);
|
|
153
|
+
const code = q.get("code");
|
|
154
|
+
finish(() => {
|
|
155
|
+
server.close();
|
|
156
|
+
resolve(code);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await new Promise((r, rj) => {
|
|
162
|
+
server.once("error", rj);
|
|
163
|
+
server.listen(callbackPort, "127.0.0.1", r);
|
|
164
|
+
});
|
|
165
|
+
} catch (e) {
|
|
166
|
+
finish(() => reject(new Error(`listen callback port ${callbackPort}: ${e.message}`)));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log("Opening browser to sign in…");
|
|
171
|
+
// Best-effort open. The `open` package handles the Windows & escape for us.
|
|
172
|
+
open(authURL).catch(() => {});
|
|
173
|
+
|
|
174
|
+
timer = setTimeout(
|
|
175
|
+
() =>
|
|
176
|
+
finish(() => {
|
|
177
|
+
server.close();
|
|
178
|
+
reject(new Error("authorization timed out (5 minutes)"));
|
|
179
|
+
}),
|
|
180
|
+
5 * 60 * 1000,
|
|
181
|
+
);
|
|
182
|
+
// Don't keep the process alive solely for this timeout once the callback
|
|
183
|
+
// server has closed.
|
|
184
|
+
timer.unref();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function login(mcpURL) {
|
|
189
|
+
const wk = await fetchWellKnown(mcpURL);
|
|
190
|
+
const redirectURI = `http://127.0.0.1:${callbackPort}/callback`;
|
|
191
|
+
let clientID = "";
|
|
192
|
+
if (wk.registration_endpoint) {
|
|
193
|
+
clientID = await doDCR(wk.registration_endpoint, redirectURI);
|
|
194
|
+
}
|
|
195
|
+
if (!clientID) {
|
|
196
|
+
throw new Error("server has no dynamic client registration; a pre-registered client_id is required");
|
|
197
|
+
}
|
|
198
|
+
const { verifier, challenge } = pkce();
|
|
199
|
+
const state = randState();
|
|
200
|
+
const authURL = buildAuthURL(
|
|
201
|
+
wk.authorization_endpoint,
|
|
202
|
+
clientID,
|
|
203
|
+
redirectURI,
|
|
204
|
+
defaultScope,
|
|
205
|
+
state,
|
|
206
|
+
challenge,
|
|
207
|
+
);
|
|
208
|
+
const code = await awaitCallback(authURL, state);
|
|
209
|
+
|
|
210
|
+
const tok = await postToken(wk.token_endpoint, {
|
|
211
|
+
grant_type: "authorization_code",
|
|
212
|
+
code,
|
|
213
|
+
redirect_uri: redirectURI,
|
|
214
|
+
client_id: clientID,
|
|
215
|
+
code_verifier: verifier,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const creds = {
|
|
219
|
+
url: mcpURL,
|
|
220
|
+
access_token: tok.access_token,
|
|
221
|
+
refresh_token: tok.refresh_token,
|
|
222
|
+
client_id: clientID,
|
|
223
|
+
token_endpoint: wk.token_endpoint,
|
|
224
|
+
updated_at: new Date().toISOString(),
|
|
225
|
+
};
|
|
226
|
+
if (tok.expires_in) {
|
|
227
|
+
creds.expires_at = Math.floor(Date.now() / 1000) + tok.expires_in;
|
|
228
|
+
}
|
|
229
|
+
await saveCreds(creds);
|
|
230
|
+
|
|
231
|
+
// Invalidate the cached mtok. The new access_token may belong to a
|
|
232
|
+
// different endpoint / user / scope set; the old mtok can no longer be
|
|
233
|
+
// trusted. ensureApiToken() will re-mint on the next call.
|
|
234
|
+
try {
|
|
235
|
+
const { readAuth, writeAuth } = await import("./auth.js");
|
|
236
|
+
const auth = await readAuth();
|
|
237
|
+
if (auth && auth.apiToken) {
|
|
238
|
+
delete auth.apiToken;
|
|
239
|
+
await writeAuth(auth);
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// auth.js missing or writeAuth failed — best-effort; safe to ignore.
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return creds;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function saveCreds(c) {
|
|
249
|
+
const p = await credentialsPath();
|
|
250
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
251
|
+
const { dirname } = await import("node:path");
|
|
252
|
+
await mkdir(dirname(p), { recursive: true, mode: 0o700 });
|
|
253
|
+
await writeFile(p, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function loadCreds() {
|
|
257
|
+
const { readFile } = await import("node:fs/promises");
|
|
258
|
+
const p = await credentialsPath();
|
|
259
|
+
try {
|
|
260
|
+
const raw = await readFile(p, "utf8");
|
|
261
|
+
return JSON.parse(raw);
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ensureToken returns a usable access token, refreshing when near expiry.
|
|
268
|
+
// Returns "" with no error when not logged in (callers may proceed unauth, e.g.
|
|
269
|
+
// tools/list and public product-knowledge tools work without a token).
|
|
270
|
+
export async function ensureToken(mcpURL) {
|
|
271
|
+
const c = await loadCreds();
|
|
272
|
+
if (!c) return "";
|
|
273
|
+
const now = Math.floor(Date.now() / 1000);
|
|
274
|
+
if (c.expires_at && now > c.expires_at - 30 && c.refresh_token) {
|
|
275
|
+
try {
|
|
276
|
+
const t = await postToken(c.token_endpoint, {
|
|
277
|
+
grant_type: "refresh_token",
|
|
278
|
+
refresh_token: c.refresh_token,
|
|
279
|
+
client_id: c.client_id,
|
|
280
|
+
});
|
|
281
|
+
c.access_token = t.access_token;
|
|
282
|
+
if (t.refresh_token) c.refresh_token = t.refresh_token;
|
|
283
|
+
if (t.expires_in) c.expires_at = Math.floor(Date.now() / 1000) + t.expires_in;
|
|
284
|
+
c.updated_at = new Date().toISOString();
|
|
285
|
+
await saveCreds(c);
|
|
286
|
+
} catch {
|
|
287
|
+
/* keep old token; caller will see 401 if it really expired */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return c.access_token || "";
|
|
291
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Proxy support for the Node fetch (undici). Go's net/http honors HTTP_PROXY /
|
|
2
|
+
// HTTPS_PROXY / NO_PROXY env vars by default; undici does not, so we wire them
|
|
3
|
+
// up explicitly. Most users behind the GFW will have these set already.
|
|
4
|
+
|
|
5
|
+
import { ProxyAgent, Agent, setGlobalDispatcher, getGlobalDispatcher } from "undici";
|
|
6
|
+
|
|
7
|
+
let installed = false;
|
|
8
|
+
let lastEnv = "";
|
|
9
|
+
|
|
10
|
+
function getProxyFor(url) {
|
|
11
|
+
const u = new URL(url);
|
|
12
|
+
const env = process.env;
|
|
13
|
+
if (u.protocol === "https:") {
|
|
14
|
+
return env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy || env.ALL_PROXY || env.all_proxy || "";
|
|
15
|
+
}
|
|
16
|
+
return env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy || env.ALL_PROXY || env.all_proxy || "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function installProxy() {
|
|
20
|
+
// Read the most permissive env var that could point to a proxy.
|
|
21
|
+
const proxyURL =
|
|
22
|
+
process.env.HTTPS_PROXY ||
|
|
23
|
+
process.env.https_proxy ||
|
|
24
|
+
process.env.HTTP_PROXY ||
|
|
25
|
+
process.env.http_proxy ||
|
|
26
|
+
process.env.ALL_PROXY ||
|
|
27
|
+
process.env.all_proxy ||
|
|
28
|
+
"";
|
|
29
|
+
// If env changed since last install, reinstall.
|
|
30
|
+
if (installed && lastEnv === proxyURL) return;
|
|
31
|
+
lastEnv = proxyURL;
|
|
32
|
+
installed = true;
|
|
33
|
+
if (proxyURL) {
|
|
34
|
+
setGlobalDispatcher(new ProxyAgent({ uri: proxyURL }));
|
|
35
|
+
} else {
|
|
36
|
+
setGlobalDispatcher(new Agent());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { getProxyFor };
|
package/src/register.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Registration writers shell out to each client's own MCP-add command. They
|
|
2
|
+
// only register the endpoint — they do NOT authenticate the client (each client
|
|
3
|
+
// runs its own OAuth, e.g. Claude Code's `/mcp`). Every writer also returns the
|
|
4
|
+
// exact command string so the user can run it by hand if the exec fails.
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const pexecFile = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
function trim(s, n = 200) {
|
|
12
|
+
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function registerClaude(name, mcpURL) {
|
|
16
|
+
const args = ["mcp", "add", "--transport", "http", name, mcpURL];
|
|
17
|
+
const cmdStr = `claude ${args.join(" ")}`;
|
|
18
|
+
try {
|
|
19
|
+
await pexecFile("claude", args);
|
|
20
|
+
return { ok: true, cmd: cmdStr };
|
|
21
|
+
} catch (e) {
|
|
22
|
+
if (e.code === "ENOENT") {
|
|
23
|
+
return { ok: false, cmd: cmdStr, err: new Error("claude CLI not found on PATH") };
|
|
24
|
+
}
|
|
25
|
+
const out = trim((e.stdout || "") + (e.stderr || ""));
|
|
26
|
+
return { ok: false, cmd: cmdStr, err: new Error(`${e.message}: ${out}`) };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function registerVSCode(name, mcpURL) {
|
|
31
|
+
const cfg = JSON.stringify({ name, type: "http", url: mcpURL });
|
|
32
|
+
const cmdStr = `code --add-mcp ${cfg}`;
|
|
33
|
+
try {
|
|
34
|
+
await pexecFile("code", ["--add-mcp", cfg]);
|
|
35
|
+
return { ok: true, cmd: cmdStr };
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e.code === "ENOENT") {
|
|
38
|
+
return { ok: false, cmd: cmdStr, err: new Error("code CLI not found on PATH") };
|
|
39
|
+
}
|
|
40
|
+
const out = trim((e.stdout || "") + (e.stderr || ""));
|
|
41
|
+
return { ok: false, cmd: cmdStr, err: new Error(`${e.message}: ${out}`) };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Misc formatting helpers ported from main.go.
|
|
2
|
+
|
|
3
|
+
export function tierOf(desc) {
|
|
4
|
+
const d = (desc || "").trim();
|
|
5
|
+
if (d.startsWith("[")) {
|
|
6
|
+
const i = d.indexOf("]");
|
|
7
|
+
if (i > 0) return d.slice(1, i);
|
|
8
|
+
}
|
|
9
|
+
return "other";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function stripTier(desc) {
|
|
13
|
+
const d = (desc || "").trim();
|
|
14
|
+
if (d.startsWith("[")) {
|
|
15
|
+
const i = d.indexOf("]");
|
|
16
|
+
if (i > 0) return d.slice(i + 1).trim();
|
|
17
|
+
}
|
|
18
|
+
return d;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function firstLine(s) {
|
|
22
|
+
const collapsed = (s || "").replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
|
23
|
+
return collapsed.length > 88 ? collapsed.slice(0, 88) + "…" : collapsed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function short(s) {
|
|
27
|
+
if (!s) return "-";
|
|
28
|
+
return s.length > 8 ? s.slice(0, 8) + "…" : s;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function or(s, fallback) {
|
|
32
|
+
return (s || "").trim() ? s : fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// schemaType 把一个 JSON Schema 属性节点转成简短类型串:"string[]" / "integer" 等。
|
|
36
|
+
function schemaType(p) {
|
|
37
|
+
if (!p) return "";
|
|
38
|
+
if (p.type === "array") return `${(p.items && p.items.type) || "any"}[]`;
|
|
39
|
+
return p.type || "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// toolParams 从网关 tool 的 input_schema 抽出参数清单:
|
|
43
|
+
// [{ name, required, type, description }]
|
|
44
|
+
// required 来源:schema 的 required 数组 ∪ 描述里自称必填的(后端有时只在文案里
|
|
45
|
+
// 写 "Required."/"必填" 而没进 required 数组,如 search_recordings 的 query)。
|
|
46
|
+
export function toolParams(tool) {
|
|
47
|
+
const schema = (tool && tool.input_schema) || {};
|
|
48
|
+
const props = schema.properties || {};
|
|
49
|
+
const reqSet = new Set(schema.required || []);
|
|
50
|
+
return Object.entries(props).map(([name, p]) => {
|
|
51
|
+
const description = (p && p.description) || "";
|
|
52
|
+
const declaredReq = /^\s*(required|必填|必须)/i.test(description);
|
|
53
|
+
return { name, required: reqSet.has(name) || declaredReq, type: schemaType(p), description };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// requiredParamNames 只返回必填参数名(给列表页/示例挑主参用)。
|
|
58
|
+
export function requiredParamNames(tool) {
|
|
59
|
+
return toolParams(tool).filter((p) => p.required).map((p) => p.name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Coerce CLI k=v args into JSON values: "true"/"false" -> bool, "42" -> number,
|
|
63
|
+
// JSON-shaped strings -> parsed, otherwise string.
|
|
64
|
+
export function coerce(v) {
|
|
65
|
+
if (v === "true") return true;
|
|
66
|
+
if (v === "false") return false;
|
|
67
|
+
if (v === "null") return null;
|
|
68
|
+
if (/^-?\d+$/.test(v)) {
|
|
69
|
+
const n = Number(v);
|
|
70
|
+
if (Number.isSafeInteger(n)) return n;
|
|
71
|
+
}
|
|
72
|
+
if (/^-?\d+\.\d+$/.test(v)) return Number(v);
|
|
73
|
+
if (v.startsWith("[") || v.startsWith("{")) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(v);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`coerce: value looks like JSON array/object but failed to parse: ${v.slice(0, 80)}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return v;
|
|
83
|
+
}
|