@piagent/platform 1.1.9 → 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.
- package/CHANGELOG.md +43 -1
- package/README.md +2 -2
- package/docs/command-reference-vietnamese.md +13 -4
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +108 -2
- package/docs/model-options.md +10 -8
- package/docs/operator-manual-vietnamese.md +12 -12
- package/docs/publishing-for-teams.md +1 -0
- package/docs/quickstart-vietnamese.md +2 -2
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +1 -1
- package/docs/team-onboarding.md +4 -4
- package/docs/vercel-docs-site.md +34 -4
- package/docs-site/architecture.html +152 -0
- package/docs-site/assets/docs.css +1001 -0
- package/docs-site/assets/docs.js +84 -0
- package/docs-site/commands.html +203 -0
- package/docs-site/content/architecture.html +29 -0
- package/docs-site/content/commands.html +80 -0
- package/docs-site/content/index.html +80 -0
- package/docs-site/content/mcp-approval.html +81 -0
- package/docs-site/content/mcp-auth.html +76 -0
- package/docs-site/content/mcp-servers.html +111 -0
- package/docs-site/content/mcp.html +120 -0
- package/docs-site/content/permissions.html +54 -0
- package/docs-site/content/profiles.html +68 -0
- package/docs-site/content/quickstart.html +144 -0
- package/docs-site/content/runtime.html +45 -0
- package/docs-site/content/scope.html +71 -0
- package/docs-site/content/security.html +51 -0
- package/docs-site/content/team.html +23 -0
- package/docs-site/content/workflows.html +47 -0
- package/docs-site/index.html +131 -1534
- package/docs-site/mcp-approval.html +204 -0
- package/docs-site/mcp-auth.html +199 -0
- package/docs-site/mcp-servers.html +234 -0
- package/docs-site/mcp.html +243 -0
- package/docs-site/permissions.html +177 -0
- package/docs-site/profiles.html +191 -0
- package/docs-site/quickstart.html +267 -0
- package/docs-site/runtime.html +168 -0
- package/docs-site/scope.html +194 -0
- package/docs-site/security.html +174 -0
- package/docs-site/team.html +142 -0
- package/docs-site/workflows.html +170 -0
- package/package.json +4 -2
- package/packages/piagent-core/capabilities/capability-core.js +17 -1
- package/packages/piagent-core/extensions/document-intake.ts +5 -5
- package/packages/piagent-core/extensions/piagent-guard.ts +316 -0
- package/packages/piagent-core/mcp/mcp-approval-store.js +175 -0
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +197 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +291 -0
- package/packages/piagent-core/mcp/mcp-config-layers.js +133 -0
- package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +242 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +185 -0
- package/packages/piagent-core/package.json +7 -3
- package/packages/piagent-core/prompts/model-options.md +8 -8
- package/scripts/build-docs-site.mjs +421 -0
- package/scripts/configure-model-scope.sh +2 -2
- package/scripts/configure-subagents.sh +1 -1
- package/scripts/install-global.sh +5 -5
- package/scripts/mcp-manage.mjs +567 -0
- package/scripts/piagent-cli.mjs +1 -1
- package/scripts/preview-docs-site.mjs +88 -0
- package/scripts/setup.sh +2 -2
- package/scripts/team-doctor.sh +18 -2
- package/scripts/verify-local.sh +46 -19
- package/scripts/verify-release-identity.mjs +8 -2
- 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 };
|
package/scripts/piagent-cli.mjs
CHANGED
|
@@ -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/
|
|
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)
|