@ahrzb/personal-mcp-cli 0.1.0
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/dist/pmcp.mjs +14 -0
- package/dist/src/commands.mjs +98 -0
- package/dist/src/config.mjs +197 -0
- package/dist/src/errors.mjs +173 -0
- package/dist/src/main.mjs +2300 -0
- package/dist/src/plan.mjs +828 -0
- package/dist/src/render.mjs +215 -0
- package/package.json +34 -0
package/dist/pmcp.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli/pmcp.mts — the executable face of cli/src/main.ts, nothing else lives here.
|
|
3
|
+
// Run through the repo script: pnpm pmcp <command …> (flags that collide with
|
|
4
|
+
// pnpm's own go after a `--`: pnpm pmcp -- apply --yes), or without a clone via
|
|
5
|
+
// the root package's bin: npx github:ahrzb/personal-mcps <command …> (needs a
|
|
6
|
+
// Node whose type stripping is on by default, ≥22.18 / ≥23.6 — the shebang runs
|
|
7
|
+
// this .mts file as-is).
|
|
8
|
+
import { main } from "./src/main.mjs";
|
|
9
|
+
|
|
10
|
+
// `exitCode`, not `exit()`: process.exit() tears the loop down mid-flight, and on Windows
|
|
11
|
+
// Node ≤24.19 that aborts with a libuv assertion (src\win\async.c) once two or more fetch()
|
|
12
|
+
// calls have run — nodejs/node#56645, fixed upstream only in v26.7.0. Letting the loop drain
|
|
13
|
+
// costs ~1ms here; nothing in the CLI holds a handle open past the last await.
|
|
14
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/src/commands.ts — §10's command surface as DATA: every subcommand and what it
|
|
3
|
+
* fronts. main.ts dispatches through these names; `server/test/worker/contracts.test.ts`
|
|
4
|
+
* reads the table as the left-hand side of parity direction D (§4, §8).
|
|
5
|
+
*
|
|
6
|
+
* It is a module of its own rather than a `const` in main.ts because the parity suite is a
|
|
7
|
+
* reader of DATA, not of the CLI: main.ts reaches for node:fs and node:os to read the
|
|
8
|
+
* config file, and the suite that checks the mapping has no business loading that (it runs
|
|
9
|
+
* inside workerd, where those are a compatibility shim rather than a filesystem). Nothing
|
|
10
|
+
* here imports anything. main.ts re-exports the table, so a CLI consumer still finds it
|
|
11
|
+
* where the command surface lives.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One row of the CLI's command table: an argv spelling and what it fronts.
|
|
16
|
+
*
|
|
17
|
+
* `ops` is the §8 admin ops the subcommand actually calls — the left-hand side of parity
|
|
18
|
+
* direction D. `method` is for the two commands that front the GATEWAY rather than an
|
|
19
|
+
* admin op: `pmcp tools` and `pmcp call` are the MCP tool surface itself (any agent
|
|
20
|
+
* holding the same token calls tools/list and tools/call directly), so they are not a CLI
|
|
21
|
+
* capability that needs a tool of its own — §8's exception list does not mention them
|
|
22
|
+
* because they are not an exception to it. `exception` names §8's pinned parity
|
|
23
|
+
* exceptions — the auth/credential family, the upstream-OAuth consent redirect, and
|
|
24
|
+
* `/audit`'s JSONL export — so a name that fronts no op of its own is always explicitly
|
|
25
|
+
* accounted for rather than skipped.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every subcommand of §10's surface, as data. A table rather than a switch so the mapping
|
|
36
|
+
* is inspectable from outside the CLI; main.ts dispatches through the same names.
|
|
37
|
+
*/
|
|
38
|
+
export const COMMANDS = [
|
|
39
|
+
{ name: "login", ops: [], exception: "auth" },
|
|
40
|
+
{ name: "logout", ops: [], exception: "auth" },
|
|
41
|
+
{ name: "whoami", ops: [], exception: "auth" },
|
|
42
|
+
{ name: "ls", ops: ["service_list"] },
|
|
43
|
+
{ name: "tools", ops: [], method: "tools/list" },
|
|
44
|
+
{ name: "call", ops: [], method: "tools/call" },
|
|
45
|
+
// §20.6 (added 2026-08-26): gateway sugar of exactly the same kind as the two rows above
|
|
46
|
+
// — they front an MCP method on the scoped endpoint, not an admin op, so they sit outside
|
|
47
|
+
// §8's parity list rather than inside it (§10's amendment note).
|
|
48
|
+
{ name: "prompts", ops: [], method: "prompts/list" },
|
|
49
|
+
{ name: "prompt", ops: [], method: "prompts/get" },
|
|
50
|
+
{ name: "resources", ops: [], method: "resources/list" },
|
|
51
|
+
{ name: "read", ops: [], method: "resources/read" },
|
|
52
|
+
// A tunneled service is unusable without its token, so §6's lifecycle makes this create
|
|
53
|
+
// two calls rather than one.
|
|
54
|
+
{ name: "service create", ops: ["service_create", "token_issue"] },
|
|
55
|
+
{ name: "service archive", ops: ["service_archive"] },
|
|
56
|
+
{ name: "service unarchive", ops: ["service_unarchive"] },
|
|
57
|
+
{ name: "service delete", ops: ["service_delete"] },
|
|
58
|
+
{ name: "service disconnect", ops: ["service_disconnect"] },
|
|
59
|
+
{ name: "service set-auth", ops: ["service_set_upstream_auth"] },
|
|
60
|
+
{ name: "account list", ops: ["account_list"] },
|
|
61
|
+
{ name: "account create", ops: ["account_create"] },
|
|
62
|
+
{ name: "account delete", ops: ["account_delete"] },
|
|
63
|
+
{ name: "approvals", ops: ["approval_list"] },
|
|
64
|
+
{ name: "approve", ops: ["approval_decide"] },
|
|
65
|
+
{ name: "reject", ops: ["approval_decide"] },
|
|
66
|
+
{ name: "token issue", ops: ["token_issue"] },
|
|
67
|
+
{ name: "token list", ops: ["token_list"] },
|
|
68
|
+
{ name: "token revoke", ops: ["token_revoke"] },
|
|
69
|
+
{ name: "audit", ops: ["audit_query"] },
|
|
70
|
+
// A serialization of the same query, not a new capability (§8's third pinned exception).
|
|
71
|
+
{ name: "audit --export jsonl", ops: ["audit_query"], exception: "jsonl-export" },
|
|
72
|
+
// The consent REDIRECT is browser-only (§8); the command itself only checks the slug
|
|
73
|
+
// and prints the URL.
|
|
74
|
+
{ name: "connect", ops: ["service_get"], exception: "oauth-consent" },
|
|
75
|
+
// §19/§8: the OAuth clients connected to this namespace (inbound OAuth, distinct from
|
|
76
|
+
// `connect`'s outbound upstream flow above). No exception here — unlike the consent
|
|
77
|
+
// SCREEN it manages, this pair is grants-shaped and fronts a real op each.
|
|
78
|
+
{ name: "connections", ops: ["connection_list"] },
|
|
79
|
+
{ name: "connection revoke", ops: ["connection_revoke"] },
|
|
80
|
+
{ name: "diff", ops: ["service_list", "account_list"] },
|
|
81
|
+
{
|
|
82
|
+
name: "apply",
|
|
83
|
+
// The planner's whole vocabulary plus the two reads it plans against — `apply` is the
|
|
84
|
+
// only front for service_update and grant_set (§9: grants are declarative).
|
|
85
|
+
ops: [
|
|
86
|
+
"service_list",
|
|
87
|
+
"account_list",
|
|
88
|
+
"service_create",
|
|
89
|
+
"service_update",
|
|
90
|
+
"service_delete",
|
|
91
|
+
"service_archive",
|
|
92
|
+
"service_unarchive",
|
|
93
|
+
"account_create",
|
|
94
|
+
"account_delete",
|
|
95
|
+
"grant_set",
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/src/config.ts — §10's profile store: where a hub identity lives between invocations,
|
|
3
|
+
* and which profile is ACTIVE for a given invocation.
|
|
4
|
+
*
|
|
5
|
+
* Parses and emits `~/.config/pmcp/config.toml` via smol-toml (§4 dependency), behind a
|
|
6
|
+
* wrapper whose contract is pinned by the implementation plan and cli/test/config-store.test.ts:
|
|
7
|
+
*
|
|
8
|
+
* - A parse error never lets smol-toml's own message escape: every line of this file is a
|
|
9
|
+
* live credential, and TomlError's message embeds the offending line's text. Only the
|
|
10
|
+
* line NUMBER survives, in a message this module composes itself.
|
|
11
|
+
* - Unknown keys — top-level or inside a `[profiles.<name>]` table — survive a
|
|
12
|
+
* parse→emit round trip; this module never drops a hand-written key it does not
|
|
13
|
+
* recognize (`bootstrap_secret` is the standing example, §12).
|
|
14
|
+
* - Profiles are `Record<string, string>`. A non-string TOML value (an unquoted number,
|
|
15
|
+
* bool, date, array, inline table) is stringified rather than rejected, so a file with
|
|
16
|
+
* one odd hand-edited line still loads instead of failing the whole config.
|
|
17
|
+
*
|
|
18
|
+
* `configPath`/`readConfig`/`writeConfig`/`activeProfile`/`profileOf`/`applyProfile` are
|
|
19
|
+
* ports of the functions of the same name in `main.ts` (~lines 150-250) — same behavior,
|
|
20
|
+
* same precedence, same 0600 write. `resolveActiveProfile` is new: it names WHERE the active
|
|
21
|
+
* profile came from, for `profile list --json`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { parse as parseSmolToml, stringify as stringifySmolToml, TomlError } from "smol-toml";
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/** One hub identity: `url`, `token`, and whatever else the operator wrote beside them. */
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The config file as data: the top-level `profile` key (which profile is active when
|
|
35
|
+
* nothing else selects one), the named profiles, and any other top-level key the file
|
|
36
|
+
* carried — kept as `unknown` because this module never interprets one.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/** Where profile-list resolved its `name` from, most-specific first. */
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/** The key order emitConfig writes inside a profile; everything else follows, sorted. */
|
|
48
|
+
const PROFILE_KEYS = ["url", "token", "bootstrap_secret"];
|
|
49
|
+
|
|
50
|
+
/** Where the profiles live between invocations (§10). */
|
|
51
|
+
export function configPath() {
|
|
52
|
+
return join(homedir(), ".config", "pmcp", "config.toml");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The file as it was before profiles: one flat `{ url, token }`. Read once as profile
|
|
57
|
+
* `default` and superseded by the next write (§10) — never rewritten, never deleted, so a
|
|
58
|
+
* downgrade still finds the session it left behind.
|
|
59
|
+
*/
|
|
60
|
+
export function legacyConfigPath() {
|
|
61
|
+
return join(homedir(), ".config", "pmcp", "config.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Text → shape, via smol-toml, with the pinned parse-error contract: a `TomlError`'s own
|
|
66
|
+
* message (which embeds the offending line's text — a live credential) never escapes.
|
|
67
|
+
*/
|
|
68
|
+
export function parseConfig(text ) {
|
|
69
|
+
let raw ;
|
|
70
|
+
try {
|
|
71
|
+
raw = parseSmolToml(text);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof TomlError) throw new Error(`config: line ${err.line} is not valid TOML`);
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
const config = { profiles: {} };
|
|
77
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
78
|
+
if (key !== "profiles") config[key] = toStringValue(value);
|
|
79
|
+
}
|
|
80
|
+
if (isTable(raw.profiles)) {
|
|
81
|
+
for (const [name, profile] of Object.entries(raw.profiles)) {
|
|
82
|
+
if (!isTable(profile)) continue;
|
|
83
|
+
const entry = {};
|
|
84
|
+
for (const [key, value] of Object.entries(profile)) entry[key] = toStringValue(value);
|
|
85
|
+
config.profiles[name] = entry;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return config;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Shape → text, via smol-toml. Known keys first in their declared order, then the rest sorted. */
|
|
92
|
+
export function emitConfig(config ) {
|
|
93
|
+
const table = {};
|
|
94
|
+
for (const key of ordered(Object.keys(config).filter((k) => k !== "profiles" && typeof config[k] === "string"), ["profile"])) {
|
|
95
|
+
table[key] = config[key] ;
|
|
96
|
+
}
|
|
97
|
+
const profiles = {};
|
|
98
|
+
for (const name of Object.keys(config.profiles).sort()) {
|
|
99
|
+
const profile = config.profiles[name];
|
|
100
|
+
const entry = {};
|
|
101
|
+
for (const key of ordered(Object.keys(profile), PROFILE_KEYS)) entry[key] = profile[key];
|
|
102
|
+
profiles[name] = entry;
|
|
103
|
+
}
|
|
104
|
+
table.profiles = profiles;
|
|
105
|
+
return stringifySmolToml(table);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readConfig() {
|
|
109
|
+
// A malformed config.toml is NOT swallowed: parseConfig names the line, and the caller
|
|
110
|
+
// prints it. Silently resolving to "not logged in" would send the user to `pmcp login`
|
|
111
|
+
// for a typo three lines up. The legacy json is best-effort — it is on its way out.
|
|
112
|
+
if (existsSync(configPath())) return parseConfig(readFileSync(configPath(), "utf8"));
|
|
113
|
+
if (!existsSync(legacyConfigPath())) return { profiles: {} };
|
|
114
|
+
try {
|
|
115
|
+
const flat = JSON.parse(readFileSync(legacyConfigPath(), "utf8")) ;
|
|
116
|
+
return {
|
|
117
|
+
profile: "default",
|
|
118
|
+
profiles: {
|
|
119
|
+
default: { ...(flat.url === undefined ? {} : { url: flat.url }), ...(flat.token === undefined ? {} : { token: flat.token }) },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
} catch {
|
|
123
|
+
return { profiles: {} };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function writeConfig(config ) {
|
|
128
|
+
const path = configPath();
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
130
|
+
// 0600: the file holds a live session bearer — one per profile.
|
|
131
|
+
writeFileSync(path, emitConfig(config), { mode: 0o600 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Which profile this invocation acts on: the `--profile` flag, else PMCP_PROFILE, else the
|
|
136
|
+
* file's own top-level `profile`, else the name `default` — neutral on purpose, since the
|
|
137
|
+
* CLI's users are not only developers with environments (§10).
|
|
138
|
+
*/
|
|
139
|
+
export function activeProfile(config , flag ) {
|
|
140
|
+
return flag ?? process.env.PMCP_PROFILE ?? config.profile ?? "default";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The active profile's stored values — `{}` when the file has no table by that name. */
|
|
144
|
+
export function profileOf(config , name ) {
|
|
145
|
+
return config.profiles[name] ?? {};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The `pnpm users` bridge, called by scripts/users.mts: consumes a leading
|
|
150
|
+
* `--profile <name>`, fills PMCP_URL and BOOTSTRAP_SECRET from that profile wherever the
|
|
151
|
+
* environment has not already spoken, and returns the rest of argv. It lives here because
|
|
152
|
+
* the precedence lives here — scripts/users.ts stays env-only, which is its tested
|
|
153
|
+
* contract (§12), and gains a config file it never reads.
|
|
154
|
+
*/
|
|
155
|
+
export function applyProfile(argv ) {
|
|
156
|
+
const rest = [...argv];
|
|
157
|
+
const flag = rest[0] === "--profile" ? rest.splice(0, 2)[1] : undefined;
|
|
158
|
+
const config = readConfig();
|
|
159
|
+
const profile = profileOf(config, activeProfile(config, flag));
|
|
160
|
+
for (const [variable, key] of [["PMCP_URL", "url"], ["BOOTSTRAP_SECRET", "bootstrap_secret"]] ) {
|
|
161
|
+
// The environment wins where it is already set; an empty value is not set (users.ts
|
|
162
|
+
// reads it the same way), and `undefined` is never assigned — process.env would spell
|
|
163
|
+
// it as the string "undefined".
|
|
164
|
+
if ((process.env[variable] ?? "") === "" && (profile[key] ?? "") !== "") process.env[variable] = profile[key];
|
|
165
|
+
}
|
|
166
|
+
return rest;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Which profile a command resolves to, and WHERE that name came from — the same precedence
|
|
171
|
+
* as `activeProfile`, exposed for `profile list --json` (source ∈ flag/env/config/builtin).
|
|
172
|
+
*/
|
|
173
|
+
export function resolveActiveProfile(flagValue ) {
|
|
174
|
+
if (flagValue !== undefined) return { name: flagValue, source: "flag" };
|
|
175
|
+
if (process.env.PMCP_PROFILE !== undefined) return { name: process.env.PMCP_PROFILE, source: "env" };
|
|
176
|
+
const config = readConfig();
|
|
177
|
+
if (config.profile !== undefined) return { name: config.profile, source: "config" };
|
|
178
|
+
return { name: "default", source: "builtin" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Known keys in their declared order, then everything else alphabetically. */
|
|
182
|
+
function ordered(keys , known ) {
|
|
183
|
+
const rank = (key ) => (known.indexOf(key) === -1 ? known.length : known.indexOf(key));
|
|
184
|
+
return [...keys].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isTable(value ) {
|
|
188
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Non-string TOML values (numbers, booleans, dates, arrays, inline tables) are stringified. */
|
|
192
|
+
function toStringValue(value ) {
|
|
193
|
+
if (typeof value === "string") return value;
|
|
194
|
+
if (value instanceof Date) return value.toISOString();
|
|
195
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
196
|
+
return String(value);
|
|
197
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/src/errors.ts — the frozen error contract (§10 "Errors"): one error type, one
|
|
3
|
+
* renderer, one did-you-mean helper. The code vocabulary below is FROZEN — prose may be
|
|
4
|
+
* reworded, a code may never be renamed, and the human/JSON grammar `emitError` produces
|
|
5
|
+
* is what every agent driving this CLI parses (§8: column-0 `error:`/`usage:`/`hint:`
|
|
6
|
+
* prefixes, never line counts).
|
|
7
|
+
*
|
|
8
|
+
* deps: none
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** §10's stable snake_case code vocabulary. `usage` is the sole exit-2 code; every other
|
|
12
|
+
* code exits 1 (a well-formed command that fails at runtime or on the wire, never a
|
|
13
|
+
* malformed one). The vocabulary may grow; these nine may never be renamed. */
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/** Extra fields the JSON error document may carry, per code (§10): a not_found/invalid_arguments
|
|
31
|
+
* suggestion, and the tool's expected-arguments schema rendered from `inputSchema`. */
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The one error type every command throws to report a stable, agent-parseable failure.
|
|
51
|
+
* `exitCode` is derived from `code` at construction — `usage` is 2, everything else 1
|
|
52
|
+
* (§10) — so a caller never has to keep the two in sync by hand.
|
|
53
|
+
*/
|
|
54
|
+
export class CliError extends Error {
|
|
55
|
+
code ;
|
|
56
|
+
exitCode ;
|
|
57
|
+
usage ;
|
|
58
|
+
hints ;
|
|
59
|
+
detail ;
|
|
60
|
+
extra ;
|
|
61
|
+
|
|
62
|
+
constructor(code , message , opts = {}) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "CliError";
|
|
65
|
+
this.code = code;
|
|
66
|
+
this.exitCode = code === "usage" ? 2 : 1;
|
|
67
|
+
this.usage = opts.usage;
|
|
68
|
+
this.hints = opts.hints ?? [];
|
|
69
|
+
this.detail = opts.detail ?? [];
|
|
70
|
+
this.extra = opts.extra;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Any thrown value, normalized to a CliError: a CliError passes through unchanged; an
|
|
75
|
+
* unexpected Error (a hub failure that never got its own code, a network exception) becomes
|
|
76
|
+
* a bare `remote_error` carrying its message — never a stack trace, never `undefined`. */
|
|
77
|
+
function toCliError(err ) {
|
|
78
|
+
if (err instanceof CliError) return err;
|
|
79
|
+
if (err instanceof Error) return new CliError("remote_error", err.message);
|
|
80
|
+
return new CliError("remote_error", String(err));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Indents every line of `text` by `spaces` — a detail entry may already be a multi-line
|
|
84
|
+
* block (an arguments table under a did-you-mean line) whose own relative indentation
|
|
85
|
+
* must survive being nested one level deeper under the error line. */
|
|
86
|
+
function indentBlock(text , spaces ) {
|
|
87
|
+
const pad = " ".repeat(spaces);
|
|
88
|
+
return text
|
|
89
|
+
.split("\n")
|
|
90
|
+
.map((line) => pad + line)
|
|
91
|
+
.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Renders `err` to `opts.stream` per §10's grammar and returns the process exit code, so a
|
|
103
|
+
* caller does one `process.exitCode = emitError(err, { json, stream: process.stderr })`.
|
|
104
|
+
*
|
|
105
|
+
* Human form: `error: <code>: <message>`, then each `detail` entry indented 2 spaces
|
|
106
|
+
* (column-0 is reserved for `error:`/`usage:`/`hint:` — every other line is human detail
|
|
107
|
+
* attached to the line above it, §8), then one `usage: …` line if present, then one
|
|
108
|
+
* `hint: …` line per hint, in order.
|
|
109
|
+
*
|
|
110
|
+
* JSON form: one `{"error":{code,message,hint?,didYouMean?,expectedArguments?}}` document —
|
|
111
|
+
* `hint` is the first of `hints` (the JSON reader gets the single actionable next step, not
|
|
112
|
+
* the human-only elaboration `detail` carries); `didYouMean`/`expectedArguments` ride
|
|
113
|
+
* straight from `extra` when present.
|
|
114
|
+
*/
|
|
115
|
+
export function emitError(err , opts ) {
|
|
116
|
+
const cli = toCliError(err);
|
|
117
|
+
if (opts.json) {
|
|
118
|
+
const doc = { code: cli.code, message: cli.message };
|
|
119
|
+
if (cli.hints[0] !== undefined) doc.hint = cli.hints[0];
|
|
120
|
+
if (cli.extra?.didYouMean !== undefined) doc.didYouMean = cli.extra.didYouMean;
|
|
121
|
+
if (cli.extra?.expectedArguments !== undefined) doc.expectedArguments = cli.extra.expectedArguments;
|
|
122
|
+
opts.stream.write(`${JSON.stringify({ error: doc })}\n`);
|
|
123
|
+
return cli.exitCode;
|
|
124
|
+
}
|
|
125
|
+
const lines = [`error: ${cli.code}: ${cli.message}`];
|
|
126
|
+
for (const entry of cli.detail) lines.push(indentBlock(entry, 2));
|
|
127
|
+
if (cli.usage !== undefined) lines.push(`usage: ${cli.usage}`);
|
|
128
|
+
for (const hint of cli.hints) lines.push(`hint: ${hint}`);
|
|
129
|
+
opts.stream.write(`${lines.join("\n")}\n`);
|
|
130
|
+
return cli.exitCode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The did-you-mean behind every `not_found` / `invalid_arguments` enrichment (§10). Two
|
|
135
|
+
* rules, prefix first: a candidate the input is a PREFIX of wins outright (shortest such
|
|
136
|
+
* candidate), because prefix-shaped exploration is how a human or an agent probes a catalog
|
|
137
|
+
* — `describe service/mcp-tools/paper` means `paper_fetch`, at edit distance 6, which no
|
|
138
|
+
* distance rule would ever suggest. Otherwise the closest candidate within edit distance ≤2,
|
|
139
|
+
* or `undefined`: a distance-3+ guess is worse than no suggestion. The prefix rule needs
|
|
140
|
+
* three characters, so a two-letter typo like `ur` stays with the distance rule that turns
|
|
141
|
+
* it into `url` rather than matching some unrelated `ur…`.
|
|
142
|
+
*/
|
|
143
|
+
export function didYouMean(input , candidates ) {
|
|
144
|
+
if (input.length >= 3) {
|
|
145
|
+
const prefixed = candidates.filter((candidate) => candidate !== input && candidate.startsWith(input));
|
|
146
|
+
if (prefixed.length > 0) return prefixed.reduce((a, b) => (b.length < a.length ? b : a));
|
|
147
|
+
}
|
|
148
|
+
let best ;
|
|
149
|
+
let bestDistance = Infinity;
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
const distance = levenshtein(input, candidate);
|
|
152
|
+
if (distance < bestDistance) {
|
|
153
|
+
bestDistance = distance;
|
|
154
|
+
best = candidate;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return bestDistance <= 2 ? best : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Classic single-row space-optimized edit distance — no dependency earns its keep for this. */
|
|
161
|
+
function levenshtein(a , b ) {
|
|
162
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
163
|
+
for (let i = 1; i <= a.length; i++) {
|
|
164
|
+
let diagonal = row[0];
|
|
165
|
+
row[0] = i;
|
|
166
|
+
for (let j = 1; j <= b.length; j++) {
|
|
167
|
+
const above = row[j];
|
|
168
|
+
row[j] = a[i - 1] === b[j - 1] ? diagonal : 1 + Math.min(diagonal, row[j - 1], above);
|
|
169
|
+
diagonal = above;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return row[b.length];
|
|
173
|
+
}
|