@piagent/platform 1.1.8 → 1.2.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +2 -2
  3. package/docs/command-reference-vietnamese.md +13 -4
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +108 -2
  6. package/docs/model-options.md +10 -8
  7. package/docs/operator-manual-vietnamese.md +12 -12
  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 +1 -1
  12. package/docs/team-onboarding.md +4 -4
  13. package/docs/vercel-docs-site.md +34 -4
  14. package/docs-site/architecture.html +152 -0
  15. package/docs-site/assets/docs.css +1001 -0
  16. package/docs-site/assets/docs.js +84 -0
  17. package/docs-site/commands.html +203 -0
  18. package/docs-site/content/architecture.html +29 -0
  19. package/docs-site/content/commands.html +80 -0
  20. package/docs-site/content/index.html +80 -0
  21. package/docs-site/content/mcp-approval.html +81 -0
  22. package/docs-site/content/mcp-auth.html +76 -0
  23. package/docs-site/content/mcp-servers.html +111 -0
  24. package/docs-site/content/mcp.html +120 -0
  25. package/docs-site/content/permissions.html +54 -0
  26. package/docs-site/content/profiles.html +68 -0
  27. package/docs-site/content/quickstart.html +144 -0
  28. package/docs-site/content/runtime.html +45 -0
  29. package/docs-site/content/scope.html +71 -0
  30. package/docs-site/content/security.html +51 -0
  31. package/docs-site/content/team.html +23 -0
  32. package/docs-site/content/workflows.html +47 -0
  33. package/docs-site/index.html +131 -1534
  34. package/docs-site/mcp-approval.html +204 -0
  35. package/docs-site/mcp-auth.html +199 -0
  36. package/docs-site/mcp-servers.html +234 -0
  37. package/docs-site/mcp.html +243 -0
  38. package/docs-site/permissions.html +177 -0
  39. package/docs-site/profiles.html +191 -0
  40. package/docs-site/quickstart.html +267 -0
  41. package/docs-site/runtime.html +168 -0
  42. package/docs-site/scope.html +194 -0
  43. package/docs-site/security.html +174 -0
  44. package/docs-site/team.html +142 -0
  45. package/docs-site/workflows.html +170 -0
  46. package/package.json +4 -2
  47. package/packages/piagent-core/capabilities/capability-core.js +17 -1
  48. package/packages/piagent-core/extensions/document-intake.ts +5 -5
  49. package/packages/piagent-core/extensions/piagent-guard.ts +319 -4
  50. package/packages/piagent-core/extensions/update-check.js +41 -0
  51. package/packages/piagent-core/mcp/mcp-approval-store.js +175 -0
  52. package/packages/piagent-core/mcp/mcp-auth-readiness.js +197 -0
  53. package/packages/piagent-core/mcp/mcp-command-actions.js +291 -0
  54. package/packages/piagent-core/mcp/mcp-config-layers.js +133 -0
  55. package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
  56. package/packages/piagent-core/mcp/mcp-server-entry.js +242 -0
  57. package/packages/piagent-core/mcp/mcp-session-view.js +185 -0
  58. package/packages/piagent-core/package.json +7 -3
  59. package/packages/piagent-core/prompts/model-options.md +8 -8
  60. package/scripts/build-docs-site.mjs +421 -0
  61. package/scripts/configure-model-scope.sh +2 -2
  62. package/scripts/configure-subagents.sh +1 -1
  63. package/scripts/install-global.sh +5 -5
  64. package/scripts/mcp-manage.mjs +567 -0
  65. package/scripts/piagent-cli.mjs +1 -1
  66. package/scripts/preview-docs-site.mjs +88 -0
  67. package/scripts/setup.sh +2 -2
  68. package/scripts/team-doctor.sh +18 -2
  69. package/scripts/verify-local.sh +46 -19
  70. package/scripts/verify-release-identity.mjs +8 -2
  71. package/scripts/configure-mcp.sh +0 -349
