@widgetic/cli 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +122 -0
  2. package/README.md +184 -2
  3. package/bin/widgetic.js +5 -0
  4. package/dist/cli.js +138 -0
  5. package/dist/commands/auth.js +269 -0
  6. package/dist/commands/call.js +97 -0
  7. package/dist/commands/completion.js +150 -0
  8. package/dist/commands/doctor.js +293 -0
  9. package/dist/commands/schema.js +216 -0
  10. package/dist/commands/update.js +159 -0
  11. package/dist/commands/verify.js +64 -0
  12. package/dist/config.js +98 -0
  13. package/dist/confirm.js +15 -0
  14. package/dist/constants.js +57 -0
  15. package/dist/contract-note.js +42 -0
  16. package/dist/credentials.js +111 -0
  17. package/dist/environment.js +150 -0
  18. package/dist/errors.js +67 -0
  19. package/dist/help.js +174 -0
  20. package/dist/http.js +122 -0
  21. package/dist/input.js +37 -0
  22. package/dist/main.js +30 -0
  23. package/dist/manifest/load.js +39 -0
  24. package/dist/manifest/types.js +14 -0
  25. package/dist/mcp/plan.js +32 -0
  26. package/dist/mcp/register.js +112 -0
  27. package/dist/mcp/session.js +186 -0
  28. package/dist/mcp/wait.js +68 -0
  29. package/dist/options.js +228 -0
  30. package/dist/output.js +90 -0
  31. package/dist/overlay.js +54 -0
  32. package/dist/patterns.js +11 -0
  33. package/dist/rest/execute.js +67 -0
  34. package/dist/rest/mime.js +37 -0
  35. package/dist/rest/plan.js +185 -0
  36. package/dist/rest/register.js +144 -0
  37. package/dist/retry.js +49 -0
  38. package/dist/secrets.js +149 -0
  39. package/dist/state.js +37 -0
  40. package/dist/update/installation.js +83 -0
  41. package/dist/update/lookup.js +128 -0
  42. package/dist/update/offer.js +165 -0
  43. package/dist/update/perform.js +33 -0
  44. package/dist/update/registry.js +68 -0
  45. package/dist/update/version.js +39 -0
  46. package/dist/validate.js +93 -0
  47. package/dist/version.js +59 -0
  48. package/generated/contracts.json +1 -0
  49. package/generated/mcp-manifest.json +1 -0
  50. package/generated/rest-manifest.json +1 -0
  51. package/package.json +56 -5
