@piagent/platform 1.1.9 → 1.2.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 (81) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/README.md +2 -2
  3. package/docs/command-reference-vietnamese.md +19 -5
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +154 -2
  6. package/docs/model-options.md +10 -8
  7. package/docs/operator-manual-vietnamese.md +25 -13
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/quickstart-vietnamese.md +2 -2
  10. package/docs/release-install-policy.md +6 -6
  11. package/docs/security-threat-model.md +4 -3
  12. package/docs/team-onboarding.md +4 -4
  13. package/docs/usage-observability.md +29 -0
  14. package/docs/vercel-docs-site.md +34 -4
  15. package/docs-site/architecture.html +152 -0
  16. package/docs-site/assets/docs.css +1001 -0
  17. package/docs-site/assets/docs.js +84 -0
  18. package/docs-site/commands.html +203 -0
  19. package/docs-site/content/architecture.html +29 -0
  20. package/docs-site/content/commands.html +80 -0
  21. package/docs-site/content/index.html +80 -0
  22. package/docs-site/content/mcp-approval.html +133 -0
  23. package/docs-site/content/mcp-auth.html +76 -0
  24. package/docs-site/content/mcp-servers.html +111 -0
  25. package/docs-site/content/mcp.html +120 -0
  26. package/docs-site/content/permissions.html +54 -0
  27. package/docs-site/content/profiles.html +68 -0
  28. package/docs-site/content/quickstart.html +144 -0
  29. package/docs-site/content/runtime.html +45 -0
  30. package/docs-site/content/scope.html +71 -0
  31. package/docs-site/content/security.html +51 -0
  32. package/docs-site/content/team.html +23 -0
  33. package/docs-site/content/workflows.html +47 -0
  34. package/docs-site/index.html +131 -1534
  35. package/docs-site/mcp-approval.html +256 -0
  36. package/docs-site/mcp-auth.html +199 -0
  37. package/docs-site/mcp-servers.html +234 -0
  38. package/docs-site/mcp.html +243 -0
  39. package/docs-site/permissions.html +177 -0
  40. package/docs-site/profiles.html +191 -0
  41. package/docs-site/quickstart.html +267 -0
  42. package/docs-site/runtime.html +168 -0
  43. package/docs-site/scope.html +194 -0
  44. package/docs-site/security.html +174 -0
  45. package/docs-site/team.html +142 -0
  46. package/docs-site/workflows.html +170 -0
  47. package/package.json +4 -2
  48. package/packages/piagent-core/capabilities/capability-core.js +58 -5
  49. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  50. package/packages/piagent-core/extensions/document-intake.ts +5 -5
  51. package/packages/piagent-core/extensions/piagent-guard.ts +432 -2
  52. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  53. package/packages/piagent-core/mcp/mcp-approval-store.js +216 -0
  54. package/packages/piagent-core/mcp/mcp-auth-readiness.js +225 -0
  55. package/packages/piagent-core/mcp/mcp-command-actions.js +400 -0
  56. package/packages/piagent-core/mcp/mcp-config-layers.js +466 -0
  57. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  58. package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
  59. package/packages/piagent-core/mcp/mcp-server-entry.js +293 -0
  60. package/packages/piagent-core/mcp/mcp-session-view.js +236 -0
  61. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  62. package/packages/piagent-core/package.json +7 -3
  63. package/packages/piagent-core/prompts/model-options.md +8 -8
  64. package/scripts/build-docs-site.mjs +421 -0
  65. package/scripts/configure-model-scope.sh +21 -5
  66. package/scripts/configure-subagents.sh +18 -5
  67. package/scripts/init-project.sh +15 -8
  68. package/scripts/install-global.sh +5 -5
  69. package/scripts/mcp-manage.mjs +656 -0
  70. package/scripts/pi-model-catalog.sh +11 -1
  71. package/scripts/pi-session-stats.sh +27 -1
  72. package/scripts/pi-usage-history.mjs +567 -0
  73. package/scripts/piagent-cli.mjs +1 -1
  74. package/scripts/preview-docs-site.mjs +88 -0
  75. package/scripts/quality-benchmark.sh +37 -9
  76. package/scripts/setup.sh +23 -15
  77. package/scripts/team-doctor.sh +18 -2
  78. package/scripts/uninstall-global.sh +10 -4
  79. package/scripts/verify-local.sh +65 -19
  80. package/scripts/verify-release-identity.mjs +37 -2
  81. package/scripts/configure-mcp.sh +0 -349
