@widgetic/cli 0.0.0-stage → 0.1.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 +122 -0
- package/README.md +184 -2
- package/bin/widgetic.js +5 -0
- package/dist/cli.js +106 -0
- package/dist/commands/auth.js +269 -0
- package/dist/commands/call.js +97 -0
- package/dist/commands/completion.js +148 -0
- package/dist/commands/doctor.js +291 -0
- package/dist/commands/schema.js +214 -0
- package/dist/commands/update.js +159 -0
- package/dist/commands/verify.js +64 -0
- package/dist/config.js +98 -0
- package/dist/confirm.js +15 -0
- package/dist/constants.js +51 -0
- package/dist/contract-note.js +42 -0
- package/dist/credentials.js +111 -0
- package/dist/environment.js +150 -0
- package/dist/errors.js +67 -0
- package/dist/http.js +122 -0
- package/dist/input.js +37 -0
- package/dist/main.js +30 -0
- package/dist/manifest/load.js +39 -0
- package/dist/manifest/types.js +14 -0
- package/dist/mcp/plan.js +32 -0
- package/dist/mcp/register.js +112 -0
- package/dist/mcp/session.js +185 -0
- package/dist/mcp/wait.js +68 -0
- package/dist/options.js +228 -0
- package/dist/output.js +90 -0
- package/dist/overlay.js +31 -0
- package/dist/patterns.js +11 -0
- package/dist/rest/execute.js +67 -0
- package/dist/rest/mime.js +37 -0
- package/dist/rest/plan.js +185 -0
- package/dist/rest/register.js +139 -0
- package/dist/retry.js +49 -0
- package/dist/secrets.js +149 -0
- package/dist/state.js +37 -0
- package/dist/update/installation.js +83 -0
- package/dist/update/lookup.js +128 -0
- package/dist/update/offer.js +165 -0
- package/dist/update/perform.js +33 -0
- package/dist/update/registry.js +68 -0
- package/dist/update/version.js +39 -0
- package/dist/validate.js +93 -0
- package/dist/version.js +59 -0
- package/generated/contracts.json +1 -0
- package/generated/mcp-manifest.json +1 -0
- package/generated/rest-manifest.json +1 -0
- package/package.json +56 -5
package/dist/http.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { CliError, EXIT_CODES, exitCodeForStatus } from "./errors.js";
|
|
2
|
+
export function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
/** Reads a whole answer. */
|
|
6
|
+
export async function readAnswer(response) {
|
|
7
|
+
const text = await response.text();
|
|
8
|
+
const type = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
9
|
+
let body;
|
|
10
|
+
if (text !== '') {
|
|
11
|
+
if (type.includes('json')) {
|
|
12
|
+
try {
|
|
13
|
+
body = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
body = text;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
body = text;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { status: response.status, headers: response.headers, body };
|
|
24
|
+
}
|
|
25
|
+
/** `Retry-After` is a number of seconds or a date; either way, seconds from now (never negative). */
|
|
26
|
+
export function parseRetryAfter(value, now) {
|
|
27
|
+
if (value === null || value.trim() === '')
|
|
28
|
+
return undefined;
|
|
29
|
+
if (/^\d+$/.test(value.trim()))
|
|
30
|
+
return Number(value.trim());
|
|
31
|
+
const date = Date.parse(value);
|
|
32
|
+
if (Number.isNaN(date))
|
|
33
|
+
return undefined;
|
|
34
|
+
return Math.max(0, Math.ceil((date - now.getTime()) / 1000));
|
|
35
|
+
}
|
|
36
|
+
function upgradeHint(details) {
|
|
37
|
+
const command = isRecord(details) ? details.upgradeCommand : undefined;
|
|
38
|
+
// The gateway names the package of the client it recognised; for this CLI that is `@widgetic/cli`.
|
|
39
|
+
return typeof command === 'string' ? `Update the CLI: ${command}` : undefined;
|
|
40
|
+
}
|
|
41
|
+
const NO_KEY_HINT = 'Run `widgetic auth login` with an API key, or set WIDGETIC_API_KEY.';
|
|
42
|
+
/** The error an answer that was not a success stands for. */
|
|
43
|
+
export function errorFromAnswer(answer, now, keySource) {
|
|
44
|
+
const { status, headers, body } = answer;
|
|
45
|
+
const envelope = isRecord(body) && isRecord(body.error) ? body.error : undefined;
|
|
46
|
+
const text = typeof body === 'string' ? body.trim() : '';
|
|
47
|
+
// The MCP endpoint answers an OAuth-style body to a key it does not accept: `{"error":"invalid_token","error_description":"…"}`.
|
|
48
|
+
const oauthCode = isRecord(body) && typeof body.error === 'string' ? body.error : undefined;
|
|
49
|
+
const oauthMessage = isRecord(body) && typeof body.error_description === 'string' ? body.error_description : undefined;
|
|
50
|
+
const code = typeof envelope?.code === 'string' ? envelope.code : (oauthCode ?? `HTTP_${status}`);
|
|
51
|
+
const message = (typeof envelope?.message === 'string' && envelope.message) ||
|
|
52
|
+
(isRecord(body) && typeof body.message === 'string' && body.message) ||
|
|
53
|
+
oauthMessage ||
|
|
54
|
+
(text !== '' && text.slice(0, 300)) ||
|
|
55
|
+
`The server answered ${status}.`;
|
|
56
|
+
const requestId = (typeof envelope?.correlationId === 'string' && envelope.correlationId) ||
|
|
57
|
+
headers.get('x-correlation-id') ||
|
|
58
|
+
undefined;
|
|
59
|
+
const retryAfter = (typeof envelope?.retryAfter === 'number' ? envelope.retryAfter : undefined) ??
|
|
60
|
+
parseRetryAfter(headers.get('retry-after'), now);
|
|
61
|
+
let details = envelope?.details;
|
|
62
|
+
if (typeof envelope?.param === 'string') {
|
|
63
|
+
const rest = isRecord(details) ? details : details === undefined ? {} : { detail: details };
|
|
64
|
+
details = { param: envelope.param, ...rest };
|
|
65
|
+
}
|
|
66
|
+
const suggested = typeof envelope?.suggestedAction === 'string' ? envelope.suggestedAction : undefined;
|
|
67
|
+
let hint = suggested;
|
|
68
|
+
if (status === 401) {
|
|
69
|
+
hint =
|
|
70
|
+
keySource === 'environment'
|
|
71
|
+
? 'The key in WIDGETIC_API_KEY was not accepted: check it, or unset it to use the saved one.'
|
|
72
|
+
: NO_KEY_HINT;
|
|
73
|
+
}
|
|
74
|
+
else if (status === 426) {
|
|
75
|
+
hint = upgradeHint(details) ?? suggested;
|
|
76
|
+
}
|
|
77
|
+
else if (status === 429 && retryAfter !== undefined) {
|
|
78
|
+
hint = `Rate limited: try again in ${retryAfter}s.`;
|
|
79
|
+
}
|
|
80
|
+
return new CliError({
|
|
81
|
+
code,
|
|
82
|
+
message,
|
|
83
|
+
exitCode: exitCodeForStatus(status),
|
|
84
|
+
status,
|
|
85
|
+
requestId,
|
|
86
|
+
retryAfter,
|
|
87
|
+
details,
|
|
88
|
+
hint
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function hostOf(url) {
|
|
92
|
+
try {
|
|
93
|
+
return new URL(url).host;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return url;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** What went wrong before there was an answer: a wait that ran out, a host that cannot be reached. */
|
|
100
|
+
export function networkFailure(error, url, timeoutMilliseconds) {
|
|
101
|
+
const name = error instanceof Error ? error.name : '';
|
|
102
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
103
|
+
return new CliError({
|
|
104
|
+
code: 'TIMEOUT',
|
|
105
|
+
message: `No answer from ${hostOf(url)} within ${Math.round(timeoutMilliseconds / 1000)}s.`,
|
|
106
|
+
exitCode: EXIT_CODES.unavailable,
|
|
107
|
+
hint: 'Try again, or wait longer with --timeout <seconds>.',
|
|
108
|
+
cause: error
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
112
|
+
const reason = (typeof cause?.code === 'string' && cause.code) ||
|
|
113
|
+
(typeof cause?.message === 'string' && cause.message) ||
|
|
114
|
+
(error instanceof Error ? error.message : String(error));
|
|
115
|
+
return new CliError({
|
|
116
|
+
code: 'NETWORK_ERROR',
|
|
117
|
+
message: `Cannot reach ${hostOf(url)}: ${reason}`,
|
|
118
|
+
exitCode: EXIT_CODES.unavailable,
|
|
119
|
+
hint: 'Check the connection (and WIDGETIC_API_URL or WIDGETIC_MCP_URL if one is set), then try again.',
|
|
120
|
+
cause: error
|
|
121
|
+
});
|
|
122
|
+
}
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { readAll } from "./environment.js";
|
|
3
|
+
import { usageError } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* A document given on the command line (`--data`, `--input`): the JSON itself, `@path` for a file that holds it, or
|
|
6
|
+
* `-` for what is on standard input. It has to be a JSON object: every body and every tool input of the contracts is one.
|
|
7
|
+
*/
|
|
8
|
+
export async function readDocument(environment, source, flag) {
|
|
9
|
+
let text;
|
|
10
|
+
if (source === '-') {
|
|
11
|
+
text = await readAll(environment.stdin);
|
|
12
|
+
}
|
|
13
|
+
else if (source.startsWith('@')) {
|
|
14
|
+
const path = resolve(environment.cwd, source.slice(1));
|
|
15
|
+
try {
|
|
16
|
+
text = new TextDecoder().decode(await environment.fs.readFile(path));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw usageError(`--${flag}: the file ${path} cannot be read.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
text = source;
|
|
24
|
+
}
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(text);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
31
|
+
throw usageError(`--${flag} is not valid JSON: ${reason}`, 'Give the document itself, @path/to/file.json, or - to read it from standard input.');
|
|
32
|
+
}
|
|
33
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
34
|
+
throw usageError(`--${flag} must be a JSON object ({ ... }).`);
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { run } from "./cli.js";
|
|
2
|
+
import { realEnvironment } from "./environment.js";
|
|
3
|
+
import { offerUpdate, startUpdateCheck } from "./update/offer.js";
|
|
4
|
+
/**
|
|
5
|
+
* One run of the program as the process does it: the command, and after it, when a newer version exists, the question
|
|
6
|
+
* whether to update (`src/update/offer.ts`). The look for that version starts first and goes on beside the command, so
|
|
7
|
+
* the command is not slower for it; the question comes last, so it cannot change what the command printed or the exit
|
|
8
|
+
* code it returns. `run` stays the command alone: it is what the tests of every command call.
|
|
9
|
+
*/
|
|
10
|
+
export async function runProcess(argv, environment) {
|
|
11
|
+
const pendingUpdateCheck = startUpdateCheck(environment, argv);
|
|
12
|
+
const exitCode = await run(argv, environment);
|
|
13
|
+
await offerUpdate(environment, pendingUpdateCheck, exitCode);
|
|
14
|
+
return exitCode;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The process entry (called by bin/widgetic.js): runs the program against the real environment and sets the exit code.
|
|
18
|
+
* `process.exitCode` is set rather than `process.exit()` called, so what was written to a pipe is flushed first.
|
|
19
|
+
*/
|
|
20
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
21
|
+
// `widgetic ... | head` closes the pipe early: that is not an error of ours.
|
|
22
|
+
process.stdout.on('error', (error) => {
|
|
23
|
+
if (error.code === 'EPIPE')
|
|
24
|
+
process.exit(0);
|
|
25
|
+
throw error;
|
|
26
|
+
});
|
|
27
|
+
// Ctrl-C ends the run at once, the way a shell expects (128 + SIGINT).
|
|
28
|
+
process.once('SIGINT', () => process.exit(130));
|
|
29
|
+
process.exitCode = await runProcess(argv, realEnvironment());
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { MANIFEST_FORMAT } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads the manifests `npm run generate` wrote into `generated/` (one folder above `src/` and `dist/` alike), once
|
|
5
|
+
* each and only when a command needs them: `widgetic auth status` never reads a 500 KB description of the API.
|
|
6
|
+
*/
|
|
7
|
+
function readGenerated(file) {
|
|
8
|
+
const url = new URL(`../../generated/${file}`, import.meta.url);
|
|
9
|
+
let text;
|
|
10
|
+
try {
|
|
11
|
+
text = readFileSync(url, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error(`generated/${file} is missing: this build of @widgetic/cli is incomplete. Run \`npm run generate\` (or reinstall the package).`);
|
|
15
|
+
}
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
function checkedFormat(manifest, file) {
|
|
19
|
+
if (manifest.format !== MANIFEST_FORMAT) {
|
|
20
|
+
throw new Error(`generated/${file} has format ${manifest.format}, and this program reads format ${MANIFEST_FORMAT}: run \`npm run generate\`.`);
|
|
21
|
+
}
|
|
22
|
+
return manifest;
|
|
23
|
+
}
|
|
24
|
+
let rest;
|
|
25
|
+
let mcp;
|
|
26
|
+
let contracts;
|
|
27
|
+
export function loadRestManifest() {
|
|
28
|
+
rest ??= checkedFormat(readGenerated('rest-manifest.json'), 'rest-manifest.json');
|
|
29
|
+
return rest;
|
|
30
|
+
}
|
|
31
|
+
export function loadMcpManifest() {
|
|
32
|
+
mcp ??= checkedFormat(readGenerated('mcp-manifest.json'), 'mcp-manifest.json');
|
|
33
|
+
return mcp;
|
|
34
|
+
}
|
|
35
|
+
/** What the contracts the program was generated from say about themselves (`widgetic doctor` prints it). */
|
|
36
|
+
export function loadContractsInfo() {
|
|
37
|
+
contracts ??= readGenerated('contracts.json');
|
|
38
|
+
return contracts;
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes of the two command manifests.
|
|
3
|
+
*
|
|
4
|
+
* `scripts/generate.ts` writes them into `generated/` from the installed `@widgetic/api-contracts` (the public OpenAPI
|
|
5
|
+
* description and the MCP tool contract the gateway publishes), and the program reads them at run time to build its
|
|
6
|
+
* commands. Nothing in a manifest is typed by hand: a command exists because the contract has an operation or a tool,
|
|
7
|
+
* and a flag exists because the contract has a parameter or a property. The few rules that are ours (the names, the
|
|
8
|
+
* aliases, which command asks before it runs) are in `scripts/lib/` and pinned by tests.
|
|
9
|
+
*
|
|
10
|
+
* This file is imported by the generator and by the program, so a manifest cannot be written in one shape and read in
|
|
11
|
+
* another.
|
|
12
|
+
*/
|
|
13
|
+
/** The version of the manifest format. The program refuses a manifest of another version. */
|
|
14
|
+
export const MANIFEST_FORMAT = 1;
|
package/dist/mcp/plan.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readDocument } from "../input.js";
|
|
2
|
+
import { redactSecrets } from "../secrets.js";
|
|
3
|
+
import { namedByFlag, requireValid } from "../validate.js";
|
|
4
|
+
import { cliIdentity } from "../version.js";
|
|
5
|
+
export async function planMcpCall(plan) {
|
|
6
|
+
const { environment, command, values, input } = plan;
|
|
7
|
+
const args = input === undefined ? {} : await readDocument(environment, input, 'input');
|
|
8
|
+
for (const flag of command.flags) {
|
|
9
|
+
if (values.has(flag.flag))
|
|
10
|
+
args[flag.property] = values.get(flag.flag);
|
|
11
|
+
}
|
|
12
|
+
// What the CLI does when it is not told: a build is a draft (`--publish` makes it public). Only where the person gave nothing, in a flag or in the document.
|
|
13
|
+
for (const flag of command.flags) {
|
|
14
|
+
if (flag.cliDefault !== undefined && !(flag.property in args))
|
|
15
|
+
args[flag.property] = flag.cliDefault;
|
|
16
|
+
}
|
|
17
|
+
const flagOf = new Map(command.flags.map((flag) => [flag.property, flag.flag]));
|
|
18
|
+
await requireValid(command.inputSchema, args, `The input of ${command.tool}`, (path) => namedByFlag(path, flagOf));
|
|
19
|
+
return { command, arguments: args };
|
|
20
|
+
}
|
|
21
|
+
/** What `--dry-run` prints: the call exactly as it would be made, with the key left out. */
|
|
22
|
+
export function describeMcpPlan(plan, settings) {
|
|
23
|
+
return {
|
|
24
|
+
dryRun: true,
|
|
25
|
+
method: 'tools/call',
|
|
26
|
+
url: settings.mcpUrl,
|
|
27
|
+
headers: { authorization: 'Bearer [redacted]', 'x-sdk-version': cliIdentity() },
|
|
28
|
+
tool: plan.command.tool,
|
|
29
|
+
// No tool takes a secret today; one that does keeps it out of this print, as a REST document does.
|
|
30
|
+
arguments: redactSecrets(plan.arguments, plan.command.inputSchema)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Option } from 'commander';
|
|
2
|
+
import { resolveSettings, timeoutMilliseconds } from "../config.js";
|
|
3
|
+
import { confirmOrStop } from "../confirm.js";
|
|
4
|
+
import { noteContractDifference } from "../contract-note.js";
|
|
5
|
+
import { requireApiKey } from "../credentials.js";
|
|
6
|
+
import { addCommonOptions, addFlag, commandHelp, readCommon, readFlags } from "../options.js";
|
|
7
|
+
import { printResult } from "../output.js";
|
|
8
|
+
import { isWaitTool, WAIT_OVERLAY } from "../overlay.js";
|
|
9
|
+
import { retryNotifier, withRetries } from "../retry.js";
|
|
10
|
+
import { describeMcpPlan, planMcpCall } from "./plan.js";
|
|
11
|
+
import { McpConnection, refusalOf } from "./session.js";
|
|
12
|
+
import { waitForBuild } from "./wait.js";
|
|
13
|
+
/**
|
|
14
|
+
* The `mcp` namespace: one command for every tool of the Widgetic MCP server, built from the tool contract the
|
|
15
|
+
* gateway publishes. A command is the tool name in kebab case; an argument that is a string, a number, a choice, a
|
|
16
|
+
* boolean or a list of strings is a flag, and `--input` takes the whole document (and the arguments that are objects).
|
|
17
|
+
*/
|
|
18
|
+
const SCOPE_HEADINGS = {
|
|
19
|
+
read: 'Read (look at what exists):',
|
|
20
|
+
write: 'Write (build and edit):',
|
|
21
|
+
publish: 'Publish (make something public):',
|
|
22
|
+
delete: 'Delete:'
|
|
23
|
+
};
|
|
24
|
+
/** What the question before a tool that asks says it does: a tool the CLI does not know (called with `mcp call`) could be anything. */
|
|
25
|
+
function whatItDoes(command) {
|
|
26
|
+
if (command.destructive)
|
|
27
|
+
return 'deletes or overwrites something';
|
|
28
|
+
if (command.scopes.length === 0)
|
|
29
|
+
return 'is not a tool this CLI knows, so it cannot tell what it changes';
|
|
30
|
+
return 'makes something public or removes it';
|
|
31
|
+
}
|
|
32
|
+
/** What a tool's answer is, printed: the data of the tool; without data, its text. */
|
|
33
|
+
function printOutcome(environment, outcome, json) {
|
|
34
|
+
if (outcome.structured !== undefined) {
|
|
35
|
+
printResult(environment, outcome.structured, json);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// A tool that answers in words only: a program still gets JSON.
|
|
39
|
+
printResult(environment, json ? { text: outcome.text } : outcome.text, json);
|
|
40
|
+
}
|
|
41
|
+
/** Runs one tool command: plan and check, then (unless `--dry-run`) confirm, connect, call, optionally wait, print. */
|
|
42
|
+
export async function runMcpCommand(run, command, node, bindings) {
|
|
43
|
+
const { environment, manifest } = run;
|
|
44
|
+
const common = readCommon(node);
|
|
45
|
+
const values = readFlags(node, bindings);
|
|
46
|
+
const options = node.opts();
|
|
47
|
+
const input = typeof options.input === 'string' ? options.input : undefined;
|
|
48
|
+
const waits = options.wait === true;
|
|
49
|
+
const settings = resolveSettings(environment, { profile: common.profile });
|
|
50
|
+
const plan = await planMcpCall({ environment, command, values, input });
|
|
51
|
+
if (common.dryRun) {
|
|
52
|
+
printResult(environment, describeMcpPlan(plan, settings), common.json);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (command.confirm) {
|
|
56
|
+
await confirmOrStop(environment, {
|
|
57
|
+
what: `${command.tool} ${whatItDoes(command)}`,
|
|
58
|
+
yes: common.yes,
|
|
59
|
+
json: common.json
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const key = await requireApiKey(environment, settings);
|
|
63
|
+
const connection = new McpConnection(environment, settings, { apiKey: key.apiKey, source: key.source }, timeoutMilliseconds(common.timeout));
|
|
64
|
+
try {
|
|
65
|
+
// A call that cannot do anything twice is repeated after a lost answer; one that starts a build or edits is not.
|
|
66
|
+
const safeToRepeat = command.readOnly || command.idempotent;
|
|
67
|
+
let outcome = await withRetries(environment, { safeToRepeat }, async () => {
|
|
68
|
+
const answered = await connection.call(command.tool, plan.arguments);
|
|
69
|
+
if (answered.isError)
|
|
70
|
+
throw refusalOf(answered);
|
|
71
|
+
return answered;
|
|
72
|
+
}, retryNotifier(environment, common.json));
|
|
73
|
+
await noteContractDifference(environment, settings, {
|
|
74
|
+
server: connection.serverVersion,
|
|
75
|
+
expected: manifest.contractVersion,
|
|
76
|
+
json: common.json
|
|
77
|
+
});
|
|
78
|
+
if (waits)
|
|
79
|
+
outcome = await waitForBuild(environment, connection, outcome, common.json);
|
|
80
|
+
printOutcome(environment, outcome, common.json);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await connection.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Adds the command of one tool under `parent` and returns it. */
|
|
87
|
+
export function registerMcpCommand(parent, command, run) {
|
|
88
|
+
const node = parent
|
|
89
|
+
.command(command.name)
|
|
90
|
+
.summary(command.title)
|
|
91
|
+
.description(commandHelp(command.title, command.description, `widgetic schema mcp ${command.name}`));
|
|
92
|
+
node.helpGroup(SCOPE_HEADINGS[command.scopeGroup]);
|
|
93
|
+
const bindings = command.flags.map((flag) => addFlag(node, flag, { mandatory: false, group: 'Arguments of the tool:' }));
|
|
94
|
+
addCommonOptions(node, { asks: command.confirm, document: 'input' });
|
|
95
|
+
if (isWaitTool(command.tool)) {
|
|
96
|
+
node.addOption(new Option('--wait', `Keep asking ${WAIT_OVERLAY.pollTool} until the build is finished, and print the finished widget (up to ${Math.round(WAIT_OVERLAY.defaultMaxSeconds / 60)} minutes).`).helpGroup('Common options:'));
|
|
97
|
+
}
|
|
98
|
+
node.action(async () => {
|
|
99
|
+
await runMcpCommand(run, command, node, bindings);
|
|
100
|
+
});
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
/** Adds one command for every tool of the manifest under `mcp`, listed by what they do to your account: read first, delete last. */
|
|
104
|
+
export function registerMcpCommands(mcp, manifest, environment) {
|
|
105
|
+
const run = { environment, manifest };
|
|
106
|
+
for (const scopeGroup of Object.keys(SCOPE_HEADINGS)) {
|
|
107
|
+
for (const command of manifest.commands) {
|
|
108
|
+
if (command.scopeGroup === scopeGroup)
|
|
109
|
+
registerMcpCommand(mcp, command, run);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { CliError, EXIT_CODES, exitCodeForStatus } from "../errors.js";
|
|
2
|
+
import { errorFromAnswer, isRecord, networkFailure, readAnswer } from "../http.js";
|
|
3
|
+
import { cliVersion, identityHeaders } from "../version.js";
|
|
4
|
+
/** Reads a `tools/call` result: the data, the text, whether it is a refusal. */
|
|
5
|
+
export function outcomeOf(result) {
|
|
6
|
+
const record = isRecord(result) ? result : {};
|
|
7
|
+
const content = Array.isArray(record.content) ? record.content : [];
|
|
8
|
+
const text = content
|
|
9
|
+
.filter((block) => isRecord(block) && block.type === 'text' && typeof block.text === 'string')
|
|
10
|
+
.map((block) => block.text)
|
|
11
|
+
.join('\n\n');
|
|
12
|
+
const meta = isRecord(record._meta) ? record._meta : {};
|
|
13
|
+
return {
|
|
14
|
+
isError: record.isError === true,
|
|
15
|
+
structured: isRecord(record.structuredContent) ? record.structuredContent : undefined,
|
|
16
|
+
text,
|
|
17
|
+
authChallenge: 'mcp/www_authenticate' in meta
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** The error a refusal of a tool is: exit 3 when the key is not accepted, else exit 5 with the tool's own words. */
|
|
21
|
+
export function refusalOf(outcome) {
|
|
22
|
+
if (outcome.authChallenge) {
|
|
23
|
+
return new CliError({
|
|
24
|
+
code: 'AUTH_REQUIRED',
|
|
25
|
+
message: outcome.text || 'The API key was not accepted.',
|
|
26
|
+
exitCode: EXIT_CODES.auth,
|
|
27
|
+
hint: 'Run `widgetic auth login` with an API key, or set WIDGETIC_API_KEY.'
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return new CliError({
|
|
31
|
+
code: 'TOOL_ERROR',
|
|
32
|
+
message: outcome.text || 'The tool refused the call.',
|
|
33
|
+
exitCode: EXIT_CODES.rejected
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const JSON_RPC = {
|
|
37
|
+
invalidRequest: -32600,
|
|
38
|
+
methodNotFound: -32601,
|
|
39
|
+
invalidParams: -32602,
|
|
40
|
+
connectionClosed: -32000,
|
|
41
|
+
requestTimeout: -32001
|
|
42
|
+
};
|
|
43
|
+
function isMcpError(error) {
|
|
44
|
+
return (error instanceof Error &&
|
|
45
|
+
error.name === 'McpError' &&
|
|
46
|
+
typeof error.code === 'number');
|
|
47
|
+
}
|
|
48
|
+
function isHttpError(error) {
|
|
49
|
+
return error instanceof Error && error.name === 'StreamableHTTPError';
|
|
50
|
+
}
|
|
51
|
+
export class McpConnection {
|
|
52
|
+
/** The version the server reported when it was opened: the version of its tool contract. */
|
|
53
|
+
serverVersion;
|
|
54
|
+
client;
|
|
55
|
+
lastRefusal;
|
|
56
|
+
environment;
|
|
57
|
+
settings;
|
|
58
|
+
auth;
|
|
59
|
+
timeoutMilliseconds;
|
|
60
|
+
constructor(environment, settings, auth, timeoutMilliseconds) {
|
|
61
|
+
this.environment = environment;
|
|
62
|
+
this.settings = settings;
|
|
63
|
+
this.auth = auth;
|
|
64
|
+
this.timeoutMilliseconds = timeoutMilliseconds;
|
|
65
|
+
}
|
|
66
|
+
/** Calls a tool; throws a `CliError` for everything that is not an answer (a refusal of the tool is an answer: see `refusalOf`). */
|
|
67
|
+
async call(tool, args) {
|
|
68
|
+
this.lastRefusal = undefined;
|
|
69
|
+
try {
|
|
70
|
+
const client = await this.connected();
|
|
71
|
+
const result = await client.callTool({ name: tool, arguments: args }, undefined, {
|
|
72
|
+
timeout: this.timeoutMilliseconds
|
|
73
|
+
});
|
|
74
|
+
return outcomeOf(result);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const failure = this.explain(error);
|
|
78
|
+
// A connection that failed is not trusted again: the next call opens its own.
|
|
79
|
+
await this.close();
|
|
80
|
+
throw failure;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The names of the tools the server offers (`tools/list`), to compare with the ones this CLI was built for. */
|
|
84
|
+
async listTools() {
|
|
85
|
+
this.lastRefusal = undefined;
|
|
86
|
+
try {
|
|
87
|
+
const client = await this.connected();
|
|
88
|
+
const names = [];
|
|
89
|
+
let cursor;
|
|
90
|
+
do {
|
|
91
|
+
const page = await client.listTools(cursor === undefined ? undefined : { cursor }, {
|
|
92
|
+
timeout: this.timeoutMilliseconds
|
|
93
|
+
});
|
|
94
|
+
for (const tool of page.tools)
|
|
95
|
+
names.push(tool.name);
|
|
96
|
+
cursor = page.nextCursor;
|
|
97
|
+
} while (cursor !== undefined);
|
|
98
|
+
return names;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const failure = this.explain(error);
|
|
102
|
+
await this.close();
|
|
103
|
+
throw failure;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async close() {
|
|
107
|
+
const client = this.client;
|
|
108
|
+
this.client = undefined;
|
|
109
|
+
try {
|
|
110
|
+
await client?.close();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Closing is a courtesy; the command's result does not depend on it.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async connected() {
|
|
117
|
+
if (this.client)
|
|
118
|
+
return this.client;
|
|
119
|
+
const [{ Client: SdkClient }, { StreamableHTTPClientTransport }] = await Promise.all([
|
|
120
|
+
import('@modelcontextprotocol/sdk/client/index.js'),
|
|
121
|
+
import('@modelcontextprotocol/sdk/client/streamableHttp.js')
|
|
122
|
+
]);
|
|
123
|
+
const transport = new StreamableHTTPClientTransport(new URL(this.settings.mcpUrl), {
|
|
124
|
+
fetch: async (url, init) => {
|
|
125
|
+
const response = await this.environment.fetch(url, init);
|
|
126
|
+
// Only the answer to a message says why a call failed. The stream a client may open with GET is refused with a 405 by a server that keeps none, and that is not a failure.
|
|
127
|
+
if (!response.ok && init?.method === 'POST')
|
|
128
|
+
this.lastRefusal = await readAnswer(response.clone());
|
|
129
|
+
return response;
|
|
130
|
+
},
|
|
131
|
+
requestInit: {
|
|
132
|
+
headers: { authorization: `Bearer ${this.auth.apiKey}`, ...identityHeaders(this.environment) }
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
const client = new SdkClient({ name: '@widgetic/cli', version: cliVersion() }, { capabilities: {} });
|
|
136
|
+
await client.connect(transport, { timeout: this.timeoutMilliseconds });
|
|
137
|
+
this.client = client;
|
|
138
|
+
this.serverVersion = client.getServerVersion()?.version;
|
|
139
|
+
return client;
|
|
140
|
+
}
|
|
141
|
+
/** What a failure of the SDK stands for, in the CLI's terms. */
|
|
142
|
+
explain(error) {
|
|
143
|
+
if (error instanceof CliError)
|
|
144
|
+
return error;
|
|
145
|
+
// The server refused at the HTTP layer (401, 403, 426, 429 …): its own envelope says what and why.
|
|
146
|
+
if (this.lastRefusal)
|
|
147
|
+
return errorFromAnswer(this.lastRefusal, this.environment.now(), this.auth.source);
|
|
148
|
+
if (isMcpError(error)) {
|
|
149
|
+
const message = error.message.replace(/^MCP error -?\d+:\s*/, '');
|
|
150
|
+
switch (error.code) {
|
|
151
|
+
case JSON_RPC.requestTimeout:
|
|
152
|
+
return networkFailure(Object.assign(new Error(message), { name: 'TimeoutError' }), this.settings.mcpUrl, this.timeoutMilliseconds);
|
|
153
|
+
case JSON_RPC.connectionClosed:
|
|
154
|
+
return networkFailure(error, this.settings.mcpUrl, this.timeoutMilliseconds);
|
|
155
|
+
case JSON_RPC.invalidParams:
|
|
156
|
+
case JSON_RPC.invalidRequest:
|
|
157
|
+
case JSON_RPC.methodNotFound:
|
|
158
|
+
return new CliError({
|
|
159
|
+
code: 'MCP_REJECTED',
|
|
160
|
+
message,
|
|
161
|
+
exitCode: EXIT_CODES.rejected,
|
|
162
|
+
hint: 'The server did not accept the call. This CLI may be older than the server: `widgetic doctor` compares them.',
|
|
163
|
+
cause: error
|
|
164
|
+
});
|
|
165
|
+
default:
|
|
166
|
+
return new CliError({
|
|
167
|
+
code: `MCP_ERROR_${Math.abs(error.code)}`,
|
|
168
|
+
message,
|
|
169
|
+
exitCode: EXIT_CODES.failure,
|
|
170
|
+
cause: error
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (isHttpError(error) && typeof error.code === 'number' && error.code > 0) {
|
|
175
|
+
return new CliError({
|
|
176
|
+
code: `HTTP_${error.code}`,
|
|
177
|
+
message: error.message,
|
|
178
|
+
exitCode: exitCodeForStatus(error.code),
|
|
179
|
+
status: error.code,
|
|
180
|
+
cause: error
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return networkFailure(error, this.settings.mcpUrl, this.timeoutMilliseconds);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/dist/mcp/wait.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { CliError, EXIT_CODES } from "../errors.js";
|
|
2
|
+
import { note } from "../output.js";
|
|
3
|
+
import { WAIT_OVERLAY } from "../overlay.js";
|
|
4
|
+
import { retryNotifier, withRetries } from "../retry.js";
|
|
5
|
+
import { refusalOf } from "./session.js";
|
|
6
|
+
/**
|
|
7
|
+
* `--wait`: a tool that starts a build answers at once with the widget's id and the phase it is in; the command then
|
|
8
|
+
* asks the poll tool again and again until the phase is one of the finished ones, and its result is the finished
|
|
9
|
+
* widget. What is waited for, and how often it is asked, is the overlay (`overlay.ts`), checked against the manifests.
|
|
10
|
+
*/
|
|
11
|
+
function phaseOf(outcome) {
|
|
12
|
+
const phase = outcome.structured?.[WAIT_OVERLAY.phaseProperty];
|
|
13
|
+
return typeof phase === 'string' ? phase : undefined;
|
|
14
|
+
}
|
|
15
|
+
export async function waitForBuild(environment, connection, started, json) {
|
|
16
|
+
const widgetId = started.structured?.[WAIT_OVERLAY.idProperty];
|
|
17
|
+
if (typeof widgetId !== 'string') {
|
|
18
|
+
throw new CliError({
|
|
19
|
+
code: 'WAIT_UNAVAILABLE',
|
|
20
|
+
message: `--wait: the answer names no widget (${WAIT_OVERLAY.idProperty}), so there is nothing to wait for.`,
|
|
21
|
+
exitCode: EXIT_CODES.failure,
|
|
22
|
+
details: started.structured
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const readyPhases = WAIT_OVERLAY.readyPhases;
|
|
26
|
+
const failedPhases = WAIT_OVERLAY.failedPhases;
|
|
27
|
+
const clockStart = environment.now().getTime();
|
|
28
|
+
let waitedSeconds = 0;
|
|
29
|
+
let reported;
|
|
30
|
+
let outcome = started;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const phase = phaseOf(outcome);
|
|
33
|
+
if (phase !== undefined && phase !== reported) {
|
|
34
|
+
reported = phase;
|
|
35
|
+
if (!json)
|
|
36
|
+
note(environment, `Widget ${widgetId}: ${phase}`);
|
|
37
|
+
}
|
|
38
|
+
if (phase !== undefined && readyPhases.includes(phase))
|
|
39
|
+
return outcome;
|
|
40
|
+
if (phase !== undefined && failedPhases.includes(phase)) {
|
|
41
|
+
throw new CliError({
|
|
42
|
+
code: 'BUILD_FAILED',
|
|
43
|
+
message: `The build of widget ${widgetId} ended in phase "${phase}".`,
|
|
44
|
+
exitCode: EXIT_CODES.rejected,
|
|
45
|
+
details: outcome.structured,
|
|
46
|
+
hint: `See the widget: widgetic mcp get-widget --${WAIT_OVERLAY.idProperty.replace(/_/g, '-')} ${widgetId}`
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const elapsed = Math.max(waitedSeconds, (environment.now().getTime() - clockStart) / 1000);
|
|
50
|
+
if (elapsed >= WAIT_OVERLAY.defaultMaxSeconds) {
|
|
51
|
+
throw new CliError({
|
|
52
|
+
code: 'WAIT_TIMED_OUT',
|
|
53
|
+
message: `Widget ${widgetId} is still building (phase: ${phase ?? 'unknown'}) after ${Math.round(WAIT_OVERLAY.defaultMaxSeconds / 60)} minutes. It keeps going on the server.`,
|
|
54
|
+
exitCode: EXIT_CODES.failure,
|
|
55
|
+
details: outcome.structured,
|
|
56
|
+
hint: `Look again later: widgetic mcp get-widget --${WAIT_OVERLAY.idProperty.replace(/_/g, '-')} ${widgetId}`
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
await environment.sleep(WAIT_OVERLAY.pollIntervalSeconds * 1000);
|
|
60
|
+
waitedSeconds += WAIT_OVERLAY.pollIntervalSeconds;
|
|
61
|
+
outcome = await withRetries(environment, { safeToRepeat: true }, async () => {
|
|
62
|
+
const polled = await connection.call(WAIT_OVERLAY.pollTool, { [WAIT_OVERLAY.idProperty]: widgetId });
|
|
63
|
+
if (polled.isError)
|
|
64
|
+
throw refusalOf(polled);
|
|
65
|
+
return polled;
|
|
66
|
+
}, retryNotifier(environment, json));
|
|
67
|
+
}
|
|
68
|
+
}
|