@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 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
+ }