@@ -0,0 +1,656 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ import {
7
+ SCOPES,
8
+ REPOSITORY_SCOPES,
9
+ McpConfigError,
10
+ collectServers,
11
+ configPathForScope,
12
+ isRecord,
13
+ mergedServerEntry,
14
+ readMcpConfig,
15
+ writeMcpConfig
16
+ } from "../packages/piagent-core/mcp/mcp-config-layers.js";
17
+ import {
18
+ CATALOG_PRESETS,
19
+ CATALOG_PREREQUISITES,
20
+ CATALOG_SERVERS,
21
+ isCatalogPreset,
22
+ resolvePreset
23
+ } from "../packages/piagent-core/mcp/mcp-server-catalog.js";
24
+ import {
25
+ McpEntryError,
26
+ assertServerName,
27
+ buildServerEntry,
28
+ maskServerEntry
29
+ } from "../packages/piagent-core/mcp/mcp-server-entry.js";
30
+ import {
31
+ approvalState,
32
+ approvalStorePath,
33
+ clearApproval,
34
+ recordApproval
35
+ } from "../packages/piagent-core/mcp/mcp-approval-store.js";
36
+ import { evaluateServerReadiness } from "../packages/piagent-core/mcp/mcp-auth-readiness.js";
37
+ import {
38
+ McpViewError,
39
+ describeServer,
40
+ formatDoctorReport,
41
+ formatServerDetail,
42
+ formatServerTable,
43
+ sessionMcpState,
44
+ usesDocker
45
+ } from "../packages/piagent-core/mcp/mcp-session-view.js";
46
+
47
+ const SUBCOMMANDS = new Set([
48
+ "add", "remove", "get", "list", "enable", "disable", "approve", "reject", "reset", "doctor", "preset"
49
+ ]);
50
+
51
+ function usage() {
52
+ process.stdout.write(`Usage:
53
+ piagent-mcp <command> [options]
54
+ piagent-mcp --preset <preset> [options] seed a pinned server group
55
+
56
+ Commands:
57
+ add <name> --url <url> Add a remote MCP server
58
+ add <name> -- <command> [args] Add a local MCP server
59
+ remove <name> Remove a server from a scope
60
+ get <name> Show one server, with credentials masked
61
+ list Show every server visible here, with state
62
+ enable <name> / disable <name> Turn a server off without losing its config
63
+ approve <name> / reject <name> Decide on a server this repository defines
64
+ reset Forget every decision for this project
65
+ doctor Report what each server still needs
66
+
67
+ Options:
68
+ --scope <global|pi-global|project|pi-project>
69
+ Config layer (default: global; project for approve)
70
+ --project <path> Project path for project scopes (default: cwd)
71
+ --config <path> Explicit config path; overrides --scope/--project
72
+ --env KEY=value Environment entry for a local server (repeatable)
73
+ --header "Name: value" Header for a remote server (repeatable)
74
+ --bearer-token-env-var <VAR> Variable holding the bearer token for a remote server
75
+ --oauth-client-id <id> OAuth client identifier
76
+ --oauth-scopes <a,b> Pin the OAuth scopes requested
77
+ --direct-tools Expose this server's tools directly instead of via the proxy
78
+ --preset <name> Seed a pinned group: ${Object.keys(CATALOG_PRESETS).join(", ")}
79
+ --replace Overwrite existing definitions the preset covers
80
+ --dry-run Print what would be written
81
+ --list Print available presets/servers
82
+ --json Machine-readable output
83
+ -h, --help
84
+
85
+ Credentials are never written into MCP config. Pass a variable reference such as
86
+ --env 'GITHUB_PERSONAL_ACCESS_TOKEN=\${GITHUB_PERSONAL_ACCESS_TOKEN}' and export the
87
+ value in your shell. OAuth sign-in belongs to Pi: run /mcp-auth <name> inside a session.
88
+
89
+ Servers defined by a repository (project and pi-project scopes) are not usable
90
+ until approved here. The decision is stored in your home directory, so a clone
91
+ cannot approve itself, and it is tied to the definition that was approved.
92
+
93
+ Examples:
94
+ piagent-mcp --preset core --scope global --replace
95
+ piagent-mcp add sentry --url https://mcp.sentry.dev/mcp --scope global
96
+ piagent-mcp add internal --scope project -- npx -y @acme/internal-mcp
97
+ piagent-mcp list
98
+ piagent-mcp approve internal
99
+ piagent-mcp doctor
100
+ `);
101
+ }
102
+
103
+ class UsageError extends Error {}
104
+
105
+ /**
106
+ * @param {string[]} argv
107
+ * @returns {{command: string, positionals: string[], flags: Map<string, string|true>, repeated: Map<string, string[]>, passthrough: string[]}}
108
+ */
109
+ function parseArgs(argv) {
110
+ const repeatable = new Set(["--env", "--header"]);
111
+ const valued = new Set([
112
+ "--scope", "--project", "--config", "--preset", "--url", "--bearer-token-env-var",
113
+ "--oauth-client-id", "--oauth-scopes", "--env", "--header"
114
+ ]);
115
+ const boolean = new Set(["--replace", "--dry-run", "--list", "--json", "--direct-tools", "--validate-preset", "--force"]);
116
+
117
+ const flags = new Map();
118
+ const repeated = new Map();
119
+ const positionals = [];
120
+ /** @type {string[]} */
121
+ let passthrough = [];
122
+
123
+ let command = "";
124
+ let index = 0;
125
+ if (argv[0] && !argv[0].startsWith("-")) {
126
+ command = argv[0];
127
+ index = 1;
128
+ if (!SUBCOMMANDS.has(command)) throw new UsageError(`unknown command: ${command}`);
129
+ }
130
+
131
+ for (; index < argv.length; index += 1) {
132
+ const token = argv[index];
133
+ if (token === "--") {
134
+ passthrough = argv.slice(index + 1);
135
+ break;
136
+ }
137
+ if (token === "-h" || token === "--help") {
138
+ flags.set("--help", true);
139
+ continue;
140
+ }
141
+ if (!token.startsWith("-")) {
142
+ positionals.push(token);
143
+ continue;
144
+ }
145
+ if (boolean.has(token)) {
146
+ flags.set(token, true);
147
+ continue;
148
+ }
149
+ if (!valued.has(token)) throw new UsageError(`unknown option: ${token}`);
150
+ const value = argv[index + 1];
151
+ if (value === undefined || value === "--") throw new UsageError(`missing value for ${token}`);
152
+ index += 1;
153
+ if (repeatable.has(token)) {
154
+ repeated.set(token, [...(repeated.get(token) ?? []), value]);
155
+ continue;
156
+ }
157
+ flags.set(token, value);
158
+ }
159
+
160
+ return { command, positionals, flags, repeated, passthrough };
161
+ }
162
+
163
+ /** @param {Map<string, string|true>} flags @param {string} name @returns {string|undefined} */
164
+ function value(flags, name) {
165
+ const raw = flags.get(name);
166
+ return typeof raw === "string" ? raw : undefined;
167
+ }
168
+
169
+ /**
170
+ * @param {Map<string, string|true>} flags
171
+ * @param {string} fallback
172
+ * @returns {string}
173
+ */
174
+ function requireScope(flags, fallback) {
175
+ const scope = value(flags, "--scope") ?? fallback;
176
+ if (!SCOPES.includes(scope)) throw new UsageError(`unsupported scope: ${scope}. Use one of ${SCOPES.join(", ")}.`);
177
+ return scope;
178
+ }
179
+
180
+ /** @param {Map<string, string|true>} flags @returns {string} */
181
+ function projectPath(flags) {
182
+ return path.resolve(value(flags, "--project") ?? process.cwd());
183
+ }
184
+
185
+ /** @param {Map<string, string|true>} flags @param {string} scope @returns {string} */
186
+ function targetConfig(flags, scope) {
187
+ const explicit = value(flags, "--config");
188
+ if (explicit) {
189
+ if (path.basename(explicit) !== "mcp.json" && path.basename(explicit) !== ".mcp.json") {
190
+ throw new UsageError("--config must point at an mcp.json or .mcp.json file");
191
+ }
192
+ return path.resolve(explicit);
193
+ }
194
+ return configPathForScope(scope, { projectPath: projectPath(flags) });
195
+ }
196
+
197
+ /** @param {string} name @param {Map<string, string|true>} flags @returns {{scope: string, file: string, entry: Record<string, unknown>}} */
198
+ function locateServer(name, flags) {
199
+ const explicitScope = value(flags, "--scope");
200
+ const servers = collectServers({ projectPath: projectPath(flags) })
201
+ .filter((server) => server.name === name && (!explicitScope || server.scope === explicitScope));
202
+ if (servers.length === 0) {
203
+ throw new UsageError(
204
+ explicitScope
205
+ ? `no server named ${name} in ${explicitScope} scope`
206
+ : `no server named ${name}. Run \`piagent-mcp list\` to see what is configured.`
207
+ );
208
+ }
209
+ if (servers.length > 1) {
210
+ throw new UsageError(
211
+ `${name} is defined in more than one scope (${servers.map((server) => server.scope).join(", ")}). ` +
212
+ "Pass --scope to say which one."
213
+ );
214
+ }
215
+ return servers[0];
216
+ }
217
+
218
+ /**
219
+ * A server this command has no file of its own to edit: it was read out of
220
+ * another tool's config through `imports`. `located.file` is that other tool's
221
+ * config, and writing it back through `writeMcpConfig` would rewrite somebody's
222
+ * Cursor or VS Code file in this platform's format -- adding a `settings` block
223
+ * it does not use and dropping every key this shape does not carry.
224
+ *
225
+ * @param {{name: string, origin?: string, scope: string, file: string}} located
226
+ * @param {string} action
227
+ */
228
+ function assertOwnedByThisTool(located, action) {
229
+ if (!located.origin?.startsWith("import:")) return;
230
+ const kind = located.origin.slice("import:".length);
231
+ throw new UsageError(
232
+ `${located.name} is not defined by any config this command owns. The ${located.scope} config imports ${kind}, ` +
233
+ `and the definition lives in ${located.file}. Edit that file to ${action} it, or drop "${kind}" from its imports list.`
234
+ );
235
+ }
236
+
237
+ // --- commands ---------------------------------------------------------------
238
+
239
+ /** @param {ReturnType<typeof parseArgs>} args */
240
+ function commandAdd(args) {
241
+ const name = args.positionals[0];
242
+ if (!name) throw new UsageError("add needs a server name");
243
+ const scope = requireScope(args.flags, "global");
244
+ const entry = buildServerEntry({
245
+ name,
246
+ url: value(args.flags, "--url") ?? undefined,
247
+ command: args.passthrough.length > 0 ? args.passthrough : undefined,
248
+ env: args.repeated.get("--env") ?? [],
249
+ header: args.repeated.get("--header") ?? [],
250
+ bearerTokenEnvVar: value(args.flags, "--bearer-token-env-var"),
251
+ oauthClientId: value(args.flags, "--oauth-client-id"),
252
+ oauthScopes: value(args.flags, "--oauth-scopes"),
253
+ directTools: args.flags.get("--direct-tools") === true
254
+ });
255
+
256
+ const file = targetConfig(args.flags, scope);
257
+ const config = readMcpConfig(file);
258
+ const existed = Object.hasOwn(config.mcpServers, name);
259
+ if (existed && args.flags.get("--replace") !== true) {
260
+ throw new UsageError(`${name} already exists in ${scope} scope. Pass --replace to overwrite it.`);
261
+ }
262
+ config.mcpServers[name] = entry;
263
+
264
+ if (args.flags.get("--dry-run") === true) {
265
+ process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, name, scope, file, entry: maskServerEntry(entry) }, null, 2)}\n`);
266
+ return 0;
267
+ }
268
+ writeMcpConfig(file, config);
269
+
270
+ // A previous decision described a different server. Dropping it here means the
271
+ // operator is asked again rather than inheriting approval for what was replaced.
272
+ if (existed && REPOSITORY_SCOPES.has(scope)) clearApproval({ projectPath: projectPath(args.flags), name });
273
+
274
+ const transport = typeof entry.url === "string" ? `URL: ${entry.url}` : `command: ${[entry.command, ...(Array.isArray(entry.args) ? entry.args : [])].join(" ")}`;
275
+ process.stdout.write(`${existed ? "Replaced" : "Added"} ${typeof entry.url === "string" ? "remote" : "local"} MCP server ${name} with ${transport} in ${scope} scope\n`);
276
+ process.stdout.write(`File modified: ${file}\n`);
277
+ if (REPOSITORY_SCOPES.has(scope)) {
278
+ process.stdout.write(`This scope is part of the repository, so the server is not usable until approved: piagent-mcp approve ${name}\n`);
279
+ }
280
+ reportMissingEnv(entry);
281
+ return 0;
282
+ }
283
+
284
+ /** @param {Record<string, unknown>} entry */
285
+ function reportMissingEnv(entry) {
286
+ const readiness = evaluateServerReadiness({ name: "", scope: "global", entry }, { projectPath: process.cwd() });
287
+ if (readiness.state === "needs-env") {
288
+ process.stderr.write(`\nStill needed before it can connect: ${readiness.detail}.\n`);
289
+ }
290
+ if (readiness.state === "needs-command") {
291
+ process.stderr.write(`\nStill needed before it can connect: ${readiness.detail}.\n`);
292
+ }
293
+ }
294
+
295
+ /** @param {ReturnType<typeof parseArgs>} args */
296
+ function commandRemove(args) {
297
+ const name = args.positionals[0];
298
+ if (!name) throw new UsageError("remove needs a server name");
299
+ const located = locateServer(name, args.flags);
300
+ assertOwnedByThisTool(located, "remove");
301
+ const config = readMcpConfig(located.file);
302
+ delete config.mcpServers[name];
303
+ writeMcpConfig(located.file, config);
304
+ process.stdout.write(`Removed MCP server ${name} from ${located.scope} scope\n`);
305
+ process.stdout.write(`File modified: ${located.file}\n`);
306
+ // The definition is gone either way; the decision about it is a separate
307
+ // record, and leaving one behind for a name that no longer exists means a
308
+ // later server reusing the name would meet an approval nobody granted it.
309
+ if (!clearApproval({ projectPath: projectPath(args.flags), name })) {
310
+ process.stdout.write(
311
+ `Warning: could not update ${approvalStorePath() ?? "the approval store"}, ` +
312
+ `so the recorded decision for ${name} is still there. Run \`piagent-mcp reset ${name}\` once it is writable.\n`
313
+ );
314
+ }
315
+ return 0;
316
+ }
317
+
318
+ /** @param {ReturnType<typeof parseArgs>} args */
319
+ function commandGet(args) {
320
+ const name = args.positionals[0];
321
+ if (!name) throw new UsageError("get needs a server name");
322
+ const detail = describeServer({
323
+ projectPath: projectPath(args.flags),
324
+ name,
325
+ scope: value(args.flags, "--scope"),
326
+ listHint: "piagent-mcp list"
327
+ });
328
+
329
+ if (args.flags.get("--json") === true) {
330
+ process.stdout.write(`${JSON.stringify({
331
+ name,
332
+ scope: detail.scope,
333
+ file: detail.file,
334
+ state: detail.readiness.state,
335
+ detail: detail.readiness.detail,
336
+ entry: detail.masked
337
+ }, null, 2)}\n`);
338
+ return 0;
339
+ }
340
+
341
+ for (const line of formatServerDetail(detail)) process.stdout.write(`${line}\n`);
342
+ process.stdout.write(` remove: piagent-mcp remove ${name} --scope ${detail.scope}\n`);
343
+ return 0;
344
+ }
345
+
346
+ /** @param {ReturnType<typeof parseArgs>} args */
347
+ function commandList(args) {
348
+ const scopeFilter = value(args.flags, "--scope");
349
+ const project = projectPath(args.flags);
350
+ // Listing nothing is the correct answer only when nothing is reaching the
351
+ // session. A config the gate cannot verify reaches it with an unknown set of
352
+ // servers, and telling somebody to seed a baseline in that state sends them
353
+ // to the wrong file entirely.
354
+ const state = sessionMcpState({ projectPath: project, scopes: scopeFilter ? [scopeFilter] : undefined });
355
+ const { rows } = state;
356
+
357
+ if (args.flags.get("--json") === true) {
358
+ // The same fields the human output carries. A caller scripting against this
359
+ // read `servers: []` as "nothing configured" while every tool call was being
360
+ // refused, and an exit code of 0 agreed with it.
361
+ process.stdout.write(`${JSON.stringify({
362
+ project,
363
+ usable: state.usable,
364
+ blockingConfig: state.blockingConfig,
365
+ servers: rows.map((row) => ({
366
+ name: row.name,
367
+ scope: row.scope,
368
+ origin: row.origin,
369
+ requiresApproval: row.requiresApproval,
370
+ file: row.file,
371
+ state: row.readiness.state,
372
+ detail: row.readiness.detail,
373
+ entry: maskServerEntry(row.entry)
374
+ }))
375
+ }, null, 2)}\n`);
376
+ return state.usable ? 0 : 1;
377
+ }
378
+
379
+ for (const problem of state.blockingConfig) process.stdout.write(`BLOCKED every tool call: ${problem.detail}\n`);
380
+
381
+ if (rows.length === 0) {
382
+ if (state.usable) {
383
+ process.stdout.write("No MCP servers configured.\n");
384
+ process.stdout.write("Seed the pinned baseline with: piagent-mcp --preset core --scope global\n");
385
+ }
386
+ } else {
387
+ process.stdout.write(`${formatServerTable(rows)}\n`);
388
+ if (state.blocked.length > 0) {
389
+ process.stdout.write(`\n${state.blocked.length} server(s) not usable yet. Run \`piagent-mcp doctor\`.\n`);
390
+ }
391
+ }
392
+ // Out of the no-rows branch: the exit code answers "can MCP work here", and
393
+ // that question does not depend on how many rows happened to print.
394
+ return state.usable ? 0 : 1;
395
+ }
396
+
397
+ /** @param {ReturnType<typeof parseArgs>} args @param {boolean} enabled */
398
+ function commandToggle(args, enabled) {
399
+ const name = args.positionals[0];
400
+ if (!name) throw new UsageError(`${enabled ? "enable" : "disable"} needs a server name`);
401
+ const located = locateServer(name, args.flags);
402
+ assertOwnedByThisTool(located, enabled ? "enable" : "disable");
403
+ const config = readMcpConfig(located.file);
404
+ const entry = { ...(isRecord(config.mcpServers[name]) ? config.mcpServers[name] : {}) };
405
+ if (enabled) delete entry.enabled;
406
+ else entry.enabled = false;
407
+ config.mcpServers[name] = entry;
408
+ writeMcpConfig(located.file, config);
409
+ process.stdout.write(`${enabled ? "Enabled" : "Disabled"} MCP server ${name} in ${located.scope} scope\n`);
410
+ process.stdout.write(`File modified: ${located.file}\n`);
411
+ if (!enabled) process.stdout.write("The definition is kept, so `piagent-mcp enable` restores it.\n");
412
+ return 0;
413
+ }
414
+
415
+ /** @param {ReturnType<typeof parseArgs>} args @param {"approved"|"rejected"} decision */
416
+ function commandDecide(args, decision) {
417
+ const name = args.positionals[0];
418
+ if (!name) throw new UsageError(`${decision === "approved" ? "approve" : "reject"} needs a server name`);
419
+ assertServerName(name);
420
+ const project = projectPath(args.flags);
421
+ const servers = collectServers({ projectPath: project, scopes: [...REPOSITORY_SCOPES] })
422
+ .filter((server) => server.name === name);
423
+ if (servers.length === 0) {
424
+ throw new UsageError(
425
+ `${name} is not defined by this repository. Approval only applies to servers in ${[...REPOSITORY_SCOPES].join(" or ")} scope.`
426
+ );
427
+ }
428
+ if (servers.length > 1) throw new UsageError(`${name} is defined in ${servers.map((server) => server.scope).join(" and ")}; remove one.`);
429
+
430
+ const server = servers[0];
431
+ const before = approvalState({ projectPath: project, name, entry: server.entry });
432
+ if (decision === "approved" && before.state === "pending" && args.flags.get("--force") !== true) {
433
+ // Approving without reading it is the failure this gate exists to prevent, so
434
+ // the definition is put in front of the operator before the decision lands.
435
+ // The merged entry, not this layer's fragment: the adapter merges a
436
+ // same-named server key by key across layers, so a repository declaring only
437
+ // `{"args": [...]}` runs with a command it never showed the operator.
438
+ process.stdout.write(`${JSON.stringify(
439
+ maskServerEntry(mergedServerEntry({ projectPath: project, name, entry: server.entry })), null, 2
440
+ )}\n`);
441
+ }
442
+ // A decision that was not written is not a decision. Printing "Approved" while
443
+ // the store rejected the write would leave the operator waiting for a gate that
444
+ // is still blocking, with nothing on screen to explain why.
445
+ if (!recordApproval({ projectPath: project, name, entry: server.entry, decision })) {
446
+ throw new UsageError(`could not write ${approvalStorePath() ?? "the approval store"}; ${name} is still ${before.state}`);
447
+ }
448
+ const after = approvalState({ projectPath: project, name, entry: server.entry });
449
+ process.stdout.write(`${decision === "approved" ? "Approved" : "Rejected"} MCP server ${name} (${server.scope}) for ${project}\n`);
450
+ process.stdout.write(`Pinned to this definition: ${after.digest}\n`);
451
+ process.stdout.write("Changing the server's command, URL or arguments returns it to pending.\n");
452
+ return 0;
453
+ }
454
+
455
+ /** @param {ReturnType<typeof parseArgs>} args */
456
+ function commandReset(args) {
457
+ const project = projectPath(args.flags);
458
+ const name = args.positionals[0];
459
+ if (!clearApproval({ projectPath: project, name })) {
460
+ throw new UsageError(`could not write ${approvalStorePath() ?? "the approval store"}; nothing was forgotten`);
461
+ }
462
+ process.stdout.write(name
463
+ ? `Forgot the decision for ${name} in ${project}\n`
464
+ : `Forgot every MCP approval decision for ${project}\n`);
465
+ return 0;
466
+ }
467
+
468
+ /** @param {ReturnType<typeof parseArgs>} args */
469
+ function commandDoctor(args) {
470
+ const project = projectPath(args.flags);
471
+ // Resolved before the no-servers shortcut below. A layer nobody can parse
472
+ // produces no rows, so reporting "no servers configured" and stopping would
473
+ // answer the question exactly backwards. The guard refuses every tool call
474
+ // under `blockingConfig`, and this is where somebody comes to find out why.
475
+ const state = sessionMcpState({ projectPath: project });
476
+ const { rows, unreadable: broken, imports, blockingConfig: unverifiable } = state;
477
+
478
+ // The one check too slow for a session start: a daemon that is installed but
479
+ // not running looks exactly like one that is running until something asks it.
480
+ let dockerRunning;
481
+ if (usesDocker(rows)) {
482
+ const probe = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
483
+ dockerRunning = probe.status === 0;
484
+ }
485
+
486
+ if (args.flags.get("--json") === true) {
487
+ process.stdout.write(`${JSON.stringify({
488
+ project,
489
+ dockerRunning,
490
+ servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail, remedy: row.readiness.remedy })),
491
+ unreadableLayers: broken,
492
+ repositoryImports: imports.map((layer) => ({ scope: layer.scope, kinds: layer.kinds })),
493
+ unverifiableConfig: unverifiable
494
+ }, null, 2)}\n`);
495
+ return state.blocked.length > 0 || broken.length > 0 || unverifiable.length > 0 ? 1 : 0;
496
+ }
497
+
498
+ for (const layer of broken) process.stdout.write(`Unreadable ${layer.scope} config: ${layer.detail}\n`);
499
+ for (const problem of unverifiable) process.stdout.write(`BLOCKED every tool call: ${problem.detail}\n`);
500
+
501
+ if (rows.length === 0) {
502
+ if (unverifiable.length > 0 || broken.length > 0) {
503
+ process.stdout.write(`\n${unverifiable.length + broken.length} MCP config problem(s) need attention.\n`);
504
+ return 1;
505
+ }
506
+ process.stdout.write("No MCP servers configured.\n");
507
+ return 0;
508
+ }
509
+
510
+ const report = formatDoctorReport(rows, { dockerRunning, rerun: "piagent-mcp doctor" });
511
+ for (const line of report.lines) process.stdout.write(`${line}\n`);
512
+
513
+ // A layer nothing can parse hides whatever it defines and reads as empty, so
514
+ // it is counted (and already printed above). An import is named but not
515
+ // counted: the servers it brings in are in the report above with their own
516
+ // state, and this line says where they came from, which reading the
517
+ // repository's own config would not reveal.
518
+ for (const layer of imports) {
519
+ process.stdout.write(
520
+ `${layer.file} imports servers from ${layer.kinds.join(", ")} config; ` +
521
+ "they need approval here like any server the repository defines.\n"
522
+ );
523
+ }
524
+
525
+ const problems = report.problems + broken.length + unverifiable.length;
526
+ process.stdout.write(problems === 0
527
+ ? "\nPASS: every configured MCP server can be reached.\n"
528
+ : `\n${problems} MCP problem(s) need attention.\n`);
529
+ return problems === 0 ? 0 : 1;
530
+ }
531
+
532
+ /** @param {ReturnType<typeof parseArgs>} args */
533
+ function commandPreset(args) {
534
+ if (args.flags.get("--list") === true) {
535
+ process.stdout.write(`${JSON.stringify({
536
+ presets: CATALOG_PRESETS,
537
+ servers: Object.fromEntries(Object.entries(CATALOG_SERVERS).map(([name, server]) => [
538
+ name,
539
+ { description: server.description, requires: CATALOG_PREREQUISITES[name] ?? [] }
540
+ ]))
541
+ }, null, 2)}\n`);
542
+ return 0;
543
+ }
544
+
545
+ const presetName = value(args.flags, "--preset") ?? "core";
546
+ if (!presetName) throw new UsageError("--preset cannot be empty");
547
+ if (!isCatalogPreset(presetName)) {
548
+ throw new UsageError(`unknown preset: ${presetName}. Available presets: ${Object.keys(CATALOG_PRESETS).join(", ")}`);
549
+ }
550
+ // Answering the name question and stopping, so a caller can ask before it
551
+ // installs rather than discovering the typo after two packages have landed.
552
+ if (args.flags.get("--validate-preset") === true) {
553
+ process.stdout.write(`${presetName}\n`);
554
+ return 0;
555
+ }
556
+
557
+ const scope = requireScope(args.flags, "global");
558
+ const file = targetConfig(args.flags, scope);
559
+ const config = readMcpConfig(file);
560
+ const replace = args.flags.get("--replace") === true;
561
+ const dryRun = args.flags.get("--dry-run") === true;
562
+
563
+ const added = [];
564
+ const kept = [];
565
+ const replaced = [];
566
+ for (const server of resolvePreset(presetName)) {
567
+ if (Object.hasOwn(config.mcpServers, server.name)) {
568
+ if (!replace) {
569
+ kept.push(server.name);
570
+ continue;
571
+ }
572
+ config.mcpServers[server.name] = server.config;
573
+ replaced.push(server.name);
574
+ continue;
575
+ }
576
+ config.mcpServers[server.name] = server.config;
577
+ added.push(server.name);
578
+ }
579
+
580
+ const report = {
581
+ configPath: file,
582
+ preset: presetName,
583
+ dryRun,
584
+ replace,
585
+ added,
586
+ kept,
587
+ replaced,
588
+ serverCount: Object.keys(config.mcpServers).length
589
+ };
590
+
591
+ if (dryRun) {
592
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
593
+ process.stdout.write(`${JSON.stringify({ settings: config.settings, mcpServers: config.mcpServers }, null, 2)}\n`);
594
+ return 0;
595
+ }
596
+
597
+ writeMcpConfig(file, config);
598
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
599
+
600
+ // stdout stays machine-readable; the operator note goes to stderr.
601
+ const lines = resolvePreset(presetName).flatMap((server) => server.requires.map((item) => ` ${server.name}: ${item}`));
602
+ if (lines.length > 0) {
603
+ process.stderr.write("\nServer definitions are written. Each still needs this before it can connect:\n");
604
+ for (const line of lines) process.stderr.write(`${line}\n`);
605
+ process.stderr.write("Servers connect lazily, so nothing above is checked until the first call.\n");
606
+ process.stderr.write("Run `piagent-mcp doctor` to see which ones are ready on this machine.\n");
607
+ }
608
+ return 0;
609
+ }
610
+
611
+ // --- entry ------------------------------------------------------------------
612
+
613
+ function main(argv) {
614
+ let args;
615
+ try {
616
+ args = parseArgs(argv);
617
+ } catch (error) {
618
+ process.stderr.write(`FAIL: ${error instanceof Error ? error.message : String(error)}\n`);
619
+ usage();
620
+ return 2;
621
+ }
622
+
623
+ if (args.flags.get("--help") === true || (!args.command && argv.length === 0)) {
624
+ usage();
625
+ return 0;
626
+ }
627
+
628
+ try {
629
+ switch (args.command || "preset") {
630
+ case "add": return commandAdd(args);
631
+ case "remove": return commandRemove(args);
632
+ case "get": return commandGet(args);
633
+ case "list": return commandList(args);
634
+ case "enable": return commandToggle(args, true);
635
+ case "disable": return commandToggle(args, false);
636
+ case "approve": return commandDecide(args, "approved");
637
+ case "reject": return commandDecide(args, "rejected");
638
+ case "reset": return commandReset(args);
639
+ case "doctor": return commandDoctor(args);
640
+ default: return commandPreset(args);
641
+ }
642
+ } catch (error) {
643
+ if (error instanceof UsageError || error instanceof McpEntryError || error instanceof McpConfigError || error instanceof McpViewError) {
644
+ process.stderr.write(`FAIL: ${error.message}\n`);
645
+ return 2;
646
+ }
647
+ process.stderr.write(`FAIL: ${error instanceof Error ? error.message : String(error)}\n`);
648
+ return 1;
649
+ }
650
+ }
651
+
652
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
653
+ process.exit(main(process.argv.slice(2)));
654
+ }
655
+
656
+ export { main };
@@ -22,6 +22,15 @@ USAGE
22
22
  FORMAT="text"
23
23
  PROVIDER=""
24
24
 
25
+ require_value() {
26
+ local option="$1"
27
+ local value="${2:-}"
28
+ if [[ -z "$value" || "$value" == --* ]]; then
29
+ echo "Missing value for $option" >&2
30
+ exit 2
31
+ fi
32
+ }
33
+
25
34
  while [[ $# -gt 0 ]]; do
26
35
  case "$1" in
27
36
  --json)
@@ -29,7 +38,8 @@ while [[ $# -gt 0 ]]; do
29
38
  shift
30
39
  ;;
31
40
  --provider)
32
- PROVIDER="${2:-}"
41
+ require_value "$1" "${2:-}"
42
+ PROVIDER="$2"
33
43
  shift 2
34
44
  ;;
35
45
  -h|--help)