@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.
Files changed (50) 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 +106 -0
  5. package/dist/commands/auth.js +269 -0
  6. package/dist/commands/call.js +97 -0
  7. package/dist/commands/completion.js +148 -0
  8. package/dist/commands/doctor.js +291 -0
  9. package/dist/commands/schema.js +214 -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 +51 -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/http.js +122 -0
  20. package/dist/input.js +37 -0
  21. package/dist/main.js +30 -0
  22. package/dist/manifest/load.js +39 -0
  23. package/dist/manifest/types.js +14 -0
  24. package/dist/mcp/plan.js +32 -0
  25. package/dist/mcp/register.js +112 -0
  26. package/dist/mcp/session.js +185 -0
  27. package/dist/mcp/wait.js +68 -0
  28. package/dist/options.js +228 -0
  29. package/dist/output.js +90 -0
  30. package/dist/overlay.js +31 -0
  31. package/dist/patterns.js +11 -0
  32. package/dist/rest/execute.js +67 -0
  33. package/dist/rest/mime.js +37 -0
  34. package/dist/rest/plan.js +185 -0
  35. package/dist/rest/register.js +139 -0
  36. package/dist/retry.js +49 -0
  37. package/dist/secrets.js +149 -0
  38. package/dist/state.js +37 -0
  39. package/dist/update/installation.js +83 -0
  40. package/dist/update/lookup.js +128 -0
  41. package/dist/update/offer.js +165 -0
  42. package/dist/update/perform.js +33 -0
  43. package/dist/update/registry.js +68 -0
  44. package/dist/update/version.js +39 -0
  45. package/dist/validate.js +93 -0
  46. package/dist/version.js +59 -0
  47. package/generated/contracts.json +1 -0
  48. package/generated/mcp-manifest.json +1 -0
  49. package/generated/rest-manifest.json +1 -0
  50. package/package.json +56 -5
