@happyvertical/smrt-app-cli 0.43.6 → 0.43.8
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 +41 -0
- package/dist/bin/smrt-app.d.ts +8 -0
- package/dist/bin/smrt-app.js +18 -0
- package/dist/bin/smrt-mcp-bridge.js +2 -1
- package/dist/bridge-Crwjtcyt.js +2 -0
- package/dist/{config-B6arU8x7.js → config-CuTFiGxX.js} +24 -3
- package/dist/index.d.ts +32 -0
- package/dist/index.js +3 -1033
- package/dist/smrt-app.d.ts +1 -0
- package/dist/src-DCo67mp8.js +1143 -0
- package/package.json +4 -3
- package/dist/bridge-DqndDLee.js +0 -2
package/dist/index.js
CHANGED
|
@@ -1,1033 +1,3 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { SMRT_MCP_RESULT_METADATA_KEY,
|
|
3
|
-
|
|
4
|
-
//#region src/discovery.ts
|
|
5
|
-
/**
|
|
6
|
-
* Fetches the resource list from `GET /api/_resources` and re-exports the
|
|
7
|
-
* wire types so consumers can build typed extensions on top of the discovery
|
|
8
|
-
* output.
|
|
9
|
-
*
|
|
10
|
-
* Types are imported type-only from `@happyvertical/smrt-users/sveltekit`
|
|
11
|
-
* — the single source of truth for the wire contract. The CLI does not
|
|
12
|
-
* depend on smrt-users at runtime (the peer dep is `optional` in the
|
|
13
|
-
* package.json) so importing types is free of bundle cost.
|
|
14
|
-
*
|
|
15
|
-
* Importing type-only means a new field added to `CommandDefinition` /
|
|
16
|
-
* `CliResource` on the handler side automatically propagates to the CLI's
|
|
17
|
-
* typecheck — no manual mirror-update required. (#1311 review D-1.)
|
|
18
|
-
*
|
|
19
|
-
* @packageDocumentation
|
|
20
|
-
*/
|
|
21
|
-
/**
|
|
22
|
-
* Fetch the discovery payload.
|
|
23
|
-
*
|
|
24
|
-
* Translates a 401 into a friendlier error so the CLI can prompt the
|
|
25
|
-
* user to log in rather than dumping a raw HTTP error.
|
|
26
|
-
*/
|
|
27
|
-
async function fetchResourceList(context, options = {}) {
|
|
28
|
-
try {
|
|
29
|
-
const response = await requestJson(context, options.path ?? "/api/_resources", { method: "GET" }, {
|
|
30
|
-
fetch: options.fetch,
|
|
31
|
-
requireAuth: options.requireAuth,
|
|
32
|
-
loadedConfig: options.loadedConfig
|
|
33
|
-
});
|
|
34
|
-
if (!response.artifact) return response;
|
|
35
|
-
const artifact = validateDiscoveryConformanceArtifact(response.artifact);
|
|
36
|
-
return {
|
|
37
|
-
...artifact.discovery,
|
|
38
|
-
artifact
|
|
39
|
-
};
|
|
40
|
-
} catch (error) {
|
|
41
|
-
if (error instanceof Error && /401|unauthor/i.test(error.message)) throw new Error(`Not authenticated to ${context.envPrefix.toLowerCase()}. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
|
|
42
|
-
throw error;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Find a resource by slug in the discovery payload. Returns `undefined`
|
|
47
|
-
* if the slug isn't present.
|
|
48
|
-
*/
|
|
49
|
-
function findResourceBySlug(response, slug) {
|
|
50
|
-
return response.resources.find((r) => r.slug === slug);
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Find a command on a resource by its CLI-facing name. Returns
|
|
54
|
-
* `undefined` if not found.
|
|
55
|
-
*/
|
|
56
|
-
function findCommand(resource, commandName) {
|
|
57
|
-
return resource.commands.find((c) => c.commandName === commandName);
|
|
58
|
-
}
|
|
59
|
-
//#endregion
|
|
60
|
-
//#region src/invoke.ts
|
|
61
|
-
/**
|
|
62
|
-
* Build the URL the CLI should hit for this command, plus the fetch init
|
|
63
|
-
* (headers, body) it should pass. Returns enough so callers can stream
|
|
64
|
-
* the response — they decide how to render it (see `output.ts`).
|
|
65
|
-
*/
|
|
66
|
-
async function invokeCommand(options) {
|
|
67
|
-
const { context, resource, command, parsed, fetch: fetchImpl } = options;
|
|
68
|
-
const url = await buildUrl(context, resource, command, parsed, options.id);
|
|
69
|
-
const headers = new Headers();
|
|
70
|
-
const token = await getStoredToken(context);
|
|
71
|
-
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
72
|
-
let body;
|
|
73
|
-
if (command.httpMethod !== "GET" && command.httpMethod !== "DELETE") {
|
|
74
|
-
if (Object.keys(parsed.body).length > 0 || parsed.fromPositional) {
|
|
75
|
-
headers.set("content-type", "application/json");
|
|
76
|
-
body = JSON.stringify(parsed.body);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return (fetchImpl ?? fetch)(url, {
|
|
80
|
-
method: command.httpMethod,
|
|
81
|
-
headers,
|
|
82
|
-
body
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Build only the URL — exposed for tests.
|
|
87
|
-
*/
|
|
88
|
-
async function buildUrl(context, resource, command, parsed, id) {
|
|
89
|
-
const serverUrl = await getServerUrl(context);
|
|
90
|
-
const segments = ["api"];
|
|
91
|
-
for (const piece of splitPath(resource.apiPath)) segments.push(encodeURIComponent(piece));
|
|
92
|
-
if (command.scope === "item") {
|
|
93
|
-
if (!id) throw new Error(`Command \`${resource.slug} ${command.commandName}\` requires an id positional argument.`);
|
|
94
|
-
segments.push(encodeURIComponent(id));
|
|
95
|
-
}
|
|
96
|
-
for (const seg of command.pathSegments) for (const piece of splitPath(seg)) segments.push(encodeURIComponent(piece));
|
|
97
|
-
const base = `${serverUrl}/${segments.join("/")}`;
|
|
98
|
-
if (command.httpMethod === "GET" && Object.keys(parsed.query).length > 0) {
|
|
99
|
-
const params = new URLSearchParams();
|
|
100
|
-
for (const [k, v] of Object.entries(parsed.query)) if (Array.isArray(v)) for (const x of v) params.append(k, x);
|
|
101
|
-
else params.set(k, v);
|
|
102
|
-
return `${base}?${params.toString()}`;
|
|
103
|
-
}
|
|
104
|
-
return base;
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Split a URL path string into clean segments, stripping leading/trailing
|
|
108
|
-
* slashes and any empty pieces. Defends against `apiPath` overrides like
|
|
109
|
-
* `/v1/items/` or `pathSegments` like `users/` that would otherwise
|
|
110
|
-
* produce double slashes or trailing nothings in the final URL.
|
|
111
|
-
*/
|
|
112
|
-
function splitPath(s) {
|
|
113
|
-
if (typeof s !== "string") return [];
|
|
114
|
-
return s.split("/").filter((p) => p.length > 0);
|
|
115
|
-
}
|
|
116
|
-
//#endregion
|
|
117
|
-
//#region src/mcp-oauth.ts
|
|
118
|
-
function createMcpRegistrationRequest(options) {
|
|
119
|
-
if (!options.clientName.trim()) throw new Error("MCP client_name must not be empty.");
|
|
120
|
-
if (options.redirectUris.length === 0) throw new Error("MCP client metadata requires at least one redirect URI.");
|
|
121
|
-
for (const redirectUri of options.redirectUris) new URL(redirectUri);
|
|
122
|
-
return {
|
|
123
|
-
application_type: options.applicationType,
|
|
124
|
-
client_name: options.clientName,
|
|
125
|
-
grant_types: ["authorization_code"],
|
|
126
|
-
redirect_uris: [...options.redirectUris],
|
|
127
|
-
response_types: ["code"],
|
|
128
|
-
token_endpoint_auth_method: "none"
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
/** Build and validate the document hosted at an HTTPS URL used as client_id. */
|
|
132
|
-
function createMcpClientIdMetadataDocument(options) {
|
|
133
|
-
if (!options.clientId) throw new Error("MCP client_id metadata URL is required for CIMD.");
|
|
134
|
-
const clientId = new URL(options.clientId);
|
|
135
|
-
if (clientId.protocol !== "https:" || clientId.pathname === "/") throw new Error("MCP client_id metadata URL must use HTTPS and include a path.");
|
|
136
|
-
if (clientId.search || clientId.hash) throw new Error("MCP client_id metadata URL must not include query or fragment.");
|
|
137
|
-
return {
|
|
138
|
-
...createMcpRegistrationRequest(options),
|
|
139
|
-
client_id: options.clientId
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* Select registration in MCP priority order: Client ID Metadata Documents
|
|
144
|
-
* first, then RFC 7591 DCR as a compatibility fallback.
|
|
145
|
-
*/
|
|
146
|
-
function resolveMcpClientRegistration(authorizationServer, options) {
|
|
147
|
-
if (authorizationServer.client_id_metadata_document_supported === true) {
|
|
148
|
-
const metadataDocument = createMcpClientIdMetadataDocument(options);
|
|
149
|
-
return {
|
|
150
|
-
clientId: metadataDocument.client_id,
|
|
151
|
-
kind: "client_id_metadata_document",
|
|
152
|
-
metadataDocument
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
if (authorizationServer.registration_endpoint) return {
|
|
156
|
-
endpoint: authorizationServer.registration_endpoint,
|
|
157
|
-
kind: "dynamic_client_registration",
|
|
158
|
-
request: createMcpRegistrationRequest(options)
|
|
159
|
-
};
|
|
160
|
-
throw new Error("Authorization server supports neither Client ID Metadata Documents nor dynamic client registration.");
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Complete client registration against discovered authorization-server
|
|
164
|
-
* metadata. A Client ID Metadata Document needs no registration request: the
|
|
165
|
-
* authorization server retrieves it from the HTTPS client_id. The legacy DCR
|
|
166
|
-
* fallback is executed as an RFC 7591 JSON POST.
|
|
167
|
-
*/
|
|
168
|
-
async function registerMcpClient(authorizationServer, options, fetchImpl = fetch) {
|
|
169
|
-
const registration = resolveMcpClientRegistration(authorizationServer, options);
|
|
170
|
-
if (registration.kind === "client_id_metadata_document") return {
|
|
171
|
-
clientId: registration.clientId,
|
|
172
|
-
kind: registration.kind,
|
|
173
|
-
metadataDocument: registration.metadataDocument
|
|
174
|
-
};
|
|
175
|
-
const response = await fetchImpl(registration.endpoint, {
|
|
176
|
-
body: JSON.stringify(registration.request),
|
|
177
|
-
headers: { "content-type": "application/json" },
|
|
178
|
-
method: "POST"
|
|
179
|
-
});
|
|
180
|
-
if (!response.ok) throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
|
|
181
|
-
const body = await response.json();
|
|
182
|
-
if (typeof body !== "object" || body === null || typeof body.client_id !== "string" || !body.client_id) throw new Error("Dynamic client registration response omitted client_id.");
|
|
183
|
-
return {
|
|
184
|
-
clientId: body.client_id,
|
|
185
|
-
kind: registration.kind,
|
|
186
|
-
registrationResponse: body
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
//#endregion
|
|
190
|
-
//#region src/output.ts
|
|
191
|
-
var JSON_BUFFER_LIMIT = 10 * 1024 * 1024;
|
|
192
|
-
/**
|
|
193
|
-
* Render the response. Returns the desired exit code.
|
|
194
|
-
*/
|
|
195
|
-
async function renderResponse(response, options = {}) {
|
|
196
|
-
try {
|
|
197
|
-
return await renderResponseUnchecked(response, options);
|
|
198
|
-
} catch (err) {
|
|
199
|
-
if (err instanceof BrokenPipeError) return { exitCode: 0 };
|
|
200
|
-
throw err;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
async function renderResponseUnchecked(response, options = {}) {
|
|
204
|
-
const stdout = options.stdout ?? process.stdout;
|
|
205
|
-
const stderr = options.stderr ?? process.stderr;
|
|
206
|
-
const isTty = options.stdoutIsTty ?? Boolean(stdout.isTTY);
|
|
207
|
-
const ct = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
208
|
-
const cl = Number(response.headers.get("content-length") ?? "");
|
|
209
|
-
const isJson = ct.startsWith("application/json") || /\+json(\s|;|$)/.test(ct);
|
|
210
|
-
const isText = ct.startsWith("text/");
|
|
211
|
-
if (response.status === 204) return { exitCode: 0 };
|
|
212
|
-
if (response.status >= 400) {
|
|
213
|
-
if (isJson) {
|
|
214
|
-
const result = await readUntilLimitOrStream(response, JSON_BUFFER_LIMIT, stderr);
|
|
215
|
-
if (result.overflowed) stderr.write("\n[smrt-app-cli] error response exceeded 10MB cap; streamed raw\n");
|
|
216
|
-
else if (result.text) {
|
|
217
|
-
const pretty = safePrettyJson(result.text) ?? result.text;
|
|
218
|
-
stderr.write(`${pretty}\n`);
|
|
219
|
-
}
|
|
220
|
-
return { exitCode: response.status >= 500 ? 2 : 1 };
|
|
221
|
-
}
|
|
222
|
-
if (isText) {
|
|
223
|
-
await pipeBody(response, stderr);
|
|
224
|
-
return { exitCode: response.status >= 500 ? 2 : 1 };
|
|
225
|
-
}
|
|
226
|
-
stderr.write(`error: ${response.status} ${response.statusText || "HTTP error"}\n`);
|
|
227
|
-
return { exitCode: response.status >= 500 ? 2 : 1 };
|
|
228
|
-
}
|
|
229
|
-
if (isJson) {
|
|
230
|
-
if (cl && cl > JSON_BUFFER_LIMIT) {
|
|
231
|
-
stderr.write(`[smrt-app-cli] response too large to pretty-print (${cl} bytes); streaming raw JSON\n`);
|
|
232
|
-
await pipeBody(response, stdout);
|
|
233
|
-
return { exitCode: 0 };
|
|
234
|
-
}
|
|
235
|
-
const result = await readUntilLimitOrStream(response, JSON_BUFFER_LIMIT, stdout);
|
|
236
|
-
if (result.overflowed) {
|
|
237
|
-
stderr.write("[smrt-app-cli] response exceeded 10MB cap; streamed raw JSON\n");
|
|
238
|
-
return { exitCode: 0 };
|
|
239
|
-
}
|
|
240
|
-
if (!result.text) return { exitCode: 0 };
|
|
241
|
-
const pretty = safePrettyJson(result.text) ?? result.text;
|
|
242
|
-
stdout.write(`${pretty}\n`);
|
|
243
|
-
return { exitCode: 0 };
|
|
244
|
-
}
|
|
245
|
-
if (isText) {
|
|
246
|
-
await pipeBody(response, stdout);
|
|
247
|
-
return { exitCode: 0 };
|
|
248
|
-
}
|
|
249
|
-
if (isTty) {
|
|
250
|
-
const size = cl ? ` (${cl} bytes)` : "";
|
|
251
|
-
stderr.write(`[smrt-app-cli] binary response${size}; redirect to a file to capture: <cli> ... > out.bin\n`);
|
|
252
|
-
return { exitCode: 1 };
|
|
253
|
-
}
|
|
254
|
-
await pipeBody(response, stdout);
|
|
255
|
-
return { exitCode: 0 };
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Read the response body into memory up to `limit` bytes. On overflow,
|
|
259
|
-
* flush what's been buffered to `out` (default: stdout) and pipe the
|
|
260
|
-
* remaining body in chunks, so callers never silently truncate. Returns
|
|
261
|
-
* `{ text, overflowed }` so callers can branch on the outcome.
|
|
262
|
-
*/
|
|
263
|
-
async function readUntilLimitOrStream(response, limit, out) {
|
|
264
|
-
if (!response.body) return {
|
|
265
|
-
text: "",
|
|
266
|
-
overflowed: false
|
|
267
|
-
};
|
|
268
|
-
const writeStream = out ?? process.stdout;
|
|
269
|
-
const reader = response.body.getReader();
|
|
270
|
-
const chunks = [];
|
|
271
|
-
let size = 0;
|
|
272
|
-
while (true) {
|
|
273
|
-
const { done, value } = await reader.read();
|
|
274
|
-
if (done) break;
|
|
275
|
-
if (!value) continue;
|
|
276
|
-
size += value.byteLength;
|
|
277
|
-
if (size > limit) {
|
|
278
|
-
for (const c of chunks) await writeChunk(writeStream, c);
|
|
279
|
-
await writeChunk(writeStream, value);
|
|
280
|
-
while (true) {
|
|
281
|
-
const next = await reader.read();
|
|
282
|
-
if (next.done) break;
|
|
283
|
-
if (next.value) await writeChunk(writeStream, next.value);
|
|
284
|
-
}
|
|
285
|
-
return {
|
|
286
|
-
text: "",
|
|
287
|
-
overflowed: true
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
chunks.push(value);
|
|
291
|
-
}
|
|
292
|
-
return {
|
|
293
|
-
text: new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))),
|
|
294
|
-
overflowed: false
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Sentinel thrown when a write to the output stream fails because the
|
|
299
|
-
* downstream consumer closed (EPIPE) or the stream errored. Callers
|
|
300
|
-
* catch this and exit cleanly — broken pipes are normal under shell
|
|
301
|
-
* pipelines (`<cli> list | head -1`), not errors to escalate.
|
|
302
|
-
*/
|
|
303
|
-
var BrokenPipeError = class extends Error {
|
|
304
|
-
constructor(cause) {
|
|
305
|
-
super("Output stream closed");
|
|
306
|
-
this.name = "BrokenPipeError";
|
|
307
|
-
if (cause !== void 0) this.cause = cause;
|
|
308
|
-
}
|
|
309
|
-
};
|
|
310
|
-
/**
|
|
311
|
-
* Write a chunk to the output stream and wait for `drain` if needed.
|
|
312
|
-
*
|
|
313
|
-
* Races the `drain` promise against an `error` event so that broken-pipe
|
|
314
|
-
* errors (EPIPE, ECONNRESET on a stdout consumer that died) reject the
|
|
315
|
-
* pending promise rather than leaving the CLI hung forever waiting for a
|
|
316
|
-
* `drain` that will never come. (#1311 review #1.)
|
|
317
|
-
*/
|
|
318
|
-
async function writeChunk(out, chunk) {
|
|
319
|
-
const stream = out;
|
|
320
|
-
let ok;
|
|
321
|
-
try {
|
|
322
|
-
ok = stream.write(Buffer.from(chunk));
|
|
323
|
-
} catch (err) {
|
|
324
|
-
throw new BrokenPipeError(err);
|
|
325
|
-
}
|
|
326
|
-
if (ok) return;
|
|
327
|
-
await new Promise((resolve, reject) => {
|
|
328
|
-
const onDrain = () => {
|
|
329
|
-
stream.off("error", onError);
|
|
330
|
-
resolve();
|
|
331
|
-
};
|
|
332
|
-
const onError = (err) => {
|
|
333
|
-
stream.off("drain", onDrain);
|
|
334
|
-
reject(new BrokenPipeError(err));
|
|
335
|
-
};
|
|
336
|
-
stream.once("drain", onDrain);
|
|
337
|
-
stream.once("error", onError);
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
async function pipeBody(response, out) {
|
|
341
|
-
if (!response.body) return;
|
|
342
|
-
const reader = response.body.getReader();
|
|
343
|
-
try {
|
|
344
|
-
while (true) {
|
|
345
|
-
const { done, value } = await reader.read();
|
|
346
|
-
if (done) break;
|
|
347
|
-
if (!value) continue;
|
|
348
|
-
await writeChunk(out, value);
|
|
349
|
-
}
|
|
350
|
-
} finally {
|
|
351
|
-
try {
|
|
352
|
-
reader.releaseLock();
|
|
353
|
-
} catch {}
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
function safePrettyJson(text) {
|
|
357
|
-
if (!text) return void 0;
|
|
358
|
-
try {
|
|
359
|
-
return JSON.stringify(JSON.parse(text), null, 2);
|
|
360
|
-
} catch {
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
//#endregion
|
|
365
|
-
//#region src/parser.ts
|
|
366
|
-
/**
|
|
367
|
-
* Classify a JSONSchema. The CLI uses this to decide between rich flag
|
|
368
|
-
* parsing and the positional JSON escape hatch.
|
|
369
|
-
*/
|
|
370
|
-
function classifySchema(schema) {
|
|
371
|
-
if (!schema || Object.keys(schema).length === 0) return { kind: "missing" };
|
|
372
|
-
if (schema.type !== "object") return {
|
|
373
|
-
kind: "unsupported",
|
|
374
|
-
reason: "schema root is not an object"
|
|
375
|
-
};
|
|
376
|
-
if (schema.oneOf || schema.anyOf || schema.allOf || schema.$ref) return {
|
|
377
|
-
kind: "unsupported",
|
|
378
|
-
reason: "oneOf/anyOf/allOf/$ref"
|
|
379
|
-
};
|
|
380
|
-
const props = schema.properties ?? {};
|
|
381
|
-
for (const [name, prop] of Object.entries(props)) {
|
|
382
|
-
const status = classifyProperty(prop);
|
|
383
|
-
if (status.kind === "unsupported") return {
|
|
384
|
-
kind: "unsupported",
|
|
385
|
-
reason: `${name}: ${status.reason}`
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
return { kind: "ok" };
|
|
389
|
-
}
|
|
390
|
-
function classifyProperty(prop) {
|
|
391
|
-
const t = normaliseType(prop);
|
|
392
|
-
if (!t) return {
|
|
393
|
-
kind: "unsupported",
|
|
394
|
-
reason: "no type"
|
|
395
|
-
};
|
|
396
|
-
if (prop.oneOf || prop.anyOf || prop.allOf || prop.$ref) return {
|
|
397
|
-
kind: "unsupported",
|
|
398
|
-
reason: "oneOf/anyOf/allOf/$ref"
|
|
399
|
-
};
|
|
400
|
-
if (t.primary === "object") return {
|
|
401
|
-
kind: "unsupported",
|
|
402
|
-
reason: "nested object"
|
|
403
|
-
};
|
|
404
|
-
if (t.primary === "array") {
|
|
405
|
-
const itemsType = prop.items?.type;
|
|
406
|
-
if (itemsType !== "string" && itemsType !== "integer" && itemsType !== "number") return {
|
|
407
|
-
kind: "unsupported",
|
|
408
|
-
reason: "array of non-primitives"
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
return { kind: "ok" };
|
|
412
|
-
}
|
|
413
|
-
function normaliseType(prop) {
|
|
414
|
-
const raw = prop.type;
|
|
415
|
-
if (typeof raw === "string") return {
|
|
416
|
-
primary: raw,
|
|
417
|
-
nullable: Boolean(prop.nullable)
|
|
418
|
-
};
|
|
419
|
-
if (Array.isArray(raw)) {
|
|
420
|
-
const nullable = raw.includes("null");
|
|
421
|
-
const nonNull = raw.find((t) => t !== "null");
|
|
422
|
-
if (!nonNull) return void 0;
|
|
423
|
-
return {
|
|
424
|
-
primary: nonNull,
|
|
425
|
-
nullable
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
function buildFlagParser(schema, options = {}) {
|
|
430
|
-
const status = options.positionalOnly ? {
|
|
431
|
-
kind: "unsupported",
|
|
432
|
-
reason: "forced positional"
|
|
433
|
-
} : classifySchema(schema);
|
|
434
|
-
if (status.kind !== "ok") return {
|
|
435
|
-
status,
|
|
436
|
-
parse: makePositionalParser()
|
|
437
|
-
};
|
|
438
|
-
return {
|
|
439
|
-
status,
|
|
440
|
-
parse: makeRichParser(schema)
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
function makePositionalParser() {
|
|
444
|
-
return (argv, httpMethod) => {
|
|
445
|
-
const positional = argv.find((a) => !a.startsWith("-"));
|
|
446
|
-
if (!positional) return {
|
|
447
|
-
body: {},
|
|
448
|
-
query: {},
|
|
449
|
-
fromPositional: true
|
|
450
|
-
};
|
|
451
|
-
let parsed;
|
|
452
|
-
try {
|
|
453
|
-
parsed = JSON.parse(positional);
|
|
454
|
-
} catch (error) {
|
|
455
|
-
throw new Error(`Could not parse positional JSON argument: ${error.message}`);
|
|
456
|
-
}
|
|
457
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Positional JSON argument must be an object.");
|
|
458
|
-
if (httpMethod === "GET") return {
|
|
459
|
-
body: {},
|
|
460
|
-
query: objectToQuery(parsed),
|
|
461
|
-
fromPositional: true
|
|
462
|
-
};
|
|
463
|
-
return {
|
|
464
|
-
body: parsed,
|
|
465
|
-
query: {},
|
|
466
|
-
fromPositional: true
|
|
467
|
-
};
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
function makeRichParser(schema) {
|
|
471
|
-
const props = schema.properties ?? {};
|
|
472
|
-
const required = new Set(schema.required ?? [] ?? []);
|
|
473
|
-
const additionalProperties = schema.additionalProperties !== false;
|
|
474
|
-
return (argv, httpMethod) => {
|
|
475
|
-
const out = {};
|
|
476
|
-
let positionalJson;
|
|
477
|
-
for (let i = 0; i < argv.length; i++) {
|
|
478
|
-
const arg = argv[i];
|
|
479
|
-
if (!arg.startsWith("-")) {
|
|
480
|
-
if (positionalJson) throw new Error(`Unexpected extra positional argument: ${arg}`);
|
|
481
|
-
let parsed;
|
|
482
|
-
try {
|
|
483
|
-
parsed = JSON.parse(arg);
|
|
484
|
-
} catch {
|
|
485
|
-
throw new Error(`Unknown positional argument (not valid JSON): ${arg}`);
|
|
486
|
-
}
|
|
487
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Positional JSON argument must be an object.");
|
|
488
|
-
positionalJson = parsed;
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
if (arg === "--") break;
|
|
492
|
-
if (arg.startsWith("--no-")) {
|
|
493
|
-
const flagName = arg.slice(5);
|
|
494
|
-
const prop = props[flagName];
|
|
495
|
-
if (!prop) {
|
|
496
|
-
if (additionalProperties) {
|
|
497
|
-
out[flagName] = false;
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
throw new Error(`Unknown flag: ${arg}`);
|
|
501
|
-
}
|
|
502
|
-
if (normaliseType(prop)?.primary !== "boolean") throw new Error(`--no-${flagName} requires a boolean flag.`);
|
|
503
|
-
out[flagName] = false;
|
|
504
|
-
continue;
|
|
505
|
-
}
|
|
506
|
-
let key;
|
|
507
|
-
let value;
|
|
508
|
-
const eqIdx = arg.indexOf("=");
|
|
509
|
-
if (eqIdx >= 0) {
|
|
510
|
-
key = arg.slice(2, eqIdx);
|
|
511
|
-
value = arg.slice(eqIdx + 1);
|
|
512
|
-
} else {
|
|
513
|
-
key = arg.slice(2);
|
|
514
|
-
const prop = props[key];
|
|
515
|
-
if ((prop ? normaliseType(prop) : void 0)?.primary === "boolean") {
|
|
516
|
-
const next = argv[i + 1];
|
|
517
|
-
if (next === "true" || next === "false") {
|
|
518
|
-
out[key] = next === "true";
|
|
519
|
-
i += 1;
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
out[key] = true;
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
const next = argv[i + 1];
|
|
526
|
-
if (next === void 0 || next.startsWith("-")) throw new Error(`Flag --${key} requires a value.`);
|
|
527
|
-
value = next;
|
|
528
|
-
i += 1;
|
|
529
|
-
}
|
|
530
|
-
const prop = props[key];
|
|
531
|
-
if (!prop) {
|
|
532
|
-
if (!additionalProperties) throw new Error(`Unknown flag: --${key}`);
|
|
533
|
-
appendValue(out, key, value);
|
|
534
|
-
continue;
|
|
535
|
-
}
|
|
536
|
-
const t = normaliseType(prop);
|
|
537
|
-
if (!t) throw new Error(`Unknown flag: --${key}`);
|
|
538
|
-
if (t.nullable && value === "null") {
|
|
539
|
-
out[key] = null;
|
|
540
|
-
continue;
|
|
541
|
-
}
|
|
542
|
-
switch (t.primary) {
|
|
543
|
-
case "string": {
|
|
544
|
-
const allowed = prop.enum;
|
|
545
|
-
if (allowed && !allowed.includes(value)) throw new Error(`--${key}: expected one of ${allowed.join(", ")}; got ${JSON.stringify(value)}.`);
|
|
546
|
-
out[key] = value;
|
|
547
|
-
break;
|
|
548
|
-
}
|
|
549
|
-
case "integer": {
|
|
550
|
-
const n = Number(value);
|
|
551
|
-
if (!Number.isInteger(n)) throw new Error(`--${key}: expected integer; got ${JSON.stringify(value)}.`);
|
|
552
|
-
out[key] = n;
|
|
553
|
-
break;
|
|
554
|
-
}
|
|
555
|
-
case "number": {
|
|
556
|
-
const n = Number(value);
|
|
557
|
-
if (Number.isNaN(n)) throw new Error(`--${key}: expected number; got ${JSON.stringify(value)}.`);
|
|
558
|
-
out[key] = n;
|
|
559
|
-
break;
|
|
560
|
-
}
|
|
561
|
-
case "boolean":
|
|
562
|
-
if (value === "true" || value === "false") out[key] = value === "true";
|
|
563
|
-
else throw new Error(`--${key}: boolean accepts true/false; got ${JSON.stringify(value)}.`);
|
|
564
|
-
break;
|
|
565
|
-
case "array": {
|
|
566
|
-
const itemsType = prop.items?.type;
|
|
567
|
-
const parts = value.includes(",") ? value.split(",") : [value];
|
|
568
|
-
const arr = Array.isArray(out[key]) ? out[key] : [];
|
|
569
|
-
for (const part of parts) arr.push(coerce(part, itemsType));
|
|
570
|
-
out[key] = arr;
|
|
571
|
-
break;
|
|
572
|
-
}
|
|
573
|
-
default: throw new Error(`--${key}: unsupported schema type ${t.primary}.`);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
if (positionalJson) {
|
|
577
|
-
for (const [k, v] of Object.entries(positionalJson)) if (out[k] === void 0) out[k] = v;
|
|
578
|
-
}
|
|
579
|
-
for (const [name, prop] of Object.entries(props)) {
|
|
580
|
-
if (out[name] !== void 0) continue;
|
|
581
|
-
if (prop.default !== void 0) out[name] = prop.default;
|
|
582
|
-
}
|
|
583
|
-
for (const name of required) if (out[name] === void 0) throw new Error(`Missing required flag: --${name}`);
|
|
584
|
-
if (httpMethod === "GET") return {
|
|
585
|
-
body: {},
|
|
586
|
-
query: objectToQuery(out),
|
|
587
|
-
fromPositional: false
|
|
588
|
-
};
|
|
589
|
-
return {
|
|
590
|
-
body: out,
|
|
591
|
-
query: {},
|
|
592
|
-
fromPositional: false
|
|
593
|
-
};
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
|
-
function appendValue(out, key, value) {
|
|
597
|
-
if (out[key] === void 0) out[key] = value;
|
|
598
|
-
else if (Array.isArray(out[key])) out[key].push(value);
|
|
599
|
-
else out[key] = [out[key], value];
|
|
600
|
-
}
|
|
601
|
-
function coerce(value, type) {
|
|
602
|
-
if (type === "integer") {
|
|
603
|
-
const n = Number(value);
|
|
604
|
-
if (!Number.isInteger(n)) throw new Error(`expected integer; got ${value}`);
|
|
605
|
-
return n;
|
|
606
|
-
}
|
|
607
|
-
if (type === "number") {
|
|
608
|
-
const n = Number(value);
|
|
609
|
-
if (Number.isNaN(n)) throw new Error(`expected number; got ${value}`);
|
|
610
|
-
return n;
|
|
611
|
-
}
|
|
612
|
-
return value;
|
|
613
|
-
}
|
|
614
|
-
function objectToQuery(obj) {
|
|
615
|
-
const q = {};
|
|
616
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
617
|
-
if (v === void 0 || v === null) continue;
|
|
618
|
-
if (Array.isArray(v)) q[k] = v.map((x) => String(x));
|
|
619
|
-
else q[k] = String(v);
|
|
620
|
-
}
|
|
621
|
-
return q;
|
|
622
|
-
}
|
|
623
|
-
//#endregion
|
|
624
|
-
//#region src/commands/auth.ts
|
|
625
|
-
/**
|
|
626
|
-
* `<name> auth login | status | logout` — terminal device-code flow against
|
|
627
|
-
* `/api/cli/auth/start` and `/api/cli/auth/token` (shipped by smrt-users).
|
|
628
|
-
*
|
|
629
|
-
* @packageDocumentation
|
|
630
|
-
*/
|
|
631
|
-
async function runAuthLogin(options, args) {
|
|
632
|
-
const stdout = options.stdout ?? process.stdout;
|
|
633
|
-
const sleep = options.sleepMs ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
634
|
-
const { noOpen, serverUrl } = parseAuthLoginArgs(args, options.noOpenDefault);
|
|
635
|
-
const targetServer = (serverUrl ?? await getServerUrl(options.context)).replace(/\/+$/u, "");
|
|
636
|
-
const start = await requestJson(options.context, "/api/cli/auth/start", { method: "POST" }, {
|
|
637
|
-
auth: false,
|
|
638
|
-
serverUrl: targetServer
|
|
639
|
-
});
|
|
640
|
-
stdout.write(`Open ${start.verificationUrl}\n`);
|
|
641
|
-
stdout.write(`Code: ${start.userCode}\n`);
|
|
642
|
-
if (!noOpen) openVerificationUrl(start.verificationUrl);
|
|
643
|
-
const expiresAt = new Date(start.expiresAt).getTime();
|
|
644
|
-
let interval = start.interval ?? 2;
|
|
645
|
-
const stderr = options.stderr ?? process.stderr;
|
|
646
|
-
while (Date.now() < expiresAt) {
|
|
647
|
-
await sleep(interval * 1e3);
|
|
648
|
-
try {
|
|
649
|
-
const token = await requestJson(options.context, "/api/cli/auth/token", {
|
|
650
|
-
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
651
|
-
method: "POST"
|
|
652
|
-
}, {
|
|
653
|
-
auth: false,
|
|
654
|
-
serverUrl: targetServer
|
|
655
|
-
});
|
|
656
|
-
if (token.status === "approved" && token.accessToken) {
|
|
657
|
-
await saveAuth(options.context, targetServer, token.accessToken, start.issuer ?? targetServer);
|
|
658
|
-
stdout.write(`Authenticated to ${targetServer}\n`);
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
if (token.status === "expired") break;
|
|
662
|
-
interval = token.interval ?? interval;
|
|
663
|
-
} catch (error) {
|
|
664
|
-
if (error instanceof Error) {
|
|
665
|
-
const status = error.status;
|
|
666
|
-
if (status === 410 || error.message.includes("HTTP 410")) break;
|
|
667
|
-
if (isTransientPollError(error, status)) {
|
|
668
|
-
stderr.write(`[smrt-app-cli] auth poll: ${error.message} (retrying in ${interval}s)\n`);
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
throw error;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
throw new Error("Terminal login request expired.");
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Decide whether an error from the polling endpoint is transient (worth
|
|
679
|
-
* retrying until `expiresAt`) or terminal (re-thrown). The server's own
|
|
680
|
-
* `pending`/`expired` states are JSON responses, not thrown errors — so
|
|
681
|
-
* anything reaching the catch here is either an HTTP-level non-2xx or
|
|
682
|
-
* a fetch-level failure.
|
|
683
|
-
*
|
|
684
|
-
* Prefers the structured `.status` property attached by `requestJson`
|
|
685
|
-
* (since 5xx errors with server-supplied `error` fields don't have the
|
|
686
|
-
* status in the message string), falling back to message-pattern
|
|
687
|
-
* matching for fetch-level failures (`TypeError: fetch failed`,
|
|
688
|
-
* `ECONNRESET`, etc.).
|
|
689
|
-
*/
|
|
690
|
-
function isTransientPollError(error, status) {
|
|
691
|
-
if (status !== void 0) {
|
|
692
|
-
if (status >= 500 && status < 600) return true;
|
|
693
|
-
if (status >= 400 && status < 500) return false;
|
|
694
|
-
}
|
|
695
|
-
const msg = error.message;
|
|
696
|
-
if (/^HTTP 4(?!10)\d\d/.test(msg)) return false;
|
|
697
|
-
if (/^HTTP 5\d\d/.test(msg)) return true;
|
|
698
|
-
if (/fetch failed/i.test(msg)) return true;
|
|
699
|
-
if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/i.test(msg)) return true;
|
|
700
|
-
return false;
|
|
701
|
-
}
|
|
702
|
-
async function runAuthStatus(options) {
|
|
703
|
-
const stdout = options.stdout ?? process.stdout;
|
|
704
|
-
const config = await loadCliConfig(options.context);
|
|
705
|
-
const serverUrl = await getServerUrl(options.context, config);
|
|
706
|
-
if (!await getStoredToken(options.context, config)) {
|
|
707
|
-
stdout.write(`${JSON.stringify({
|
|
708
|
-
authenticated: false,
|
|
709
|
-
serverUrl
|
|
710
|
-
}, null, 2)}\n`);
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
try {
|
|
714
|
-
const session = await requestJson(options.context, "/api/cli/auth/session", { method: "GET" });
|
|
715
|
-
stdout.write(`${JSON.stringify({
|
|
716
|
-
...session,
|
|
717
|
-
serverUrl
|
|
718
|
-
}, null, 2)}\n`);
|
|
719
|
-
} catch (error) {
|
|
720
|
-
stdout.write(`${JSON.stringify({
|
|
721
|
-
authenticated: false,
|
|
722
|
-
serverUrl,
|
|
723
|
-
error: error instanceof Error ? error.message : String(error)
|
|
724
|
-
}, null, 2)}\n`);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
async function runAuthLogout(options) {
|
|
728
|
-
const stdout = options.stdout ?? process.stdout;
|
|
729
|
-
try {
|
|
730
|
-
await requestJson(options.context, "/api/cli/auth/session", { method: "DELETE" });
|
|
731
|
-
} catch {}
|
|
732
|
-
await clearStoredToken(options.context);
|
|
733
|
-
stdout.write(`${JSON.stringify({ authenticated: false }, null, 2)}\n`);
|
|
734
|
-
}
|
|
735
|
-
function parseAuthLoginArgs(args, noOpenDefault = false) {
|
|
736
|
-
const result = { noOpen: noOpenDefault };
|
|
737
|
-
for (let i = 0; i < args.length; i++) {
|
|
738
|
-
const arg = args[i];
|
|
739
|
-
if (arg === "--no-open") result.noOpen = true;
|
|
740
|
-
else if (arg === "--server") {
|
|
741
|
-
result.serverUrl = args[i + 1];
|
|
742
|
-
i += 1;
|
|
743
|
-
} else if (arg?.startsWith("--server=")) result.serverUrl = arg.slice(9);
|
|
744
|
-
else throw new Error(`Unknown auth login option: ${arg}`);
|
|
745
|
-
}
|
|
746
|
-
return result;
|
|
747
|
-
}
|
|
748
|
-
function commandExists(cmd) {
|
|
749
|
-
if (process.platform === "win32") return true;
|
|
750
|
-
return spawnSync("sh", ["-lc", `command -v ${cmd}`], { stdio: "ignore" }).status === 0;
|
|
751
|
-
}
|
|
752
|
-
function openVerificationUrl(url) {
|
|
753
|
-
const opener = (process.platform === "darwin" ? [{
|
|
754
|
-
args: [url],
|
|
755
|
-
command: "open"
|
|
756
|
-
}] : process.platform === "win32" ? [{
|
|
757
|
-
args: [
|
|
758
|
-
"/c",
|
|
759
|
-
"start",
|
|
760
|
-
"",
|
|
761
|
-
url
|
|
762
|
-
],
|
|
763
|
-
command: "cmd.exe"
|
|
764
|
-
}] : [{
|
|
765
|
-
args: [url],
|
|
766
|
-
command: "xdg-open"
|
|
767
|
-
}, {
|
|
768
|
-
args: ["open", url],
|
|
769
|
-
command: "gio"
|
|
770
|
-
}]).find((c) => commandExists(c.command));
|
|
771
|
-
if (!opener) return;
|
|
772
|
-
const child = spawn(opener.command, opener.args, {
|
|
773
|
-
detached: true,
|
|
774
|
-
stdio: "ignore",
|
|
775
|
-
windowsHide: true
|
|
776
|
-
});
|
|
777
|
-
child.on("error", () => void 0);
|
|
778
|
-
child.unref();
|
|
779
|
-
}
|
|
780
|
-
//#endregion
|
|
781
|
-
//#region src/commands/mcp.ts
|
|
782
|
-
async function runMcpCommand(options, args) {
|
|
783
|
-
const stdout = options.stdout ?? process.stdout;
|
|
784
|
-
const stderr = options.stderr ?? process.stderr;
|
|
785
|
-
const sub = args[0];
|
|
786
|
-
if (sub === "tools") {
|
|
787
|
-
const result = await requestJsonResult(options.context, "/api/mcp/tools", { method: "GET" }, { fetch: options.fetch });
|
|
788
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
789
|
-
if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
|
|
790
|
-
return result.ok;
|
|
791
|
-
}
|
|
792
|
-
if (sub === "call") {
|
|
793
|
-
const name = args[1];
|
|
794
|
-
const payload = args[2] ?? "{}";
|
|
795
|
-
if (!name) throw new Error("Usage: mcp call <tool> [<json>]");
|
|
796
|
-
let parsed;
|
|
797
|
-
try {
|
|
798
|
-
parsed = JSON.parse(payload);
|
|
799
|
-
} catch (error) {
|
|
800
|
-
throw new Error(`Could not parse mcp call payload: ${error.message}`);
|
|
801
|
-
}
|
|
802
|
-
const result = await requestJsonResult(options.context, "/api/mcp/call", {
|
|
803
|
-
body: JSON.stringify({
|
|
804
|
-
arguments: parsed,
|
|
805
|
-
name
|
|
806
|
-
}),
|
|
807
|
-
method: "POST"
|
|
808
|
-
}, { fetch: options.fetch });
|
|
809
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
810
|
-
if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
|
|
811
|
-
return result.ok;
|
|
812
|
-
}
|
|
813
|
-
throw new Error("Usage: mcp tools | mcp call <tool> [<json>]");
|
|
814
|
-
}
|
|
815
|
-
//#endregion
|
|
816
|
-
//#region src/commands/resources.ts
|
|
817
|
-
async function runResourcesCommand(options, args) {
|
|
818
|
-
const stdout = options.stdout ?? process.stdout;
|
|
819
|
-
const stderr = options.stderr ?? process.stderr;
|
|
820
|
-
const json = args.includes("--json");
|
|
821
|
-
const debug = args.includes("--debug");
|
|
822
|
-
const response = options.injectResponse ?? await fetchResourceList(options.context, { fetch: options.fetch });
|
|
823
|
-
if (json) {
|
|
824
|
-
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
if (response.resources.length === 0) {
|
|
828
|
-
if (!response.user.authenticated) stdout.write("(no resources — not authenticated)\n");
|
|
829
|
-
else stdout.write("(no resources discovered)\n");
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
for (const resource of response.resources) {
|
|
833
|
-
stdout.write(`${resource.slug} (${resource.className})\n`);
|
|
834
|
-
for (const command of resource.commands) stdout.write(` - ${formatCommandLine(resource, command)}\n`);
|
|
835
|
-
}
|
|
836
|
-
if (response.warnings.length > 0) if (debug) {
|
|
837
|
-
stderr.write(`\nwarnings:\n`);
|
|
838
|
-
for (const w of response.warnings) stderr.write(` - ${w}\n`);
|
|
839
|
-
} else stderr.write(`\n${response.warnings.length} command${response.warnings.length === 1 ? "" : "s"} unavailable (use --debug for details)\n`);
|
|
840
|
-
}
|
|
841
|
-
function formatCommandLine(_resource, command) {
|
|
842
|
-
const idHint = command.scope === "item" ? " <id>" : "";
|
|
843
|
-
const description = command.description ? ` — ${command.description}` : "";
|
|
844
|
-
return `${command.commandName}${idHint}${description}`;
|
|
845
|
-
}
|
|
846
|
-
//#endregion
|
|
847
|
-
//#region src/index.ts
|
|
848
|
-
/**
|
|
849
|
-
* `@happyvertical/smrt-app-cli` — reusable CLI factory for SMRT apps.
|
|
850
|
-
*
|
|
851
|
-
* @example
|
|
852
|
-
* ```ts
|
|
853
|
-
* #!/usr/bin/env node
|
|
854
|
-
* import { createAppCli } from '@happyvertical/smrt-app-cli';
|
|
855
|
-
*
|
|
856
|
-
* const cli = createAppCli({
|
|
857
|
-
* name: 'willgriffin',
|
|
858
|
-
* defaultServerUrl: 'https://willgriffin.dev',
|
|
859
|
-
* });
|
|
860
|
-
* await cli.run(process.argv.slice(2));
|
|
861
|
-
* ```
|
|
862
|
-
*
|
|
863
|
-
* @packageDocumentation
|
|
864
|
-
*/
|
|
865
|
-
function createAppCli(options) {
|
|
866
|
-
const context = {
|
|
867
|
-
envPrefix: options.envPrefix ?? options.name.toUpperCase(),
|
|
868
|
-
appSlug: options.configDir ?? options.name.toLowerCase(),
|
|
869
|
-
defaultServerUrl: options.defaultServerUrl
|
|
870
|
-
};
|
|
871
|
-
const extraByName = /* @__PURE__ */ new Map();
|
|
872
|
-
for (const cmd of options.extraCommands ?? []) extraByName.set(cmd.name, cmd);
|
|
873
|
-
return {
|
|
874
|
-
run: (argv) => runCli(context, options, extraByName, argv),
|
|
875
|
-
startMcpBridge: async (serverInfo) => {
|
|
876
|
-
const { runMcpStdioBridge } = await import("./bridge-DqndDLee.js");
|
|
877
|
-
await runMcpStdioBridge({
|
|
878
|
-
...context,
|
|
879
|
-
serverInfo: {
|
|
880
|
-
name: serverInfo?.name ?? `${options.name}-mcp`,
|
|
881
|
-
version: serverInfo?.version ?? "0.0.0"
|
|
882
|
-
}
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
var BUILT_IN_COMMANDS = /* @__PURE__ */ new Set([
|
|
888
|
-
"auth",
|
|
889
|
-
"resources",
|
|
890
|
-
"mcp"
|
|
891
|
-
]);
|
|
892
|
-
async function runCli(context, options, extras, argv) {
|
|
893
|
-
const stdout = process.stdout;
|
|
894
|
-
const stderr = process.stderr;
|
|
895
|
-
try {
|
|
896
|
-
await dispatchCli(context, options, extras, argv, stdout, stderr);
|
|
897
|
-
} catch (error) {
|
|
898
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
899
|
-
stderr.write(`${message}\n`);
|
|
900
|
-
process.exitCode = 1;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
async function dispatchCli(context, options, extras, argv, stdout, stderr) {
|
|
904
|
-
if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help") {
|
|
905
|
-
printUsage(options, extras, stdout);
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
const [command, ...rest] = argv;
|
|
909
|
-
const extra = extras.get(command);
|
|
910
|
-
if (extra) {
|
|
911
|
-
if (BUILT_IN_COMMANDS.has(command)) stderr.write(`[smrt-app-cli] extra command \`${command}\` shadows a built-in.\n`);
|
|
912
|
-
const ctx = await buildAppContext(context, extra.needsResources ?? false);
|
|
913
|
-
await extra.run(rest, ctx);
|
|
914
|
-
return;
|
|
915
|
-
}
|
|
916
|
-
if (command === "auth") {
|
|
917
|
-
const sub = rest[0];
|
|
918
|
-
const opts = {
|
|
919
|
-
context,
|
|
920
|
-
stdout,
|
|
921
|
-
stderr
|
|
922
|
-
};
|
|
923
|
-
if (sub === "login") return runAuthLogin(opts, rest.slice(1));
|
|
924
|
-
if (sub === "status") return runAuthStatus(opts);
|
|
925
|
-
if (sub === "logout") return runAuthLogout(opts);
|
|
926
|
-
throw new Error("Usage: auth login [--server <url>] [--no-open] | status | logout");
|
|
927
|
-
}
|
|
928
|
-
if (command === "mcp") {
|
|
929
|
-
if (!await runMcpCommand({
|
|
930
|
-
context,
|
|
931
|
-
stdout,
|
|
932
|
-
stderr
|
|
933
|
-
}, rest)) process.exitCode = 1;
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
if (command === "resources") return runResourcesCommand({
|
|
937
|
-
context,
|
|
938
|
-
stdout,
|
|
939
|
-
stderr
|
|
940
|
-
}, rest);
|
|
941
|
-
await runResourceCommand(context, command, rest, stdout, stderr);
|
|
942
|
-
}
|
|
943
|
-
async function runResourceCommand(context, slug, rest, stdout, stderr) {
|
|
944
|
-
const response = await fetchResourceList(context);
|
|
945
|
-
const resource = findResourceBySlug(response, slug);
|
|
946
|
-
if (!resource) {
|
|
947
|
-
const suggestions = response.resources.map((r) => r.slug).filter((s) => similar(s, slug)).slice(0, 3);
|
|
948
|
-
const hint = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
|
|
949
|
-
throw new Error(`Unknown resource: ${slug}.${hint}`);
|
|
950
|
-
}
|
|
951
|
-
const commandName = rest[0];
|
|
952
|
-
if (!commandName) throw new Error(`Usage: ${slug} <command> [id] [...]. Available: ${resource.commands.map((c) => c.commandName).join(", ")}`);
|
|
953
|
-
const command = findCommand(resource, commandName);
|
|
954
|
-
if (!command) throw new Error(`Unknown command \`${commandName}\` on resource \`${slug}\`. Available: ${resource.commands.map((c) => c.commandName).join(", ")}`);
|
|
955
|
-
let positional = rest.slice(1);
|
|
956
|
-
let id;
|
|
957
|
-
if (command.scope === "item") {
|
|
958
|
-
id = positional[0];
|
|
959
|
-
if (!id) throw new Error(`Command \`${slug} ${commandName}\` requires an id positional argument.`);
|
|
960
|
-
if (findCommand(resource, id)) throw new Error(`\`${id}\` is a command on \`${slug}\`, not an id. Did you mean: \`${slug} ${id}${findCommand(resource, id)?.scope === "item" ? " <id>" : ""}\`? \`${slug} ${commandName}\` is an item-scope command and needs an id as the next argument.`);
|
|
961
|
-
positional = positional.slice(1);
|
|
962
|
-
}
|
|
963
|
-
const parser = buildFlagParser(command.parameters);
|
|
964
|
-
if (parser.status.kind === "unsupported") stderr.write(`[smrt-app-cli] complex schema for \`${slug} ${commandName}\`; pass JSON payload directly\n`);
|
|
965
|
-
else if (parser.status.kind === "missing") stderr.write(`[smrt-app-cli] schema unavailable for \`${slug} ${commandName}\`; pass JSON payload directly: $ <cli> ${slug} ${commandName}${id ? ` ${id}` : ""} '<json>'\n`);
|
|
966
|
-
const { exitCode } = await renderResponse(await invokeCommand({
|
|
967
|
-
context,
|
|
968
|
-
resource,
|
|
969
|
-
command,
|
|
970
|
-
parsed: parser.parse(positional, command.httpMethod),
|
|
971
|
-
id
|
|
972
|
-
}), {
|
|
973
|
-
stdout,
|
|
974
|
-
stderr
|
|
975
|
-
});
|
|
976
|
-
if (exitCode !== 0) process.exitCode = exitCode;
|
|
977
|
-
}
|
|
978
|
-
async function buildAppContext(context, eagerResources) {
|
|
979
|
-
const config = await loadCliConfig(context);
|
|
980
|
-
const serverUrl = await getServerUrl(context, config);
|
|
981
|
-
const token = await getStoredToken(context, config);
|
|
982
|
-
let resourcesPromise = null;
|
|
983
|
-
const getResources = () => {
|
|
984
|
-
if (!resourcesPromise) resourcesPromise = fetchResourceList(context, { loadedConfig: config });
|
|
985
|
-
return resourcesPromise;
|
|
986
|
-
};
|
|
987
|
-
if (eagerResources) getResources();
|
|
988
|
-
return {
|
|
989
|
-
config,
|
|
990
|
-
serverUrl,
|
|
991
|
-
token,
|
|
992
|
-
getResources,
|
|
993
|
-
requestJson: (path, init, opts) => requestJson(context, path, init, {
|
|
994
|
-
loadedConfig: config,
|
|
995
|
-
...opts
|
|
996
|
-
}),
|
|
997
|
-
request: async (path, init) => {
|
|
998
|
-
const headers = new Headers(init?.headers);
|
|
999
|
-
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
1000
|
-
return fetch(`${serverUrl}${path}`, {
|
|
1001
|
-
...init,
|
|
1002
|
-
headers
|
|
1003
|
-
});
|
|
1004
|
-
},
|
|
1005
|
-
stdout: process.stdout,
|
|
1006
|
-
stderr: process.stderr
|
|
1007
|
-
};
|
|
1008
|
-
}
|
|
1009
|
-
function printUsage(options, extras, out) {
|
|
1010
|
-
const name = options.name;
|
|
1011
|
-
out.write(`Usage:\n`);
|
|
1012
|
-
out.write(` ${name} auth login [--server <url>] [--no-open]\n`);
|
|
1013
|
-
out.write(` ${name} auth status\n`);
|
|
1014
|
-
out.write(` ${name} auth logout\n`);
|
|
1015
|
-
out.write(` ${name} resources [--json] [--debug]\n`);
|
|
1016
|
-
out.write(` ${name} <resource> <command> [id] [--flags...] [json-payload]\n`);
|
|
1017
|
-
out.write(` ${name} mcp tools\n`);
|
|
1018
|
-
out.write(` ${name} mcp call <tool> [<json>]\n`);
|
|
1019
|
-
for (const cmd of extras.values()) out.write(` ${name} ${cmd.name} — ${cmd.description}\n`);
|
|
1020
|
-
}
|
|
1021
|
-
/** Naive levenshtein-style similarity. */
|
|
1022
|
-
function similar(a, b) {
|
|
1023
|
-
if (a === b) return true;
|
|
1024
|
-
if (Math.abs(a.length - b.length) > 2) return false;
|
|
1025
|
-
let diff = 0;
|
|
1026
|
-
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
1027
|
-
if (a[i] !== b[i]) diff += 1;
|
|
1028
|
-
if (diff > 2) return false;
|
|
1029
|
-
}
|
|
1030
|
-
return true;
|
|
1031
|
-
}
|
|
1032
|
-
//#endregion
|
|
1033
|
-
export { AppCliRequestError, SMRT_MCP_RESULT_METADATA_KEY, buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, redactTransportValue, registerMcpClient, renderResponse, requestJson, requestJsonResult, resolveMcpClientRegistration, runMcpStdioBridge, saveAuth, saveCliConfig };
|
|
1
|
+
import { a as getStoredToken, c as requestJson, d as saveCliConfig, f as createMcpStdioBridge, i as getServerUrl, l as requestJsonResult, m as runMcpStdioBridge, n as assertSecureServerUrl, o as loadCliConfig, r as clearStoredToken, s as redactTransportValue, t as AppCliRequestError, u as saveAuth } from "./config-CuTFiGxX.js";
|
|
2
|
+
import { a as buildFlagParser, c as createMcpClientIdMetadataDocument, d as buildUrl, f as invokeCommand, h as findResourceBySlug, i as runAppCliExecutable, l as registerMcpClient, m as findCommand, n as createAppCli, o as classifySchema, p as fetchResourceList, r as parseAppCliExecutableConfig, s as renderResponse, t as SMRT_MCP_RESULT_METADATA_KEY, u as resolveMcpClientRegistration } from "./src-DCo67mp8.js";
|
|
3
|
+
export { AppCliRequestError, SMRT_MCP_RESULT_METADATA_KEY, assertSecureServerUrl, buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, parseAppCliExecutableConfig, redactTransportValue, registerMcpClient, renderResponse, requestJson, requestJsonResult, resolveMcpClientRegistration, runAppCliExecutable, runMcpStdioBridge, saveAuth, saveCliConfig };
|