package/dist/help.js ADDED
@@ -0,0 +1,174 @@
1
+ import { editDistance } from "./commands/schema.js";
2
+ import { loadMcpManifest, loadRestManifest } from "./manifest/load.js";
3
+ import { API_GROUP_SUMMARIES } from "./overlay.js";
4
+ /**
5
+ * What the screens a new person meets first say beyond what the contracts say: the headings that sort the commands of
6
+ * `widgetic --help`, the "Get started" and "Examples" at the foot of `widgetic`, `widgetic api` and `widgetic mcp`, the
7
+ * line each group of the REST API gets, and what is said after a word that is no command.
8
+ *
9
+ * The commands themselves, their flags and their descriptions come from the contracts and are not here. Every command
10
+ * an example names is checked by `test/help.test.ts` (it exists, and so does every flag), so the help cannot send
11
+ * a person to a command that was renamed. The lines are short on purpose (78 characters at most): the text of a
12
+ * footer is printed as it is written, and a terminal that is 80 columns wide would break a longer one.
13
+ */
14
+ /** The headings that sort the commands of `widgetic --help`: what you do with Widgetic, how you set it up, how you look things up. */
15
+ export const ROOT_HEADINGS = {
16
+ work: 'Work with Widgetic:',
17
+ setup: 'Set up:',
18
+ lookup: 'Look things up:'
19
+ };
20
+ /** The dashboard, where an API key is made (Settings > API Keys): the address the documentation gives for it. */
21
+ const DASHBOARD = 'app.widgetic.com';
22
+ /** The first steps of a person who has just installed the CLI: the first is made in the dashboard, the rest are commands. */
23
+ export const GET_STARTED_COMMANDS = [
24
+ { command: 'widgetic auth login', says: 'save it (it is asked for, and never shown)' },
25
+ { command: 'widgetic doctor', says: 'check that the key and both servers work' },
26
+ { command: 'widgetic mcp list-widgets', says: 'see your widgets' }
27
+ ];
28
+ /** Commands that are safe to run to see what the CLI does: they read, or they only print the request. */
29
+ export const ROOT_EXAMPLES = [
30
+ 'widgetic mcp list-widgets',
31
+ 'widgetic api widgets list --json',
32
+ 'widgetic mcp create-widget --title Launch --brief "A countdown" --dry-run',
33
+ 'widgetic schema mcp create-widget'
34
+ ];
35
+ export const API_EXAMPLES = [
36
+ 'widgetic api widgets list',
37
+ 'widgetic api compositions list --size 5',
38
+ 'widgetic api tokens validate-token'
39
+ ];
40
+ export const MCP_EXAMPLES = [
41
+ 'widgetic mcp list-widgets',
42
+ 'widgetic mcp create-widget --title Launch --brief "A countdown" --wait',
43
+ 'widgetic mcp create-widget --help'
44
+ ];
45
+ /** A block of lines indented by two spaces, under a heading. */
46
+ function section(heading, lines) {
47
+ return `${heading}\n${lines.map((line) => ` ${line}`).join('\n')}`;
48
+ }
49
+ /** The numbered steps of "Get started", with the sentence of each command aligned in a column. */
50
+ function stepLines() {
51
+ const width = Math.max(...GET_STARTED_COMMANDS.map((step) => step.command.length));
52
+ return [
53
+ `1. Make an API key in the dashboard (Settings > API Keys, ${DASHBOARD}).`,
54
+ ...GET_STARTED_COMMANDS.map((step, index) => `${index + 2}. ${step.command.padEnd(width)} ${step.says}`)
55
+ ];
56
+ }
57
+ /** What `widgetic --help` ends with. */
58
+ export function rootHelpFooter() {
59
+ return [
60
+ '',
61
+ section('Get started:', stepLines()),
62
+ '',
63
+ section('Examples:', ROOT_EXAMPLES),
64
+ '',
65
+ 'Add --json to get the result as one line of JSON, for scripts. Add --dry-run',
66
+ 'to a command that calls the API to print the request and send nothing.',
67
+ 'Add --help to any command to see what it takes.'
68
+ ].join('\n');
69
+ }
70
+ /** What `widgetic api --help` ends with. */
71
+ export function apiHelpFooter() {
72
+ return [
73
+ '',
74
+ section('Examples:', API_EXAMPLES),
75
+ '',
76
+ 'Run widgetic api <group> --help to list the commands of a group. A group',
77
+ 'that needs a partner API key says so.'
78
+ ].join('\n');
79
+ }
80
+ /** What `widgetic mcp --help` ends with. */
81
+ export function mcpHelpFooter() {
82
+ return [
83
+ '',
84
+ section('Examples:', MCP_EXAMPLES),
85
+ '',
86
+ 'A command that builds a widget starts the build and answers at once; --wait',
87
+ 'keeps asking until the build is finished and prints the finished widget.'
88
+ ].join('\n');
89
+ }
90
+ /** Every command of a group needs a partner API key (`wgt_prt_…`), so the group's line says so. */
91
+ function needsPartnerKey(group) {
92
+ const listed = group.commands.filter((command) => !command.hidden);
93
+ return listed.length > 0 && listed.every((command) => command.accessLevel === 'partner');
94
+ }
95
+ /**
96
+ * The line a group of the REST API gets in the list of `widgetic api --help` and at the top of its own help: what it
97
+ * works on, from the overlay, and the kind of key it needs when that is always the partner's. A group the overlay does
98
+ * not know (a later contract added it) is listed by its name and its number of commands, which is true if not pretty.
99
+ */
100
+ export function apiGroupSummary(group) {
101
+ const count = group.commands.length;
102
+ const written = API_GROUP_SUMMARIES[group.name] ?? `${group.title}: ${count} ${count === 1 ? 'command' : 'commands'}.`;
103
+ return needsPartnerKey(group) ? `${written.replace(/\.$/, '')} (needs a partner key).` : written;
104
+ }
105
+ /** A name without its separators or its case, so `list_widgets`, `list-widgets` and `ListWidgets` are one word. */
106
+ const alphanumeric = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '');
107
+ /**
108
+ * The commands of `argv` that exist, from the program down: `api widgets` for `api widgets get`, where `get` is not a
109
+ * command of `widgets`. A word that is not a command (a flag's value, the unknown word itself) ends the walk, so what
110
+ * is returned is always a command the person can ask for `--help`.
111
+ */
112
+ export function existingCommandPath(root, argv) {
113
+ let command = root;
114
+ const path = [];
115
+ for (const word of argv) {
116
+ if (word.startsWith('-'))
117
+ continue;
118
+ const next = command.commands.find((child) => child.name() === word || child.aliases().includes(word));
119
+ if (!next)
120
+ break;
121
+ command = next;
122
+ path.push(next.name());
123
+ }
124
+ return path;
125
+ }
126
+ /** The word of a command line that is the name of the command: the first one that is not a flag. */
127
+ export function firstCommandWord(argv) {
128
+ return argv.find((word) => !word.startsWith('-'));
129
+ }
130
+ /**
131
+ * What to say after `widgetic <word>` when the word is no command of `widgetic`. People coming from other tools leave
132
+ * out the namespace (`widgetic widgets list`, `widgetic list-widgets`), so the word is looked for among the groups of
133
+ * the REST API and the tools of the MCP server, and a name that is close to one is offered. One line: the hint is
134
+ * printed on a single indented line.
135
+ */
136
+ export function unknownCommandHint(word, rest = []) {
137
+ const fallback = 'Run widgetic --help to see the commands.';
138
+ try {
139
+ const groups = loadRestManifest().groups.filter((group) => !group.hidden);
140
+ const tools = loadMcpManifest().commands;
141
+ const typed = alphanumeric(word);
142
+ const group = groups.find((candidate) => alphanumeric(candidate.name) === typed);
143
+ if (group) {
144
+ // The words the person typed after the group, up to the first flag (`widgets list --id x` is `list`).
145
+ const following = [];
146
+ for (const next of rest) {
147
+ if (next.startsWith('-'))
148
+ break;
149
+ following.push(next);
150
+ }
151
+ return `"${word}" is in the REST API: try widgetic api ${[group.name, ...following].join(' ')}`;
152
+ }
153
+ const tool = tools.find((candidate) => alphanumeric(candidate.name) === typed);
154
+ if (tool)
155
+ return `"${word}" is a tool of the MCP server: try widgetic mcp ${tool.name}`;
156
+ // Only names a typo away: a name that merely contains the word (`widget` is in a dozen tools) would bury the one meant.
157
+ const allowed = typed.length >= 5 ? 2 : 1;
158
+ const closeTo = (names) => names
159
+ .map((name) => ({ name, distance: editDistance(typed, alphanumeric(name)) }))
160
+ .filter((entry) => entry.distance <= allowed)
161
+ .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name))
162
+ .map((entry) => entry.name);
163
+ const near = [
164
+ ...closeTo(groups.map((candidate) => candidate.name)).map((name) => `widgetic api ${name}`),
165
+ ...closeTo(tools.map((candidate) => candidate.name)).map((name) => `widgetic mcp ${name}`)
166
+ ].slice(0, 3);
167
+ if (near.length > 0)
168
+ return `Did you mean ${near.join(' or ')}? ${fallback}`;
169
+ }
170
+ catch {
171
+ // The manifests could not be read (a broken install): `widgetic doctor` says so; the hint is only a courtesy.
172
+ }
173
+ return fallback;
174
+ }
package/dist/http.js ADDED
@@ -0,0 +1,122 @@
1
+ import { KEY_REFUSED_HINT } from "./constants.js";
2
+ import { CliError, EXIT_CODES, exitCodeForStatus } from "./errors.js";
3
+ export function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ /** Reads a whole answer. */
7
+ export async function readAnswer(response) {
8
+ const text = await response.text();
9
+ const type = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() ?? '';
10
+ let body;
11
+ if (text !== '') {
12
+ if (type.includes('json')) {
13
+ try {
14
+ body = JSON.parse(text);
15
+ }
16
+ catch {
17
+ body = text;
18
+ }
19
+ }
20
+ else {
21
+ body = text;
22
+ }
23
+ }
24
+ return { status: response.status, headers: response.headers, body };
25
+ }
26
+ /** `Retry-After` is a number of seconds or a date; either way, seconds from now (never negative). */
27
+ export function parseRetryAfter(value, now) {
28
+ if (value === null || value.trim() === '')
29
+ return undefined;
30
+ if (/^\d+$/.test(value.trim()))
31
+ return Number(value.trim());
32
+ const date = Date.parse(value);
33
+ if (Number.isNaN(date))
34
+ return undefined;
35
+ return Math.max(0, Math.ceil((date - now.getTime()) / 1000));
36
+ }
37
+ function upgradeHint(details) {
38
+ const command = isRecord(details) ? details.upgradeCommand : undefined;
39
+ // The gateway names the package of the client it recognised; for this CLI that is `@widgetic/cli`.
40
+ return typeof command === 'string' ? `Update the CLI: ${command}` : undefined;
41
+ }
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
+ : KEY_REFUSED_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;
@@ -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
+ }