@@ -0,0 +1,567 @@
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
+ readMcpConfig,
14
+ writeMcpConfig
15
+ } from "../packages/piagent-core/mcp/mcp-config-layers.js";
16
+ import {
17
+ CATALOG_PRESETS,
18
+ CATALOG_PREREQUISITES,
19
+ CATALOG_SERVERS,
20
+ isCatalogPreset,
21
+ resolvePreset
22
+ } from "../packages/piagent-core/mcp/mcp-server-catalog.js";
23
+ import {
24
+ McpEntryError,
25
+ assertServerName,
26
+ buildServerEntry,
27
+ maskServerEntry
28
+ } from "../packages/piagent-core/mcp/mcp-server-entry.js";
29
+ import {
30
+ approvalState,
31
+ clearApproval,
32
+ recordApproval
33
+ } from "../packages/piagent-core/mcp/mcp-approval-store.js";
34
+ import { evaluateServerReadiness } from "../packages/piagent-core/mcp/mcp-auth-readiness.js";
35
+ import {
36
+ McpViewError,
37
+ blockedRows,
38
+ collectServerRows,
39
+ describeServer,
40
+ formatDoctorReport,
41
+ formatServerDetail,
42
+ formatServerTable,
43
+ usesDocker
44
+ } from "../packages/piagent-core/mcp/mcp-session-view.js";
45
+
46
+ const SUBCOMMANDS = new Set([
47
+ "add", "remove", "get", "list", "enable", "disable", "approve", "reject", "reset", "doctor", "preset"
48
+ ]);
49
+
50
+ function usage() {
51
+ process.stdout.write(`Usage:
52
+ piagent-mcp <command> [options]
53
+ piagent-mcp --preset <preset> [options] seed a pinned server group
54
+
55
+ Commands:
56
+ add <name> --url <url> Add a remote MCP server
57
+ add <name> -- <command> [args] Add a local MCP server
58
+ remove <name> Remove a server from a scope
59
+ get <name> Show one server, with credentials masked
60
+ list Show every server visible here, with state
61
+ enable <name> / disable <name> Turn a server off without losing its config
62
+ approve <name> / reject <name> Decide on a server this repository defines
63
+ reset Forget every decision for this project
64
+ doctor Report what each server still needs
65
+
66
+ Options:
67
+ --scope <global|pi-global|project|pi-project>
68
+ Config layer (default: global; project for approve)
69
+ --project <path> Project path for project scopes (default: cwd)
70
+ --config <path> Explicit config path; overrides --scope/--project
71
+ --env KEY=value Environment entry for a local server (repeatable)
72
+ --header "Name: value" Header for a remote server (repeatable)
73
+ --bearer-token-env-var <VAR> Variable holding the bearer token for a remote server
74
+ --oauth-client-id <id> OAuth client identifier
75
+ --oauth-scopes <a,b> Pin the OAuth scopes requested
76
+ --direct-tools Expose this server's tools directly instead of via the proxy
77
+ --preset <name> Seed a pinned group: ${Object.keys(CATALOG_PRESETS).join(", ")}
78
+ --replace Overwrite existing definitions the preset covers
79
+ --dry-run Print what would be written
80
+ --list Print available presets/servers
81
+ --json Machine-readable output
82
+ -h, --help
83
+
84
+ Credentials are never written into MCP config. Pass a variable reference such as
85
+ --env 'GITHUB_PERSONAL_ACCESS_TOKEN=\${GITHUB_PERSONAL_ACCESS_TOKEN}' and export the
86
+ value in your shell. OAuth sign-in belongs to Pi: run /mcp-auth <name> inside a session.
87
+
88
+ Servers defined by a repository (project and pi-project scopes) are not usable
89
+ until approved here. The decision is stored in your home directory, so a clone
90
+ cannot approve itself, and it is tied to the definition that was approved.
91
+
92
+ Examples:
93
+ piagent-mcp --preset core --scope global --replace
94
+ piagent-mcp add sentry --url https://mcp.sentry.dev/mcp --scope global
95
+ piagent-mcp add internal --scope project -- npx -y @acme/internal-mcp
96
+ piagent-mcp list
97
+ piagent-mcp approve internal
98
+ piagent-mcp doctor
99
+ `);
100
+ }
101
+
102
+ class UsageError extends Error {}
103
+
104
+ /**
105
+ * @param {string[]} argv
106
+ * @returns {{command: string, positionals: string[], flags: Map<string, string|true>, repeated: Map<string, string[]>, passthrough: string[]}}
107
+ */
108
+ function parseArgs(argv) {
109
+ const repeatable = new Set(["--env", "--header"]);
110
+ const valued = new Set([
111
+ "--scope", "--project", "--config", "--preset", "--url", "--bearer-token-env-var",
112
+ "--oauth-client-id", "--oauth-scopes", "--env", "--header"
113
+ ]);
114
+ const boolean = new Set(["--replace", "--dry-run", "--list", "--json", "--direct-tools", "--validate-preset", "--force"]);
115
+
116
+ const flags = new Map();
117
+ const repeated = new Map();
118
+ const positionals = [];
119
+ /** @type {string[]} */
120
+ let passthrough = [];
121
+
122
+ let command = "";
123
+ let index = 0;
124
+ if (argv[0] && !argv[0].startsWith("-")) {
125
+ command = argv[0];
126
+ index = 1;
127
+ if (!SUBCOMMANDS.has(command)) throw new UsageError(`unknown command: ${command}`);
128
+ }
129
+
130
+ for (; index < argv.length; index += 1) {
131
+ const token = argv[index];
132
+ if (token === "--") {
133
+ passthrough = argv.slice(index + 1);
134
+ break;
135
+ }
136
+ if (token === "-h" || token === "--help") {
137
+ flags.set("--help", true);
138
+ continue;
139
+ }
140
+ if (!token.startsWith("-")) {
141
+ positionals.push(token);
142
+ continue;
143
+ }
144
+ if (boolean.has(token)) {
145
+ flags.set(token, true);
146
+ continue;
147
+ }
148
+ if (!valued.has(token)) throw new UsageError(`unknown option: ${token}`);
149
+ const value = argv[index + 1];
150
+ if (value === undefined || value === "--") throw new UsageError(`missing value for ${token}`);
151
+ index += 1;
152
+ if (repeatable.has(token)) {
153
+ repeated.set(token, [...(repeated.get(token) ?? []), value]);
154
+ continue;
155
+ }
156
+ flags.set(token, value);
157
+ }
158
+
159
+ return { command, positionals, flags, repeated, passthrough };
160
+ }
161
+
162
+ /** @param {Map<string, string|true>} flags @param {string} name @returns {string|undefined} */
163
+ function value(flags, name) {
164
+ const raw = flags.get(name);
165
+ return typeof raw === "string" ? raw : undefined;
166
+ }
167
+
168
+ /**
169
+ * @param {Map<string, string|true>} flags
170
+ * @param {string} fallback
171
+ * @returns {string}
172
+ */
173
+ function requireScope(flags, fallback) {
174
+ const scope = value(flags, "--scope") ?? fallback;
175
+ if (!SCOPES.includes(scope)) throw new UsageError(`unsupported scope: ${scope}. Use one of ${SCOPES.join(", ")}.`);
176
+ return scope;
177
+ }
178
+
179
+ /** @param {Map<string, string|true>} flags @returns {string} */
180
+ function projectPath(flags) {
181
+ return path.resolve(value(flags, "--project") ?? process.cwd());
182
+ }
183
+
184
+ /** @param {Map<string, string|true>} flags @param {string} scope @returns {string} */
185
+ function targetConfig(flags, scope) {
186
+ const explicit = value(flags, "--config");
187
+ if (explicit) {
188
+ if (path.basename(explicit) !== "mcp.json" && path.basename(explicit) !== ".mcp.json") {
189
+ throw new UsageError("--config must point at an mcp.json or .mcp.json file");
190
+ }
191
+ return path.resolve(explicit);
192
+ }
193
+ return configPathForScope(scope, { projectPath: projectPath(flags) });
194
+ }
195
+
196
+ /** @param {string} name @param {Map<string, string|true>} flags @returns {{scope: string, file: string, entry: Record<string, unknown>}} */
197
+ function locateServer(name, flags) {
198
+ const explicitScope = value(flags, "--scope");
199
+ const servers = collectServers({ projectPath: projectPath(flags) })
200
+ .filter((server) => server.name === name && (!explicitScope || server.scope === explicitScope));
201
+ if (servers.length === 0) {
202
+ throw new UsageError(
203
+ explicitScope
204
+ ? `no server named ${name} in ${explicitScope} scope`
205
+ : `no server named ${name}. Run \`piagent-mcp list\` to see what is configured.`
206
+ );
207
+ }
208
+ if (servers.length > 1) {
209
+ throw new UsageError(
210
+ `${name} is defined in more than one scope (${servers.map((server) => server.scope).join(", ")}). ` +
211
+ "Pass --scope to say which one."
212
+ );
213
+ }
214
+ return servers[0];
215
+ }
216
+
217
+ // --- commands ---------------------------------------------------------------
218
+
219
+ /** @param {ReturnType<typeof parseArgs>} args */
220
+ function commandAdd(args) {
221
+ const name = args.positionals[0];
222
+ if (!name) throw new UsageError("add needs a server name");
223
+ const scope = requireScope(args.flags, "global");
224
+ const entry = buildServerEntry({
225
+ name,
226
+ url: value(args.flags, "--url") ?? undefined,
227
+ command: args.passthrough.length > 0 ? args.passthrough : undefined,
228
+ env: args.repeated.get("--env") ?? [],
229
+ header: args.repeated.get("--header") ?? [],
230
+ bearerTokenEnvVar: value(args.flags, "--bearer-token-env-var"),
231
+ oauthClientId: value(args.flags, "--oauth-client-id"),
232
+ oauthScopes: value(args.flags, "--oauth-scopes"),
233
+ directTools: args.flags.get("--direct-tools") === true
234
+ });
235
+
236
+ const file = targetConfig(args.flags, scope);
237
+ const config = readMcpConfig(file);
238
+ const existed = Object.hasOwn(config.mcpServers, name);
239
+ if (existed && args.flags.get("--replace") !== true) {
240
+ throw new UsageError(`${name} already exists in ${scope} scope. Pass --replace to overwrite it.`);
241
+ }
242
+ config.mcpServers[name] = entry;
243
+
244
+ if (args.flags.get("--dry-run") === true) {
245
+ process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, name, scope, file, entry: maskServerEntry(entry) }, null, 2)}\n`);
246
+ return 0;
247
+ }
248
+ writeMcpConfig(file, config);
249
+
250
+ // A previous decision described a different server. Dropping it here means the
251
+ // operator is asked again rather than inheriting approval for what was replaced.
252
+ if (existed && REPOSITORY_SCOPES.has(scope)) clearApproval({ projectPath: projectPath(args.flags), name });
253
+
254
+ const transport = typeof entry.url === "string" ? `URL: ${entry.url}` : `command: ${[entry.command, ...(Array.isArray(entry.args) ? entry.args : [])].join(" ")}`;
255
+ process.stdout.write(`${existed ? "Replaced" : "Added"} ${typeof entry.url === "string" ? "remote" : "local"} MCP server ${name} with ${transport} in ${scope} scope\n`);
256
+ process.stdout.write(`File modified: ${file}\n`);
257
+ if (REPOSITORY_SCOPES.has(scope)) {
258
+ process.stdout.write(`This scope is part of the repository, so the server is not usable until approved: piagent-mcp approve ${name}\n`);
259
+ }
260
+ reportMissingEnv(entry);
261
+ return 0;
262
+ }
263
+
264
+ /** @param {Record<string, unknown>} entry */
265
+ function reportMissingEnv(entry) {
266
+ const readiness = evaluateServerReadiness({ name: "", scope: "global", entry }, { projectPath: process.cwd() });
267
+ if (readiness.state === "needs-env") {
268
+ process.stderr.write(`\nStill needed before it can connect: ${readiness.detail}.\n`);
269
+ }
270
+ if (readiness.state === "needs-command") {
271
+ process.stderr.write(`\nStill needed before it can connect: ${readiness.detail}.\n`);
272
+ }
273
+ }
274
+
275
+ /** @param {ReturnType<typeof parseArgs>} args */
276
+ function commandRemove(args) {
277
+ const name = args.positionals[0];
278
+ if (!name) throw new UsageError("remove needs a server name");
279
+ const located = locateServer(name, args.flags);
280
+ const config = readMcpConfig(located.file);
281
+ delete config.mcpServers[name];
282
+ writeMcpConfig(located.file, config);
283
+ clearApproval({ projectPath: projectPath(args.flags), name });
284
+ process.stdout.write(`Removed MCP server ${name} from ${located.scope} scope\n`);
285
+ process.stdout.write(`File modified: ${located.file}\n`);
286
+ return 0;
287
+ }
288
+
289
+ /** @param {ReturnType<typeof parseArgs>} args */
290
+ function commandGet(args) {
291
+ const name = args.positionals[0];
292
+ if (!name) throw new UsageError("get needs a server name");
293
+ const detail = describeServer({
294
+ projectPath: projectPath(args.flags),
295
+ name,
296
+ scope: value(args.flags, "--scope"),
297
+ listHint: "piagent-mcp list"
298
+ });
299
+
300
+ if (args.flags.get("--json") === true) {
301
+ process.stdout.write(`${JSON.stringify({
302
+ name,
303
+ scope: detail.scope,
304
+ file: detail.file,
305
+ state: detail.readiness.state,
306
+ detail: detail.readiness.detail,
307
+ entry: detail.masked
308
+ }, null, 2)}\n`);
309
+ return 0;
310
+ }
311
+
312
+ for (const line of formatServerDetail(detail)) process.stdout.write(`${line}\n`);
313
+ process.stdout.write(` remove: piagent-mcp remove ${name} --scope ${detail.scope}\n`);
314
+ return 0;
315
+ }
316
+
317
+ /** @param {ReturnType<typeof parseArgs>} args */
318
+ function commandList(args) {
319
+ const scopeFilter = value(args.flags, "--scope");
320
+ const project = projectPath(args.flags);
321
+ const rows = collectServerRows({ projectPath: project, scopes: scopeFilter ? [scopeFilter] : undefined });
322
+
323
+ if (args.flags.get("--json") === true) {
324
+ process.stdout.write(`${JSON.stringify({
325
+ project,
326
+ servers: rows.map((row) => ({
327
+ name: row.name,
328
+ scope: row.scope,
329
+ file: row.file,
330
+ state: row.readiness.state,
331
+ detail: row.readiness.detail,
332
+ entry: maskServerEntry(row.entry)
333
+ }))
334
+ }, null, 2)}\n`);
335
+ return 0;
336
+ }
337
+
338
+ if (rows.length === 0) {
339
+ process.stdout.write("No MCP servers configured.\n");
340
+ process.stdout.write("Seed the pinned baseline with: piagent-mcp --preset core --scope global\n");
341
+ return 0;
342
+ }
343
+
344
+ process.stdout.write(`${formatServerTable(rows)}\n`);
345
+ const blocked = blockedRows(rows);
346
+ if (blocked.length > 0) process.stdout.write(`\n${blocked.length} server(s) not usable yet. Run \`piagent-mcp doctor\`.\n`);
347
+ return 0;
348
+ }
349
+
350
+ /** @param {ReturnType<typeof parseArgs>} args @param {boolean} enabled */
351
+ function commandToggle(args, enabled) {
352
+ const name = args.positionals[0];
353
+ if (!name) throw new UsageError(`${enabled ? "enable" : "disable"} needs a server name`);
354
+ const located = locateServer(name, args.flags);
355
+ const config = readMcpConfig(located.file);
356
+ const entry = { ...(isRecord(config.mcpServers[name]) ? config.mcpServers[name] : {}) };
357
+ if (enabled) delete entry.enabled;
358
+ else entry.enabled = false;
359
+ config.mcpServers[name] = entry;
360
+ writeMcpConfig(located.file, config);
361
+ process.stdout.write(`${enabled ? "Enabled" : "Disabled"} MCP server ${name} in ${located.scope} scope\n`);
362
+ process.stdout.write(`File modified: ${located.file}\n`);
363
+ if (!enabled) process.stdout.write("The definition is kept, so `piagent-mcp enable` restores it.\n");
364
+ return 0;
365
+ }
366
+
367
+ /** @param {ReturnType<typeof parseArgs>} args @param {"approved"|"rejected"} decision */
368
+ function commandDecide(args, decision) {
369
+ const name = args.positionals[0];
370
+ if (!name) throw new UsageError(`${decision === "approved" ? "approve" : "reject"} needs a server name`);
371
+ assertServerName(name);
372
+ const project = projectPath(args.flags);
373
+ const servers = collectServers({ projectPath: project, scopes: [...REPOSITORY_SCOPES] })
374
+ .filter((server) => server.name === name);
375
+ if (servers.length === 0) {
376
+ throw new UsageError(
377
+ `${name} is not defined by this repository. Approval only applies to servers in ${[...REPOSITORY_SCOPES].join(" or ")} scope.`
378
+ );
379
+ }
380
+ if (servers.length > 1) throw new UsageError(`${name} is defined in ${servers.map((server) => server.scope).join(" and ")}; remove one.`);
381
+
382
+ const server = servers[0];
383
+ const before = approvalState({ projectPath: project, name, entry: server.entry });
384
+ if (decision === "approved" && before.state === "pending" && args.flags.get("--force") !== true) {
385
+ // Approving without reading it is the failure this gate exists to prevent, so
386
+ // the definition is put in front of the operator before the decision lands.
387
+ process.stdout.write(`${JSON.stringify(maskServerEntry(server.entry), null, 2)}\n`);
388
+ }
389
+ recordApproval({ projectPath: project, name, entry: server.entry, decision });
390
+ const after = approvalState({ projectPath: project, name, entry: server.entry });
391
+ process.stdout.write(`${decision === "approved" ? "Approved" : "Rejected"} MCP server ${name} (${server.scope}) for ${project}\n`);
392
+ process.stdout.write(`Pinned to this definition: ${after.digest}\n`);
393
+ process.stdout.write("Changing the server's command, URL or arguments returns it to pending.\n");
394
+ return 0;
395
+ }
396
+
397
+ /** @param {ReturnType<typeof parseArgs>} args */
398
+ function commandReset(args) {
399
+ const project = projectPath(args.flags);
400
+ const name = args.positionals[0];
401
+ clearApproval({ projectPath: project, name });
402
+ process.stdout.write(name
403
+ ? `Forgot the decision for ${name} in ${project}\n`
404
+ : `Forgot every MCP approval decision for ${project}\n`);
405
+ return 0;
406
+ }
407
+
408
+ /** @param {ReturnType<typeof parseArgs>} args */
409
+ function commandDoctor(args) {
410
+ const project = projectPath(args.flags);
411
+ const rows = collectServerRows({ projectPath: project });
412
+
413
+ // The one check too slow for a session start: a daemon that is installed but
414
+ // not running looks exactly like one that is running until something asks it.
415
+ let dockerRunning;
416
+ if (usesDocker(rows)) {
417
+ const probe = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
418
+ dockerRunning = probe.status === 0;
419
+ }
420
+
421
+ if (args.flags.get("--json") === true) {
422
+ process.stdout.write(`${JSON.stringify({
423
+ project,
424
+ dockerRunning,
425
+ servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail, remedy: row.readiness.remedy }))
426
+ }, null, 2)}\n`);
427
+ return blockedRows(rows).length > 0 ? 1 : 0;
428
+ }
429
+
430
+ if (rows.length === 0) {
431
+ process.stdout.write("No MCP servers configured.\n");
432
+ return 0;
433
+ }
434
+
435
+ const report = formatDoctorReport(rows, { dockerRunning, rerun: "piagent-mcp doctor" });
436
+ for (const line of report.lines) process.stdout.write(`${line}\n`);
437
+ process.stdout.write(report.problems === 0
438
+ ? "\nPASS: every configured MCP server can be reached.\n"
439
+ : `\n${report.problems} MCP server(s) need attention.\n`);
440
+ return report.problems === 0 ? 0 : 1;
441
+ }
442
+
443
+ /** @param {ReturnType<typeof parseArgs>} args */
444
+ function commandPreset(args) {
445
+ if (args.flags.get("--list") === true) {
446
+ process.stdout.write(`${JSON.stringify({
447
+ presets: CATALOG_PRESETS,
448
+ servers: Object.fromEntries(Object.entries(CATALOG_SERVERS).map(([name, server]) => [
449
+ name,
450
+ { description: server.description, requires: CATALOG_PREREQUISITES[name] ?? [] }
451
+ ]))
452
+ }, null, 2)}\n`);
453
+ return 0;
454
+ }
455
+
456
+ const presetName = value(args.flags, "--preset") ?? "core";
457
+ if (!presetName) throw new UsageError("--preset cannot be empty");
458
+ if (!isCatalogPreset(presetName)) {
459
+ throw new UsageError(`unknown preset: ${presetName}. Available presets: ${Object.keys(CATALOG_PRESETS).join(", ")}`);
460
+ }
461
+ // Answering the name question and stopping, so a caller can ask before it
462
+ // installs rather than discovering the typo after two packages have landed.
463
+ if (args.flags.get("--validate-preset") === true) {
464
+ process.stdout.write(`${presetName}\n`);
465
+ return 0;
466
+ }
467
+
468
+ const scope = requireScope(args.flags, "global");
469
+ const file = targetConfig(args.flags, scope);
470
+ const config = readMcpConfig(file);
471
+ const replace = args.flags.get("--replace") === true;
472
+ const dryRun = args.flags.get("--dry-run") === true;
473
+
474
+ const added = [];
475
+ const kept = [];
476
+ const replaced = [];
477
+ for (const server of resolvePreset(presetName)) {
478
+ if (Object.hasOwn(config.mcpServers, server.name)) {
479
+ if (!replace) {
480
+ kept.push(server.name);
481
+ continue;
482
+ }
483
+ config.mcpServers[server.name] = server.config;
484
+ replaced.push(server.name);
485
+ continue;
486
+ }
487
+ config.mcpServers[server.name] = server.config;
488
+ added.push(server.name);
489
+ }
490
+
491
+ const report = {
492
+ configPath: file,
493
+ preset: presetName,
494
+ dryRun,
495
+ replace,
496
+ added,
497
+ kept,
498
+ replaced,
499
+ serverCount: Object.keys(config.mcpServers).length
500
+ };
501
+
502
+ if (dryRun) {
503
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
504
+ process.stdout.write(`${JSON.stringify({ settings: config.settings, mcpServers: config.mcpServers }, null, 2)}\n`);
505
+ return 0;
506
+ }
507
+
508
+ writeMcpConfig(file, config);
509
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
510
+
511
+ // stdout stays machine-readable; the operator note goes to stderr.
512
+ const lines = resolvePreset(presetName).flatMap((server) => server.requires.map((item) => ` ${server.name}: ${item}`));
513
+ if (lines.length > 0) {
514
+ process.stderr.write("\nServer definitions are written. Each still needs this before it can connect:\n");
515
+ for (const line of lines) process.stderr.write(`${line}\n`);
516
+ process.stderr.write("Servers connect lazily, so nothing above is checked until the first call.\n");
517
+ process.stderr.write("Run `piagent-mcp doctor` to see which ones are ready on this machine.\n");
518
+ }
519
+ return 0;
520
+ }
521
+
522
+ // --- entry ------------------------------------------------------------------
523
+
524
+ function main(argv) {
525
+ let args;
526
+ try {
527
+ args = parseArgs(argv);
528
+ } catch (error) {
529
+ process.stderr.write(`FAIL: ${error instanceof Error ? error.message : String(error)}\n`);
530
+ usage();
531
+ return 2;
532
+ }
533
+
534
+ if (args.flags.get("--help") === true || (!args.command && argv.length === 0)) {
535
+ usage();
536
+ return 0;
537
+ }
538
+
539
+ try {
540
+ switch (args.command || "preset") {
541
+ case "add": return commandAdd(args);
542
+ case "remove": return commandRemove(args);
543
+ case "get": return commandGet(args);
544
+ case "list": return commandList(args);
545
+ case "enable": return commandToggle(args, true);
546
+ case "disable": return commandToggle(args, false);
547
+ case "approve": return commandDecide(args, "approved");
548
+ case "reject": return commandDecide(args, "rejected");
549
+ case "reset": return commandReset(args);
550
+ case "doctor": return commandDoctor(args);
551
+ default: return commandPreset(args);
552
+ }
553
+ } catch (error) {
554
+ if (error instanceof UsageError || error instanceof McpEntryError || error instanceof McpConfigError || error instanceof McpViewError) {
555
+ process.stderr.write(`FAIL: ${error.message}\n`);
556
+ return 2;
557
+ }
558
+ process.stderr.write(`FAIL: ${error instanceof Error ? error.message : String(error)}\n`);
559
+ return 1;
560
+ }
561
+ }
562
+
563
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
564
+ process.exit(main(process.argv.slice(2)));
565
+ }
566
+
567
+ export { main };
@@ -14,7 +14,7 @@ const scriptByCommand = {
14
14
  "piagent-usage": "scripts/pi-session-stats.sh",
15
15
  "piagent-models": "scripts/pi-model-catalog.sh",
16
16
  "piagent-model-scope": "scripts/configure-model-scope.sh",
17
- "piagent-mcp": "scripts/configure-mcp.sh",
17
+ "piagent-mcp": "scripts/mcp-manage.mjs",
18
18
  "piagent-subagents": "scripts/configure-subagents.sh",
19
19
  "piagent-capabilities": "scripts/capability-catalog.mjs",
20
20
  "piagent-migrate": "scripts/migrate-project-state.mjs",
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // Serves docs-site/ locally the way Vercel serves it.
8
+ //
9
+ // The deployed site runs with `cleanUrls: true`, so every internal link points
10
+ // at /quickstart rather than /quickstart.html. A plain static file server
11
+ // answers 404 for all of them, which makes a local preview useless for
12
+ // checking navigation. This resolves the extensionless path the same way.
13
+
14
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
+ const siteRoot = path.join(repoRoot, "docs-site");
16
+
17
+ const CONTENT_TYPES = {
18
+ ".css": "text/css; charset=utf-8",
19
+ ".html": "text/html; charset=utf-8",
20
+ ".ico": "image/x-icon",
21
+ ".js": "text/javascript; charset=utf-8",
22
+ ".json": "application/json; charset=utf-8",
23
+ ".png": "image/png",
24
+ ".svg": "image/svg+xml",
25
+ ".txt": "text/plain; charset=utf-8",
26
+ ".webmanifest": "application/manifest+json"
27
+ };
28
+
29
+ function portFromArgs(argv) {
30
+ const flag = argv.indexOf("--port");
31
+ const raw = flag >= 0 ? argv[flag + 1] : process.env.PORT;
32
+ if (!raw) return 4321;
33
+ const port = Number.parseInt(raw, 10);
34
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
35
+ throw new Error(`invalid port: ${raw}`);
36
+ }
37
+ return port;
38
+ }
39
+
40
+ /** Resolve a request path to a file inside the site root, or undefined. */
41
+ function resolveFile(urlPath) {
42
+ const requested = urlPath === "/" ? "/index.html" : urlPath;
43
+ const candidate = path.resolve(siteRoot, `.${requested}`);
44
+ // A traversal such as /../../.ssh/id_rsa escapes the site root; refuse it
45
+ // rather than reading whatever it lands on.
46
+ if (candidate !== siteRoot && !candidate.startsWith(siteRoot + path.sep)) return undefined;
47
+ for (const file of [candidate, `${candidate}.html`, path.join(candidate, "index.html")]) {
48
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ const port = portFromArgs(process.argv.slice(2));
54
+
55
+ const server = http.createServer((req, res) => {
56
+ if (req.method !== "GET" && req.method !== "HEAD") {
57
+ res.writeHead(405, { allow: "GET, HEAD" });
58
+ res.end();
59
+ return;
60
+ }
61
+ let urlPath;
62
+ try {
63
+ urlPath = decodeURIComponent(new URL(req.url, `http://localhost:${port}`).pathname);
64
+ } catch {
65
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
66
+ res.end("bad request\n");
67
+ return;
68
+ }
69
+ const file = resolveFile(urlPath);
70
+ if (!file) {
71
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
72
+ res.end(`not found: ${urlPath}\n`);
73
+ return;
74
+ }
75
+ const body = fs.readFileSync(file);
76
+ res.writeHead(200, {
77
+ "content-type": CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream",
78
+ "content-length": body.length,
79
+ "cache-control": "no-store"
80
+ });
81
+ res.end(req.method === "HEAD" ? undefined : body);
82
+ });
83
+
84
+ // Loopback only: this serves local files and has no business being reachable
85
+ // from the network.
86
+ server.listen(port, "127.0.0.1", () => {
87
+ process.stdout.write(`docs-site preview on http://localhost:${port} (Ctrl+C to stop)\n`);
88
+ });
package/scripts/setup.sh CHANGED
@@ -28,11 +28,11 @@ Options:
28
28
  Install/skip pi-subagents during global install (default: install)
29
29
  --subagents-preset <minimal|safe|async|parallel>
30
30
  Configure pi-subagents runtime baseline (default: safe)
31
- --subagents-model-scope <none|piagent|codex|claude>
31
+ --subagents-model-scope <none|piagent|codex|claude> model family for subagents
32
32
  Optional subagent model allowlist (default: none)
33
33
  --with-web-access Install pi-web-access for builtin `researcher` subagent (default: skip)
34
34
  --with-herdr / --no-herdr Install/skip Herdr Pi integration if herdr exists (default: install)
35
- --model-scope <full|codex|claude>
35
+ --model-scope <full|codex|claude> model families to enable
36
36
  Configure Pi enabledModels for selector/cycling (default: full)
37
37
  --default-model <provider/model[:thinking]>
38
38
  Configure default provider/model/thinking (default: openai-codex/gpt-5.5:xhigh)