@@ -0,0 +1,64 @@
1
+ import { errorFromAnswer, isRecord, networkFailure, readAnswer } from "../http.js";
2
+ import { withRetries } from "../retry.js";
3
+ import { identityHeaders } from "../version.js";
4
+ /** What the answer of `/tokens/validate` says about the key; a field the server did not send is `undefined`. */
5
+ export function keyCheckOf(body) {
6
+ const data = isRecord(body) && isRecord(body.data) ? body.data : {};
7
+ return {
8
+ tokenType: typeof data.tokenType === 'string' ? data.tokenType : undefined,
9
+ scopes: Array.isArray(data.scopes)
10
+ ? data.scopes.filter((scope) => typeof scope === 'string')
11
+ : [],
12
+ userId: typeof data.userId === 'string' ? data.userId : undefined,
13
+ expiresAt: typeof data.expiresAt === 'string' ? data.expiresAt : undefined
14
+ };
15
+ }
16
+ /** The address a key is checked at. */
17
+ export function validateUrl(settings) {
18
+ return `${settings.apiUrl}/tokens/validate`;
19
+ }
20
+ /** Resolves with what the server says about the key, or throws what its refusal (or its silence) stands for. */
21
+ export async function verifyApiKey(environment, settings, apiKey, options) {
22
+ const url = validateUrl(settings);
23
+ const attempt = async () => {
24
+ let answer;
25
+ try {
26
+ const response = await environment.fetch(url, {
27
+ method: 'GET',
28
+ headers: {
29
+ accept: 'application/json',
30
+ authorization: `Bearer ${apiKey}`,
31
+ ...identityHeaders(environment)
32
+ },
33
+ signal: AbortSignal.timeout(options.timeoutMilliseconds)
34
+ });
35
+ answer = await readAnswer(response);
36
+ }
37
+ catch (error) {
38
+ throw networkFailure(error, url, options.timeoutMilliseconds);
39
+ }
40
+ if (answer.status < 200 || answer.status >= 300) {
41
+ throw errorFromAnswer(answer, environment.now(), options.keySource);
42
+ }
43
+ return answer;
44
+ };
45
+ const answer = await withRetries(environment, { safeToRepeat: true }, attempt, options.onRetry);
46
+ return keyCheckOf(answer.body);
47
+ }
48
+ /** Whether a server answers at all at an address, whatever it says: the answer's status, and how long it took. */
49
+ export async function probe(environment, url, init, timeoutMilliseconds) {
50
+ const started = environment.now().getTime();
51
+ try {
52
+ const response = await environment.fetch(url, {
53
+ ...init,
54
+ headers: { ...init.headers, ...identityHeaders(environment) },
55
+ signal: AbortSignal.timeout(timeoutMilliseconds)
56
+ });
57
+ // The body is of no interest, and an unread one holds the connection.
58
+ await response.body?.cancel();
59
+ return { status: response.status, milliseconds: environment.now().getTime() - started };
60
+ }
61
+ catch (error) {
62
+ throw networkFailure(error, url, timeoutMilliseconds);
63
+ }
64
+ }
package/dist/config.js ADDED
@@ -0,0 +1,98 @@
1
+ import { join, resolve } from 'node:path';
2
+ import { DEFAULT_MCP_URL, DEFAULT_PROFILE, DEFAULT_TIMEOUT_SECONDS, PROFILE_NAME, UPDATE_CHECK_CONFIG } from "./constants.js";
3
+ import { usageError } from "./errors.js";
4
+ import { loadContractsInfo } from "./manifest/load.js";
5
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
6
+ /** An address from the environment: https, or http only for this machine, so a key is never sent in the clear to another. */
7
+ function checkedUrl(name, value) {
8
+ let url;
9
+ try {
10
+ url = new URL(value);
11
+ }
12
+ catch {
13
+ throw usageError(`${name} is not an address: ${value}`);
14
+ }
15
+ const local = LOCAL_HOSTS.has(url.hostname);
16
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
17
+ throw usageError(`${name} must be an https address (http is only accepted for localhost): ${value}`);
18
+ }
19
+ return url;
20
+ }
21
+ export function configDirectory(environment) {
22
+ const explicit = environment.env.WIDGETIC_CONFIG_DIR?.trim();
23
+ if (explicit)
24
+ return resolve(environment.cwd, explicit);
25
+ if (environment.platform === 'win32') {
26
+ return join(environment.env.APPDATA?.trim() || join(environment.homeDir, 'AppData', 'Roaming'), 'widgetic');
27
+ }
28
+ return join(environment.env.XDG_CONFIG_HOME?.trim() || join(environment.homeDir, '.config'), 'widgetic');
29
+ }
30
+ /** A switch variable is on when it is set to something other than nothing, `0`, `false`, `no` or `off`. */
31
+ function isSwitchedOn(value) {
32
+ const text = value?.trim().toLowerCase() ?? '';
33
+ return text !== '' && !['0', 'false', 'no', 'off'].includes(text);
34
+ }
35
+ /**
36
+ * Whether the environment says this run must not look for a newer version: a person turned it off
37
+ * (`NO_UPDATE_NOTIFIER`, `WIDGETIC_NO_UPDATE_CHECK`), or it is a CI job (`CI`). The word is the reason, for `WIDGETIC_DEBUG`.
38
+ */
39
+ export function updateCheckSwitchedOffBy(environment) {
40
+ for (const name of ['NO_UPDATE_NOTIFIER', 'WIDGETIC_NO_UPDATE_CHECK', 'CI']) {
41
+ if (isSwitchedOn(environment.env[name]))
42
+ return name;
43
+ }
44
+ return undefined;
45
+ }
46
+ /**
47
+ * The registry the look for a newer version asks: `npm_config_registry` (npm's own variable) when it is an https
48
+ * address (http only for this machine), else the public registry. A value that is not an address, or that carries a
49
+ * user name or password (which would end up in a debug line), is ignored, not an error: a look for an update is never
50
+ * the reason a command fails.
51
+ */
52
+ export function npmRegistryUrl(environment) {
53
+ const configured = (environment.env.npm_config_registry ?? environment.env.NPM_CONFIG_REGISTRY)?.trim();
54
+ if (configured) {
55
+ try {
56
+ const url = new URL(configured);
57
+ const local = LOCAL_HOSTS.has(url.hostname);
58
+ const carriesCredentials = url.username !== '' || url.password !== '';
59
+ if (!carriesCredentials && (url.protocol === 'https:' || (url.protocol === 'http:' && local))) {
60
+ return url.href.replace(/\/+$/, '');
61
+ }
62
+ }
63
+ catch {
64
+ // Not an address: the public registry is asked.
65
+ }
66
+ }
67
+ return UPDATE_CHECK_CONFIG.REGISTRY_URL;
68
+ }
69
+ /** The profile named by `--profile`, else by `WIDGETIC_PROFILE`, else the default one. */
70
+ export function profileName(environment, fromFlag) {
71
+ const name = fromFlag?.trim() || environment.env.WIDGETIC_PROFILE?.trim() || DEFAULT_PROFILE;
72
+ if (!PROFILE_NAME.test(name)) {
73
+ throw usageError(`"${name}" is not a profile name: use letters, digits, dots, hyphens and underscores (at most 64).`);
74
+ }
75
+ return name;
76
+ }
77
+ /**
78
+ * `manifestApiUrl` is the server of the REST manifest when the command has it loaded; the others (the MCP commands,
79
+ * `auth`, `doctor`) read the same address from the small `contracts.json` and never load the whole description.
80
+ */
81
+ export function resolveSettings(environment, options) {
82
+ const contractUrl = options.manifestApiUrl ?? loadContractsInfo().openApi.servers[0];
83
+ if (!contractUrl)
84
+ throw new Error('generated/contracts.json names no server: this build of @widgetic/cli is incomplete.');
85
+ const apiUrl = checkedUrl('WIDGETIC_API_URL', environment.env.WIDGETIC_API_URL?.trim() || contractUrl);
86
+ const mcpUrl = checkedUrl('WIDGETIC_MCP_URL', environment.env.WIDGETIC_MCP_URL?.trim() || DEFAULT_MCP_URL);
87
+ return {
88
+ profile: profileName(environment, options.profile),
89
+ apiUrl: apiUrl.href.replace(/\/+$/, ''),
90
+ apiRootUrl: apiUrl.origin,
91
+ mcpUrl: mcpUrl.href,
92
+ configDir: configDirectory(environment)
93
+ };
94
+ }
95
+ /** The seconds `--timeout` gave, or the default; the flag is checked when it is parsed. */
96
+ export function timeoutMilliseconds(seconds) {
97
+ return (seconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000;
98
+ }
@@ -0,0 +1,15 @@
1
+ import { CliError, EXIT_CODES, usageError } from "./errors.js";
2
+ export async function confirmOrStop(environment, input) {
3
+ if (input.yes)
4
+ return;
5
+ if (input.json || environment.confirm === undefined) {
6
+ throw usageError(`${input.what}, and there is nobody to ask: nothing was sent.`, 'Run it again with --yes to go ahead.');
7
+ }
8
+ if (await environment.confirm(`${input.what}. Continue?`))
9
+ return;
10
+ throw new CliError({
11
+ code: 'CANCELLED',
12
+ message: 'Cancelled: nothing was sent.',
13
+ exitCode: EXIT_CODES.failure
14
+ });
15
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Settings of the CLI that are not secrets: addresses, limits, intervals. They are constants, read where they are used,
3
+ * and the same for everybody; the only things a person sets are an API key, to point the CLI at another environment
4
+ * the two addresses (`WIDGETIC_API_URL`, `WIDGETIC_MCP_URL`), and, to stop it looking for a newer version of itself,
5
+ * `NO_UPDATE_NOTIFIER` or `WIDGETIC_NO_UPDATE_CHECK`.
6
+ */
7
+ /** The public MCP endpoint. The REST address is not here: it is the server of the OpenAPI description the CLI was generated from. */
8
+ export const DEFAULT_MCP_URL = 'https://mcp.widgetic.com/';
9
+ /** The profile that holds the key `widgetic auth login` saved when no other is named. */
10
+ export const DEFAULT_PROFILE = 'default';
11
+ /** A profile is named like a file: letters, digits, dots, hyphens and underscores. */
12
+ export const PROFILE_NAME = /^[A-Za-z0-9._-]{1,64}$/;
13
+ /** What a Widgetic API key looks like (`wgt_usr_…`, `wgt_prt_…`, `wdg_org_…`): used to warn about a value that is not one, never to refuse one. */
14
+ export const API_KEY_SHAPE = /^(?:wgt_(?:adm|prt|usr)|wdg_org)_[A-Za-z0-9]{16,}$/;
15
+ /** Seconds a call may take before the CLI gives up on it; `--timeout` changes it. */
16
+ export const DEFAULT_TIMEOUT_SECONDS = 60;
17
+ /**
18
+ * Retries. A call is tried at most `MAX_ATTEMPTS` times, waiting `BASE_DELAY_MS`, then three times as long, between
19
+ * tries; a server that says how long to wait (`Retry-After`) is believed, up to `MAX_RETRY_AFTER_SECONDS`: a longer
20
+ * wait is the error's business and is reported with the exit code for "rate limited".
21
+ */
22
+ export const RETRY_CONFIG = {
23
+ MAX_ATTEMPTS: 3,
24
+ BASE_DELAY_MS: 500,
25
+ GROWTH_FACTOR: 3,
26
+ MAX_RETRY_AFTER_SECONDS: 30
27
+ };
28
+ /** How long after it said so the CLI stays quiet about a server whose tool contract is not the one it was built for. */
29
+ export const CONTRACT_NOTE_INTERVAL_HOURS = 24;
30
+ /**
31
+ * Looking for a newer version of the CLI and offering it (`src/update`). The look is one small request to npm, made
32
+ * beside a command on a terminal, at most once a day; the offer is a question, asked after the command has finished.
33
+ */
34
+ export const UPDATE_CHECK_CONFIG = {
35
+ /** The public npm registry. `npm_config_registry` replaces it for a person whose installs come from a mirror. */
36
+ REGISTRY_URL: 'https://registry.npmjs.org',
37
+ /** After a look that worked, how long its answer is believed before npm is asked again. */
38
+ CHECK_INTERVAL_HOURS: 24,
39
+ /** After a look that failed (offline, npm down), how long before the next try. */
40
+ RETRY_AFTER_FAILURE_HOURS: 1,
41
+ /** How long npm has to answer the look that runs beside a command: a slow registry must not slow a command. */
42
+ BACKGROUND_TIMEOUT_MILLISECONDS: 2_000,
43
+ /** How long `widgetic update`, which a person asked for, waits for npm. */
44
+ COMMAND_TIMEOUT_MILLISECONDS: 15_000,
45
+ /** After a no (or no answer), how long the same version is not offered again; a newer one is offered at once. */
46
+ SNOOZE_DAYS: 7,
47
+ /** How long the question waits before it counts as a no: an agent in a pseudo-terminal cannot answer it. */
48
+ PROMPT_TIMEOUT_SECONDS: 20
49
+ };
50
+ /** Longest stretch of a command's description shown in `--help`; the whole text is in `widgetic schema`. */
51
+ export const HELP_DESCRIPTION_LIMIT = 700;
@@ -0,0 +1,42 @@
1
+ import { CONTRACT_NOTE_INTERVAL_HOURS } from "./constants.js";
2
+ import { isRecord } from "./http.js";
3
+ import { note } from "./output.js";
4
+ import { readState, writeStateKey } from "./state.js";
5
+ /**
6
+ * The one thing the CLI says unasked about the server: its tool contract is not the one this build was generated from,
7
+ * so a command may be missing or a flag may have changed. It is said once in 24 hours for the same pair of versions, on
8
+ * stderr, never with `--json` (the stderr of a program that asked for JSON holds errors only).
9
+ *
10
+ * What was said, and when, is kept under `contractNote` in `state.json` beside the credentials (`state.ts`). A state that
11
+ * cannot be read or written is not a problem: the note is simply said again.
12
+ */
13
+ const STATE_KEY = 'contractNote';
14
+ async function readRecord(environment, configDir) {
15
+ const record = (await readState(environment, configDir))[STATE_KEY];
16
+ if (isRecord(record) &&
17
+ typeof record.server === 'string' &&
18
+ typeof record.expected === 'string' &&
19
+ typeof record.at === 'string') {
20
+ return { server: record.server, expected: record.expected, at: record.at };
21
+ }
22
+ return undefined;
23
+ }
24
+ export async function noteContractDifference(environment, settings, input) {
25
+ if (input.json || input.server === undefined || input.server === input.expected)
26
+ return;
27
+ const last = await readRecord(environment, settings.configDir);
28
+ const now = environment.now();
29
+ if (last && last.server === input.server && last.expected === input.expected) {
30
+ const age = now.getTime() - Date.parse(last.at);
31
+ if (age >= 0 && age < CONTRACT_NOTE_INTERVAL_HOURS * 3_600_000)
32
+ return;
33
+ }
34
+ note(environment, `note: the Widgetic server speaks tool contract ${input.server}; this CLI was built for ${input.expected}, so a command or a flag may be missing or different. Update it: npm install -g @widgetic/cli@latest`);
35
+ try {
36
+ const record = { server: input.server, expected: input.expected, at: now.toISOString() };
37
+ await writeStateKey(environment, settings.configDir, STATE_KEY, record);
38
+ }
39
+ catch {
40
+ // Not remembered: said again next time, which is harmless.
41
+ }
42
+ }
@@ -0,0 +1,111 @@
1
+ import { join } from 'node:path';
2
+ import { DEFAULT_PROFILE } from "./constants.js";
3
+ import { CliError, EXIT_CODES } from "./errors.js";
4
+ const FILE_NAME = 'credentials.json';
5
+ function damaged(path, why) {
6
+ return new CliError({
7
+ code: 'CREDENTIALS_FILE_INVALID',
8
+ message: `The credentials file ${path} cannot be used: ${why}`,
9
+ exitCode: EXIT_CODES.auth,
10
+ hint: 'Delete it and run `widgetic auth login` again, or set WIDGETIC_API_KEY.'
11
+ });
12
+ }
13
+ function isStoredProfile(value) {
14
+ return (typeof value === 'object' &&
15
+ value !== null &&
16
+ typeof value.apiKey === 'string' &&
17
+ typeof value.savedAt === 'string');
18
+ }
19
+ export class CredentialStore {
20
+ path;
21
+ environment;
22
+ constructor(environment, directory) {
23
+ this.environment = environment;
24
+ this.path = join(directory, FILE_NAME);
25
+ }
26
+ /** The saved profiles; no file is an empty list, a file that is not ours is an error (nothing is overwritten by guessing). */
27
+ async read() {
28
+ const found = await this.environment.fs.stat(this.path);
29
+ if (!found)
30
+ return { version: 1, profiles: {} };
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(new TextDecoder().decode(await this.environment.fs.readFile(this.path)));
34
+ }
35
+ catch (error) {
36
+ throw damaged(this.path, error instanceof Error ? error.message : String(error));
37
+ }
38
+ const profiles = parsed?.profiles;
39
+ if (typeof parsed !== 'object' ||
40
+ parsed === null ||
41
+ parsed.version !== 1 ||
42
+ typeof profiles !== 'object' ||
43
+ profiles === null) {
44
+ throw damaged(this.path, 'it is not a credentials file of this version of the CLI.');
45
+ }
46
+ for (const [name, profile] of Object.entries(profiles)) {
47
+ if (!isStoredProfile(profile))
48
+ throw damaged(this.path, `the profile "${name}" has no key.`);
49
+ }
50
+ return { version: 1, profiles: { ...profiles } };
51
+ }
52
+ async get(profile) {
53
+ return (await this.read()).profiles[profile];
54
+ }
55
+ async save(profile, apiKey) {
56
+ const file = await this.read();
57
+ file.profiles[profile] = { apiKey, savedAt: this.environment.now().toISOString() };
58
+ await this.environment.fs.writeFilePrivately(this.path, `${JSON.stringify(file, null, 2)}\n`);
59
+ }
60
+ /** Forgets a profile; `false` when there was none. The file goes with its last profile. */
61
+ async remove(profile) {
62
+ const file = await this.read();
63
+ if (!(profile in file.profiles))
64
+ return false;
65
+ delete file.profiles[profile];
66
+ if (Object.keys(file.profiles).length === 0)
67
+ await this.environment.fs.remove(this.path);
68
+ else
69
+ await this.environment.fs.writeFilePrivately(this.path, `${JSON.stringify(file, null, 2)}\n`);
70
+ return true;
71
+ }
72
+ /** Whether other users of this machine can read the file (POSIX permissions); `false` where there are none to read. */
73
+ async isReadableByOthers() {
74
+ if (this.environment.platform === 'win32')
75
+ return false;
76
+ const found = await this.environment.fs.stat(this.path);
77
+ return found !== undefined && (found.mode & 0o077) !== 0;
78
+ }
79
+ }
80
+ /** The key a call is made with, or `undefined` when there is none; `WIDGETIC_API_KEY` wins over a saved one. */
81
+ export async function resolveApiKey(environment, store, profile) {
82
+ const fromEnvironment = environment.env.WIDGETIC_API_KEY?.trim();
83
+ if (fromEnvironment)
84
+ return { apiKey: fromEnvironment, source: 'environment', profile };
85
+ const saved = await store.get(profile);
86
+ return saved ? { apiKey: saved.apiKey, source: 'file', profile } : undefined;
87
+ }
88
+ /** The error for a call that needs a key and has none (exit 3, nothing was sent). */
89
+ export function noApiKey(settings) {
90
+ return new CliError({
91
+ code: 'NO_API_KEY',
92
+ message: settings.profile === DEFAULT_PROFILE
93
+ ? 'No API key.'
94
+ : `No API key is saved for the profile "${settings.profile}".`,
95
+ exitCode: EXIT_CODES.auth,
96
+ hint: 'Run `widgetic auth login` with an API key, or set WIDGETIC_API_KEY.'
97
+ });
98
+ }
99
+ /** The key for a call that carries one, or the error that says how to get one (exit 3, nothing was sent). */
100
+ export async function requireApiKey(environment, settings) {
101
+ const found = await resolveApiKey(environment, new CredentialStore(environment, settings.configDir), settings.profile);
102
+ if (found)
103
+ return found;
104
+ throw noApiKey(settings);
105
+ }
106
+ /** `wgt_usr_…a1b2`: enough to tell two keys apart, nothing that works as a key. */
107
+ export function maskKey(apiKey) {
108
+ if (apiKey.length <= 12)
109
+ return '…';
110
+ return `${apiKey.slice(0, 8)}…${apiKey.slice(-4)}`;
111
+ }
@@ -0,0 +1,150 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { createInterface } from 'node:readline';
7
+ import { fileURLToPath } from 'node:url';
8
+ /** Whether a person is at the terminal: both ends of a question are terminals. */
9
+ function hasTerminal() {
10
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY);
11
+ }
12
+ /**
13
+ * Asks one question on the terminal and answers what was typed. It rejects when the question is closed before it is
14
+ * answered (Ctrl-C or Ctrl-D): the caller decides what that means.
15
+ */
16
+ export function askOnTerminal(question, options = {}) {
17
+ const hidden = options.hidden === true;
18
+ const streams = options.streams ?? { input: process.stdin, output: process.stderr };
19
+ return new Promise((resolve, reject) => {
20
+ const readline = createInterface({ input: streams.input, output: streams.output, terminal: true });
21
+ let timer;
22
+ let timedOut = false;
23
+ let answered = false;
24
+ if (hidden) {
25
+ // Muting the echo: the question is written once, then every character the person types is swallowed.
26
+ const mutable = readline;
27
+ const original = mutable._writeToOutput.bind(readline);
28
+ let questionWritten = false;
29
+ mutable._writeToOutput = (text) => {
30
+ if (!questionWritten) {
31
+ questionWritten = true;
32
+ original(text);
33
+ }
34
+ };
35
+ }
36
+ readline.question(question, (answer) => {
37
+ // Said before the interface is closed: closing it emits 'close' at once, and that handler must know that this
38
+ // is an answer and not a question that was given up (it used to reject every answered question).
39
+ answered = true;
40
+ clearTimeout(timer);
41
+ readline.close();
42
+ if (hidden)
43
+ streams.output.write('\n');
44
+ resolve(answer);
45
+ });
46
+ readline.once('close', () => {
47
+ clearTimeout(timer);
48
+ if (answered)
49
+ return;
50
+ if (timedOut)
51
+ resolve('');
52
+ else
53
+ reject(new Error('the question was closed before it was answered'));
54
+ });
55
+ if (options.timeoutMilliseconds !== undefined) {
56
+ timer = setTimeout(() => {
57
+ timedOut = true;
58
+ // The question was left on its line: the next text starts on a new one.
59
+ streams.output.write('\n');
60
+ readline.close();
61
+ }, options.timeoutMilliseconds);
62
+ }
63
+ });
64
+ }
65
+ const isMissing = (error) => typeof error === 'object' && error !== null && error.code === 'ENOENT';
66
+ /** The files of this machine. */
67
+ export function realFileSystem() {
68
+ return {
69
+ readFile: async (path) => new Uint8Array(await readFile(path)),
70
+ stat: async (path) => {
71
+ try {
72
+ const found = await stat(path);
73
+ return {
74
+ size: found.size,
75
+ mode: found.mode & 0o777,
76
+ isFile: found.isFile(),
77
+ isDirectory: found.isDirectory()
78
+ };
79
+ }
80
+ catch (error) {
81
+ if (isMissing(error))
82
+ return undefined;
83
+ throw error;
84
+ }
85
+ },
86
+ writeFilePrivately: async (path, text) => {
87
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
88
+ // Written beside the file and moved over it: a reader sees the old file or the new one, never half of one.
89
+ const scratch = `${path}.${process.pid}.tmp`;
90
+ try {
91
+ await writeFile(scratch, text, { mode: 0o600 });
92
+ // `mode` above is subject to the umask and to a scratch file that already existed: say it again.
93
+ await chmod(scratch, 0o600);
94
+ await rename(scratch, path);
95
+ }
96
+ catch (error) {
97
+ await rm(scratch, { force: true });
98
+ throw error;
99
+ }
100
+ },
101
+ remove: async (path) => {
102
+ await rm(path, { force: true });
103
+ }
104
+ };
105
+ }
106
+ /** The real thing: this process. */
107
+ export function realEnvironment() {
108
+ const terminal = hasTerminal();
109
+ return {
110
+ env: process.env,
111
+ stdout: process.stdout,
112
+ stderr: process.stderr,
113
+ stdin: process.stdin,
114
+ fs: realFileSystem(),
115
+ fetch: globalThis.fetch.bind(globalThis),
116
+ now: () => new Date(),
117
+ sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
118
+ randomUUID,
119
+ homeDir: homedir(),
120
+ platform: process.platform,
121
+ cwd: process.cwd(),
122
+ nodeVersion: process.versions.node,
123
+ // `dist/` and `src/` are both one folder below the package root (the same fact `version.ts` relies on).
124
+ packageFolder: resolve(fileURLToPath(new URL('../', import.meta.url))),
125
+ confirm: terminal
126
+ ? async (question, options) => /^y(es)?$/i.test((await askOnTerminal(`${question} [y/N] `, {
127
+ timeoutMilliseconds: options?.timeoutMilliseconds
128
+ })).trim())
129
+ : undefined,
130
+ promptSecret: terminal ? (question) => askOnTerminal(question, { hidden: true }) : undefined,
131
+ runProgram: (command, args, options) => new Promise((resolveExitCode) => {
132
+ const child = spawn(command, [...args], {
133
+ stdio: options?.showOutput === false ? ['ignore', 'ignore', 'inherit'] : 'inherit',
134
+ // npm is `npm.cmd` on Windows, which Node starts only through a shell. What is run there is fixed
135
+ // words and a version that was checked to be digits and dots, so nothing a server sent reaches a shell.
136
+ shell: process.platform === 'win32'
137
+ });
138
+ child.once('error', () => resolveExitCode(null));
139
+ child.once('close', (code) => resolveExitCode(code));
140
+ })
141
+ };
142
+ }
143
+ /** Reads a stream to its end as UTF-8 text (a document given on stdin with `-`). */
144
+ export async function readAll(stream) {
145
+ const chunks = [];
146
+ for await (const chunk of stream) {
147
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk));
148
+ }
149
+ return Buffer.concat(chunks).toString('utf8');
150
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Exit codes and the error every command ends in.
3
+ *
4
+ * An agent that runs `widgetic` reads the exit code before it reads anything else, so the table is the contract:
5
+ *
6
+ * 0 ok
7
+ * 1 anything else (a bug, a 5xx that is not "unavailable", a build that did not finish in time)
8
+ * 2 usage or validation: the command line, a flag value or a JSON document is wrong; nothing was sent
9
+ * 3 authentication or plan refusal: no key, 401, 402, 403 (a key without the scope, a plan without API access)
10
+ * 4 not found (404, 410)
11
+ * 5 rejected by the server: 400, 409, 422 and the other 4xx answers; an MCP tool result with `isError`
12
+ * 6 rate limited or unavailable: 429, 502, 503, 504, a timeout or a network that cannot be reached (honours Retry-After)
13
+ * 7 this CLI is too old: 426 (the gateway's SDK version gate); the error carries the upgrade command
14
+ */
15
+ export const EXIT_CODES = {
16
+ ok: 0,
17
+ failure: 1,
18
+ usage: 2,
19
+ auth: 3,
20
+ notFound: 4,
21
+ rejected: 5,
22
+ unavailable: 6,
23
+ upgrade: 7
24
+ };
25
+ export class CliError extends Error {
26
+ code;
27
+ exitCode;
28
+ status;
29
+ requestId;
30
+ retryAfter;
31
+ details;
32
+ hint;
33
+ constructor(init) {
34
+ super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
35
+ this.name = 'CliError';
36
+ this.code = init.code;
37
+ this.exitCode = init.exitCode;
38
+ this.status = init.status;
39
+ this.requestId = init.requestId;
40
+ this.retryAfter = init.retryAfter;
41
+ this.details = init.details;
42
+ this.hint = init.hint;
43
+ }
44
+ }
45
+ /** The command line, a flag value or a document is wrong. Nothing was sent. */
46
+ export function usageError(message, hint) {
47
+ return new CliError({ code: 'USAGE_ERROR', message, exitCode: EXIT_CODES.usage, hint });
48
+ }
49
+ /** A document the user gave does not match the contract's schema. Nothing was sent. */
50
+ export function validationError(message, details, hint) {
51
+ return new CliError({ code: 'VALIDATION_ERROR', message, exitCode: EXIT_CODES.usage, details, hint });
52
+ }
53
+ /** The exit code of an HTTP answer that was not a success. */
54
+ export function exitCodeForStatus(status) {
55
+ if (status === 401 || status === 402 || status === 403)
56
+ return EXIT_CODES.auth;
57
+ if (status === 404 || status === 410)
58
+ return EXIT_CODES.notFound;
59
+ if (status === 426)
60
+ return EXIT_CODES.upgrade;
61
+ if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
62
+ return EXIT_CODES.unavailable;
63
+ }
64
+ if (status >= 400 && status < 500)
65
+ return EXIT_CODES.rejected;
66
+ return EXIT_CODES.failure;
67
+ }