@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +43 -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 +1 -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 +316 -0
  50. package/packages/piagent-core/mcp/mcp-approval-store.js +175 -0
  51. package/packages/piagent-core/mcp/mcp-auth-readiness.js +197 -0
  52. package/packages/piagent-core/mcp/mcp-command-actions.js +291 -0
  53. package/packages/piagent-core/mcp/mcp-config-layers.js +133 -0
  54. package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
  55. package/packages/piagent-core/mcp/mcp-server-entry.js +242 -0
  56. package/packages/piagent-core/mcp/mcp-session-view.js +185 -0
  57. package/packages/piagent-core/package.json +7 -3
  58. package/packages/piagent-core/prompts/model-options.md +8 -8
  59. package/scripts/build-docs-site.mjs +421 -0
  60. package/scripts/configure-model-scope.sh +2 -2
  61. package/scripts/configure-subagents.sh +1 -1
  62. package/scripts/install-global.sh +5 -5
  63. package/scripts/mcp-manage.mjs +567 -0
  64. package/scripts/piagent-cli.mjs +1 -1
  65. package/scripts/preview-docs-site.mjs +88 -0
  66. package/scripts/setup.sh +2 -2
  67. package/scripts/team-doctor.sh +18 -2
  68. package/scripts/verify-local.sh +46 -19
  69. package/scripts/verify-release-identity.mjs +8 -2
  70. package/scripts/configure-mcp.sh +0 -349
@@ -0,0 +1,150 @@
1
+ // The servers this platform ships a pinned definition for, and the presets that
2
+ // group them. Pinned by version or digest because an MCP server is code that runs
3
+ // on the operator's machine with the operator's credentials; a floating tag would
4
+ // make every session a fresh supply-chain decision nobody is present to make.
5
+ //
6
+ // Nothing here is a secret. Every credential is referenced as an environment
7
+ // variable, so the config can be read, diffed and committed without leaking one.
8
+
9
+ /** @typedef {{description: string, config: Record<string, unknown>}} CatalogServer */
10
+
11
+ /** @type {Record<string, CatalogServer>} */
12
+ export const CATALOG_SERVERS = {
13
+ context7: {
14
+ description: "Official Context7 MCP server for up-to-date library/framework docs.",
15
+ config: {
16
+ command: "npx",
17
+ args: ["-y", "@upstash/context7-mcp@3.2.4"],
18
+ env: {
19
+ CONTEXT7_API_KEY: "${CONTEXT7_API_KEY}"
20
+ },
21
+ lifecycle: "lazy",
22
+ directTools: false
23
+ }
24
+ },
25
+ "chrome-devtools": {
26
+ description: "Chrome DevTools MCP for runtime browser inspection, console logs, screenshots, and performance checks.",
27
+ config: {
28
+ command: "npx",
29
+ args: ["-y", "chrome-devtools-mcp@1.6.0", "--no-performance-crux"],
30
+ env: {
31
+ CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: "1",
32
+ CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: "1"
33
+ },
34
+ lifecycle: "lazy",
35
+ directTools: false
36
+ }
37
+ },
38
+ playwright: {
39
+ description: "Playwright MCP for browser automation and UI verification workflows.",
40
+ config: {
41
+ command: "npx",
42
+ args: ["-y", "@playwright/mcp@0.0.78"],
43
+ lifecycle: "lazy",
44
+ directTools: false
45
+ }
46
+ },
47
+ github: {
48
+ description: "Official GitHub MCP server via Docker. Requires Docker and GITHUB_PERSONAL_ACCESS_TOKEN when used.",
49
+ config: {
50
+ command: "docker",
51
+ args: [
52
+ "run",
53
+ "-i",
54
+ "--rm",
55
+ "-e",
56
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
57
+ "-e",
58
+ "GITHUB_READ_ONLY=1",
59
+ "-e",
60
+ "GITHUB_LOCKDOWN_MODE=1",
61
+ "ghcr.io/github/github-mcp-server@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"
62
+ ],
63
+ env: {
64
+ GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PERSONAL_ACCESS_TOKEN}"
65
+ },
66
+ lifecycle: "lazy",
67
+ directTools: false
68
+ }
69
+ },
70
+ figma: {
71
+ description: "Figma remote MCP server. Requires Figma OAuth from the MCP panel when first used.",
72
+ config: {
73
+ url: "https://mcp.figma.com/mcp",
74
+ auth: "oauth",
75
+ lifecycle: "lazy",
76
+ directTools: false
77
+ }
78
+ },
79
+ "figma-desktop": {
80
+ description: "Figma desktop/local MCP server. Requires Figma desktop Dev Mode MCP enabled.",
81
+ config: {
82
+ url: "http://127.0.0.1:3845/mcp",
83
+ lifecycle: "lazy",
84
+ directTools: false
85
+ }
86
+ }
87
+ };
88
+
89
+ // Writing a server definition does not make it usable. What each one still needs
90
+ // from the operator is stated here so it can be printed at the moment the
91
+ // definition is written, rather than living only in a description string that
92
+ // shows up under --list.
93
+ /** @type {Record<string, string[]>} */
94
+ export const CATALOG_PREREQUISITES = {
95
+ context7: ["optional: export CONTEXT7_API_KEY for a higher documentation quota"],
96
+ "chrome-devtools": ["a local Chrome installation"],
97
+ github: [
98
+ "Docker running",
99
+ "export GITHUB_PERSONAL_ACCESS_TOKEN with the scopes you intend to use"
100
+ ],
101
+ playwright: ["browsers installed on first use (npx playwright install)"],
102
+ figma: ["Figma OAuth, completed from the Pi MCP panel on first use"],
103
+ "figma-desktop": ["Figma desktop running with Dev Mode MCP enabled"]
104
+ };
105
+
106
+ // Variables a pinned server references but works without. Context7 answers
107
+ // without a key and the key only raises the quota, so reporting it as missing
108
+ // would put a line in front of the operator every session about something that
109
+ // is not wrong. A server somebody adds by hand has no entry here: they typed the
110
+ // reference, so it is treated as one they meant.
111
+ /** @type {Record<string, string[]>} */
112
+ export const CATALOG_OPTIONAL_ENV = {
113
+ context7: ["CONTEXT7_API_KEY"]
114
+ };
115
+
116
+ /** @type {Record<string, string[]>} */
117
+ export const CATALOG_PRESETS = {
118
+ minimal: [],
119
+ docs: ["context7"],
120
+ browser: ["chrome-devtools", "playwright"],
121
+ github: ["github"],
122
+ design: ["figma"],
123
+ "design-local": ["figma-desktop"],
124
+ web: ["context7", "chrome-devtools", "playwright"],
125
+ core: ["context7", "chrome-devtools", "github"],
126
+ popular: ["context7", "chrome-devtools", "playwright", "github", "figma"],
127
+ all: ["context7", "chrome-devtools", "playwright", "github", "figma", "figma-desktop"]
128
+ };
129
+
130
+ /** @param {string} name @returns {boolean} */
131
+ export function isCatalogPreset(name) {
132
+ return Object.hasOwn(CATALOG_PRESETS, name);
133
+ }
134
+
135
+ /**
136
+ * The servers a preset installs, with their definitions resolved. Throws on an
137
+ * unknown preset so a typo is refused before anything is written.
138
+ * @param {string} name
139
+ * @returns {{name: string, config: Record<string, unknown>, requires: string[]}[]}
140
+ */
141
+ export function resolvePreset(name) {
142
+ if (!isCatalogPreset(name)) {
143
+ throw new Error(`unknown preset: ${name}. Available presets: ${Object.keys(CATALOG_PRESETS).join(", ")}`);
144
+ }
145
+ return CATALOG_PRESETS[name].map((serverName) => {
146
+ const server = CATALOG_SERVERS[serverName];
147
+ if (!server) throw new Error(`preset ${name} references missing server ${serverName}`);
148
+ return { name: serverName, config: server.config, requires: CATALOG_PREREQUISITES[serverName] ?? [] };
149
+ });
150
+ }
@@ -0,0 +1,242 @@
1
+ import { containsSensitiveText } from "../security/sensitive-data.js";
2
+
3
+ // Building a server definition from what someone typed. The rule this file
4
+ // exists to hold: a credential never reaches the config file.
5
+ //
6
+ // MCP config is read by several clients, lives in the operator's home or in a
7
+ // repository, and in the `project` scope is meant to be committed. A token typed
8
+ // into `--env` would land in all of those. So a value whose name says credential
9
+ // has to be an environment reference, and the error says which form to use rather
10
+ // than only that the input was refused.
11
+
12
+ const SERVER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
13
+
14
+ // A whole-value reference, `${VAR}`. A value that merely contains one — say
15
+ // `Bearer ${TOKEN}` — is handled separately, because a header legitimately needs
16
+ // the scheme in front of the reference.
17
+ const ENV_REFERENCE = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
18
+ const CONTAINS_ENV_REFERENCE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
19
+
20
+ const CREDENTIAL_NAME_TERMS = [
21
+ "token", "secret", "password", "passwd", "passphrase", "credential",
22
+ "apikey", "api_key", "accesskey", "access_key", "privatekey", "private_key",
23
+ "authorization", "auth", "session", "cookie", "signature", "pat"
24
+ ];
25
+
26
+ export class McpEntryError extends Error {}
27
+
28
+ /**
29
+ * Whether a name says the value beside it is a credential. Deliberately broad:
30
+ * a false positive costs one `${VAR}` indirection, a false negative writes a
31
+ * live token into a file somebody commits.
32
+ * @param {string} name
33
+ * @returns {boolean}
34
+ */
35
+ export function credentialLikeName(name) {
36
+ const normalized = String(name).toLowerCase().replace(/[^a-z0-9]+/g, "_");
37
+ return CREDENTIAL_NAME_TERMS.some((term) => {
38
+ const bare = term.replace(/[^a-z0-9]+/g, "_");
39
+ return normalized === bare || normalized.startsWith(`${bare}_`) || normalized.endsWith(`_${bare}`) || normalized.includes(`_${bare}_`);
40
+ });
41
+ }
42
+
43
+ /**
44
+ * @param {string} raw `KEY=VALUE`
45
+ * @param {"env"|"header"} kind
46
+ * @returns {[string, string]}
47
+ */
48
+ export function parseAssignment(raw, kind) {
49
+ const separator = kind === "header" ? ":" : "=";
50
+ const index = String(raw).indexOf(separator);
51
+ if (index <= 0) {
52
+ throw new McpEntryError(
53
+ kind === "header"
54
+ ? `--header expects "Name: value", received: ${raw}`
55
+ : `--env expects KEY=value, received: ${raw}`
56
+ );
57
+ }
58
+ const name = raw.slice(0, index).trim();
59
+ const value = raw.slice(index + 1).trim();
60
+ if (!name) throw new McpEntryError(`${kind} name cannot be empty`);
61
+ return [name, value];
62
+ }
63
+
64
+ /**
65
+ * Refuse a literal credential, and say what to type instead. `kind` only shapes
66
+ * the message; both channels are held to the same rule.
67
+ * @param {string} name
68
+ * @param {string} value
69
+ * @param {"env"|"header"} kind
70
+ * @returns {void}
71
+ */
72
+ export function assertNoLiteralSecret(name, value, kind) {
73
+ const referenced = kind === "header" ? CONTAINS_ENV_REFERENCE.test(value) : ENV_REFERENCE.test(value);
74
+ if (referenced) return;
75
+
76
+ const suggestion = kind === "header"
77
+ ? `--header '${name}: Bearer \${${suggestedVariable(name)}}'`
78
+ : `--env '${name}=\${${suggestedVariable(name)}}'`;
79
+
80
+ if (credentialLikeName(name)) {
81
+ throw new McpEntryError(
82
+ `${name} names a credential, so its value cannot be written into MCP config. ` +
83
+ `Export it in your shell and reference it instead: ${suggestion}`
84
+ );
85
+ }
86
+ // The name gave nothing away, so the value is read on its own. This catches a
87
+ // token pasted under a neutral name such as `--env CONFIG=ghp_...`.
88
+ if (containsSensitiveText(`${name}=${value}`)) {
89
+ throw new McpEntryError(
90
+ `the value given for ${name} looks like a credential, so it cannot be written into MCP config. ` +
91
+ `Export it in your shell and reference it instead: ${suggestion}`
92
+ );
93
+ }
94
+ }
95
+
96
+ /** @param {string} name @returns {string} */
97
+ function suggestedVariable(name) {
98
+ const upper = String(name).toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
99
+ return upper || "TOKEN";
100
+ }
101
+
102
+ /** @param {string} name @returns {string} */
103
+ export function assertServerName(name) {
104
+ if (!SERVER_NAME.test(String(name ?? ""))) {
105
+ throw new McpEntryError(
106
+ `invalid server name: ${name}. Use letters, digits, dot, dash or underscore, up to 64 characters.`
107
+ );
108
+ }
109
+ return name;
110
+ }
111
+
112
+ /**
113
+ * @typedef {object} BuildEntryInput
114
+ * @property {string} name
115
+ * @property {string} [url]
116
+ * @property {string[]} [command] argv, first element is the executable
117
+ * @property {string[]} [env] raw `KEY=value`
118
+ * @property {string[]} [header] raw `Name: value`
119
+ * @property {string} [bearerTokenEnvVar]
120
+ * @property {string} [oauthClientId]
121
+ * @property {string} [oauthScopes]
122
+ * @property {boolean} [directTools]
123
+ */
124
+
125
+ /**
126
+ * @param {BuildEntryInput} input
127
+ * @returns {Record<string, unknown>}
128
+ */
129
+ export function buildServerEntry(input) {
130
+ assertServerName(input.name);
131
+ const hasUrl = typeof input.url === "string" && input.url.length > 0;
132
+ const hasCommand = Array.isArray(input.command) && input.command.length > 0;
133
+ if (hasUrl === hasCommand) {
134
+ throw new McpEntryError("give either --url for a remote server or -- <command> for a local one, not both and not neither");
135
+ }
136
+
137
+ /** @type {Record<string, unknown>} */
138
+ const entry = { lifecycle: "lazy", directTools: input.directTools === true };
139
+
140
+ if (hasUrl) {
141
+ const url = parseRemoteUrl(input.url ?? "");
142
+ entry.url = url.href;
143
+ if (input.oauthClientId || input.oauthScopes) {
144
+ /** @type {Record<string, unknown>} */
145
+ const oauth = {};
146
+ if (input.oauthClientId) oauth.clientId = input.oauthClientId;
147
+ if (input.oauthScopes) oauth.scopes = input.oauthScopes;
148
+ entry.auth = "oauth";
149
+ entry.oauth = oauth;
150
+ }
151
+ if (input.bearerTokenEnvVar) {
152
+ // The variable name, never the token. Whoever reads the config learns which
153
+ // variable to export and nothing else.
154
+ entry.bearerTokenEnvVar = assertEnvVarName(input.bearerTokenEnvVar);
155
+ }
156
+ const headers = collectAssignments(input.header ?? [], "header");
157
+ if (Object.keys(headers).length > 0) entry.headers = headers;
158
+ } else {
159
+ const argv = input.command ?? [];
160
+ entry.command = argv[0];
161
+ if (argv.length > 1) entry.args = argv.slice(1);
162
+ const env = collectAssignments(input.env ?? [], "env");
163
+ if (Object.keys(env).length > 0) entry.env = env;
164
+ if (input.header?.length) throw new McpEntryError("--header applies to remote servers; a local server takes --env");
165
+ if (input.bearerTokenEnvVar) throw new McpEntryError("--bearer-token-env-var applies to remote servers only");
166
+ }
167
+
168
+ return entry;
169
+ }
170
+
171
+ /** @param {string[]} raw @param {"env"|"header"} kind @returns {Record<string, string>} */
172
+ function collectAssignments(raw, kind) {
173
+ /** @type {Record<string, string>} */
174
+ const out = {};
175
+ for (const item of raw) {
176
+ const [name, value] = parseAssignment(item, kind);
177
+ assertNoLiteralSecret(name, value, kind);
178
+ out[name] = value;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /** @param {string} value @returns {string} */
184
+ function assertEnvVarName(value) {
185
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
186
+ throw new McpEntryError(`--bearer-token-env-var expects a variable name, received: ${value}`);
187
+ }
188
+ return value;
189
+ }
190
+
191
+ /**
192
+ * A remote server has to be reachable over a transport this platform is willing
193
+ * to describe as one. `http://` is allowed only on the loopback address, where
194
+ * there is no network to intercept — Figma's desktop server is exactly that case.
195
+ * @param {string} value
196
+ * @returns {URL}
197
+ */
198
+ export function parseRemoteUrl(value) {
199
+ let url;
200
+ try {
201
+ url = new URL(value);
202
+ } catch {
203
+ throw new McpEntryError(`--url is not a valid URL: ${value}`);
204
+ }
205
+ if (url.protocol === "https:") return url;
206
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url;
207
+ throw new McpEntryError(
208
+ `--url must be https, or http on localhost. Received: ${url.protocol}//${url.hostname}`
209
+ );
210
+ }
211
+
212
+ /** @param {string} hostname @returns {boolean} */
213
+ function isLoopbackHost(hostname) {
214
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
215
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
216
+ }
217
+
218
+ /**
219
+ * A server entry with every value that could be a credential replaced. Used for
220
+ * display, so `list` and `get` can be pasted into an issue.
221
+ * @param {Record<string, unknown>} entry
222
+ * @returns {Record<string, unknown>}
223
+ */
224
+ export function maskServerEntry(entry) {
225
+ /** @type {Record<string, unknown>} */
226
+ const out = {};
227
+ for (const [key, value] of Object.entries(entry)) {
228
+ if ((key === "env" || key === "headers") && value && typeof value === "object" && !Array.isArray(value)) {
229
+ out[key] = Object.fromEntries(
230
+ Object.entries(/** @type {Record<string, unknown>} */ (value)).map(([name, item]) => [
231
+ name,
232
+ // An environment reference is the name of a variable, not its contents,
233
+ // so showing it is the point. Anything else is treated as a value.
234
+ typeof item === "string" && ENV_REFERENCE.test(item) ? item : "*****"
235
+ ])
236
+ );
237
+ continue;
238
+ }
239
+ out[key] = value;
240
+ }
241
+ return out;
242
+ }
@@ -0,0 +1,185 @@
1
+ import { REPOSITORY_SCOPES, SCOPES, collectServers } from "./mcp-config-layers.js";
2
+ import { approvalState } from "./mcp-approval-store.js";
3
+ import { maskServerEntry } from "./mcp-server-entry.js";
4
+ import { evaluateServerReadiness } from "./mcp-auth-readiness.js";
5
+ import { CATALOG_PREREQUISITES } from "./mcp-server-catalog.js";
6
+
7
+ // What the terminal CLI and the in-session `/piagent-mcp` command both show.
8
+ //
9
+ // The same question gets asked from two places — a shell prompt and a Pi
10
+ // session — and two answers that disagree about whether a server is usable is
11
+ // worse than either answer alone. So the reading, the states and the wording
12
+ // live here once, and both surfaces print what this returns.
13
+ //
14
+ // Read-only by construction: nothing here writes a config or an approval.
15
+
16
+ /** @typedef {import("./mcp-auth-readiness.js").ReadinessState} ReadinessState */
17
+
18
+ export class McpViewError extends Error {}
19
+
20
+ /**
21
+ * The stdio command line, or the remote URL, shortened for a table cell.
22
+ * @param {Record<string, unknown>} entry
23
+ * @param {number} [limit]
24
+ * @returns {string}
25
+ */
26
+ export function describeTarget(entry, limit = 56) {
27
+ if (typeof entry.url === "string") return entry.url;
28
+ const argv = [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])].filter((item) => typeof item === "string");
29
+ const text = argv.join(" ");
30
+ return text.length > limit ? `${text.slice(0, limit - 3)}...` : text;
31
+ }
32
+
33
+ /**
34
+ * Every server visible from a project, with its readiness resolved.
35
+ * @param {{projectPath: string, scopes?: string[]}} options
36
+ */
37
+ export function collectServerRows(options) {
38
+ const { projectPath, scopes } = options;
39
+ if (scopes) {
40
+ for (const scope of scopes) {
41
+ if (!SCOPES.includes(scope)) throw new McpViewError(`unsupported scope: ${scope}`);
42
+ }
43
+ }
44
+ return collectServers({ projectPath, scopes }).map((server) => ({
45
+ ...server,
46
+ transport: typeof server.entry.url === "string" ? "http" : "stdio",
47
+ target: describeTarget(server.entry),
48
+ readiness: evaluateServerReadiness(server, { projectPath })
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Servers that will not answer a tool call. `oauth` is excluded: it may already
54
+ * be signed in, and this cannot tell.
55
+ * @param {ReturnType<typeof collectServerRows>} rows
56
+ */
57
+ export function blockedRows(rows) {
58
+ return rows.filter((row) => row.readiness.state !== "ready" && row.readiness.state !== "oauth");
59
+ }
60
+
61
+ /**
62
+ * One named server, resolved to a single scope. `listHint` is the caller's own
63
+ * way of listing servers, so the error names a command the reader can actually
64
+ * run from where they are.
65
+ *
66
+ * @param {{projectPath: string, name: string, scope?: string, listHint?: string, scopeHint?: string}} options
67
+ */
68
+ export function describeServer(options) {
69
+ const { projectPath, name, scope, listHint, scopeHint = "--scope" } = options;
70
+ const matches = collectServerRows({ projectPath, scopes: scope ? [scope] : undefined })
71
+ .filter((row) => row.name === name);
72
+ if (matches.length === 0) {
73
+ throw new McpViewError(scope
74
+ ? `no server named ${name} in ${scope} scope`
75
+ : `no server named ${name}.${listHint ? ` Run \`${listHint}\` to see what is configured.` : ""}`);
76
+ }
77
+ if (matches.length > 1) {
78
+ throw new McpViewError(
79
+ `${name} is defined in more than one scope (${matches.map((row) => row.scope).join(", ")}). ` +
80
+ `Pass ${scopeHint} to say which one.`
81
+ );
82
+ }
83
+ const row = matches[0];
84
+ const approval = REPOSITORY_SCOPES.has(row.scope)
85
+ ? approvalState({ projectPath, name, entry: row.entry })
86
+ : undefined;
87
+ return { ...row, masked: maskServerEntry(row.entry), approval };
88
+ }
89
+
90
+ /**
91
+ * Readiness remedies are written as terminal commands, because that is where
92
+ * every one of them can be run. Inside a session the same step is a slash
93
+ * command, and telling a reader to shell out for something they can do where
94
+ * they are standing is how a surface ends up feeling harder than it is.
95
+ *
96
+ * @param {string | undefined} remedy
97
+ * @param {boolean} inSession
98
+ * @returns {string | undefined}
99
+ */
100
+ export function remedyFor(remedy, inSession) {
101
+ if (!remedy || !inSession) return remedy;
102
+ return remedy.replace(/(^|[\s`])piagent-mcp /g, "$1/piagent-mcp ");
103
+ }
104
+
105
+ /** Left-aligned columns sized to their contents. */
106
+ function table(headers, rows) {
107
+ const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((cells) => cells[i].length)));
108
+ const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
109
+ return [line(headers), ...rows.map(line)].join("\n");
110
+ }
111
+
112
+ /**
113
+ * @param {ReturnType<typeof collectServerRows>} rows
114
+ * @returns {string}
115
+ */
116
+ export function formatServerTable(rows) {
117
+ if (rows.length === 0) return "";
118
+ return table(
119
+ ["NAME", "SCOPE", "TRANSPORT", "TARGET", "STATE"],
120
+ rows.map((row) => [row.name, row.scope, row.transport, row.target, row.readiness.state])
121
+ );
122
+ }
123
+
124
+ /** Fields worth showing; the rest of an entry is noise in a status view. */
125
+ const DETAIL_FIELDS = ["url", "command", "args", "env", "headers", "auth", "bearerTokenEnvVar"];
126
+
127
+ /**
128
+ * @param {ReturnType<typeof describeServer>} detail
129
+ * @param {{inSession?: boolean}} [options]
130
+ * @returns {string[]}
131
+ */
132
+ export function formatServerDetail(detail, options = {}) {
133
+ const lines = [
134
+ detail.name,
135
+ ` scope: ${detail.scope}`,
136
+ ` file: ${detail.file}`,
137
+ ` transport: ${detail.transport}`
138
+ ];
139
+ for (const key of DETAIL_FIELDS) {
140
+ const item = detail.masked[key];
141
+ if (item === undefined) continue;
142
+ lines.push(` ${key}: ${typeof item === "string" ? item : JSON.stringify(item)}`);
143
+ }
144
+ lines.push(` state: ${detail.readiness.state} (${detail.readiness.detail})`);
145
+ const next = remedyFor(detail.readiness.remedy, options.inSession === true);
146
+ if (next) lines.push(` next: ${next}`);
147
+ if (detail.approval) lines.push(` approval: ${detail.approval.state} (${detail.approval.digest})`);
148
+ return lines;
149
+ }
150
+
151
+ /**
152
+ * The readiness report, one line per server plus the remedy for anything stuck.
153
+ * `dockerRunning` is passed in rather than probed: the probe spawns a process
154
+ * and only the caller knows whether it can afford that.
155
+ *
156
+ * @param {ReturnType<typeof collectServerRows>} rows
157
+ * @param {{dockerRunning?: boolean, rerun?: string, inSession?: boolean}} [options]
158
+ * @returns {{lines: string[], problems: number}}
159
+ */
160
+ export function formatDoctorReport(rows, options = {}) {
161
+ if (rows.length === 0) return { lines: ["No MCP servers configured."], problems: 0 };
162
+
163
+ const lines = [];
164
+ let problems = 0;
165
+ for (const row of rows) {
166
+ const usable = row.readiness.state === "ready" || row.readiness.state === "oauth";
167
+ if (!usable) problems += 1;
168
+ lines.push(`${usable ? "OK " : "NEED"} ${row.name} (${row.scope}): ${row.readiness.detail}`);
169
+ const remedy = remedyFor(row.readiness.remedy, options.inSession === true);
170
+ if (remedy) lines.push(` -> ${remedy}`);
171
+ }
172
+ if (options.dockerRunning === false) {
173
+ problems += 1;
174
+ lines.push("NEED docker: installed but the daemon is not responding");
175
+ lines.push(` -> start Docker Desktop, then re-run ${options.rerun ?? "the check"}`);
176
+ }
177
+ const prerequisites = rows.flatMap((row) => (CATALOG_PREREQUISITES[row.name] ?? []).map((item) => ` ${row.name}: ${item}`));
178
+ if (prerequisites.length > 0) lines.push("", "Pinned servers also expect:", ...prerequisites);
179
+ return { lines, problems };
180
+ }
181
+
182
+ /** True when any configured server runs through the docker CLI. */
183
+ export function usesDocker(rows) {
184
+ return rows.some((row) => row.entry.command === "docker");
185
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.1.9",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -37,8 +37,12 @@
37
37
  "typebox": "1.1.38"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
- "@earendil-works/pi-coding-agent": { "optional": true },
41
- "typebox": { "optional": true }
40
+ "@earendil-works/pi-coding-agent": {
41
+ "optional": true
42
+ },
43
+ "typebox": {
44
+ "optional": true
45
+ }
42
46
  },
43
47
  "engines": {
44
48
  "node": ">=22.19.0"
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Explain native Pi model selector/scope options for Codex and Claude
2
+ description: Explain native Pi model selector/scope options across model families
3
3
  argument-hint: "[task type, budget, or provider preference]"
4
4
  ---
5
5
 
@@ -18,15 +18,15 @@ Mandatory flow:
18
18
  - `/scoped-models` for cycle scope;
19
19
  - Ctrl+P / Shift+Ctrl+P for model cycling;
20
20
  - Shift+Tab for thinking level.
21
- 4. Include both OpenAI Codex and Claude/Anthropic options unless the user explicitly restricts provider.
22
- 5. Do not limit the answer to one or two legacy examples. Include the current latest-family options for Codex 5.5/5.6 and Claude Sonnet/Opus/Fable where relevant.
21
+ 4. Include both model families — the OpenAI Codex models and the Anthropic Claude models — unless the user explicitly restricts the provider. Name them as model families, never as another tool.
22
+ 5. Do not limit the answer to one or two legacy examples. Include the current latest-family options: the Codex 5.5/5.6 models and the Claude Sonnet/Opus/Fable models, where relevant.
23
23
  6. If the user needs available model versions, recommend `piagent-models`, `/model`, or `pi --list-models`.
24
24
  7. Explain thinking levels as task effort, not as quality guarantee.
25
25
  8. Do not claim token/cost savings without benchmark evidence.
26
26
 
27
27
  Current latest-family catalog to consider:
28
28
 
29
- OpenAI Codex:
29
+ OpenAI Codex models (provider `openai-codex`):
30
30
 
31
31
  - `openai-codex/gpt-5.3-codex-spark`
32
32
  - `openai-codex/gpt-5.4-mini`
@@ -36,7 +36,7 @@ OpenAI Codex:
36
36
  - `openai-codex/gpt-5.6-sol`
37
37
  - `openai-codex/gpt-5.6-terra`
38
38
 
39
- Claude/Anthropic:
39
+ Anthropic Claude models (provider `anthropic`):
40
40
 
41
41
  - `anthropic/claude-haiku-4-5`
42
42
  - `anthropic/claude-sonnet-4-5`
@@ -50,7 +50,7 @@ Claude/Anthropic:
50
50
 
51
51
  Default selector scope seeded by setup:
52
52
 
53
- | Preset | OpenAI Codex | Claude/Anthropic | Use when |
53
+ | Preset | OpenAI Codex model | Anthropic Claude model | Use when |
54
54
  |---|---|---|---|
55
55
  | Fast scout | `openai-codex/gpt-5.4-mini:low` | `anthropic/claude-haiku-4-5:low` | quick reads, simple Q&A |
56
56
  | Balanced implement | `openai-codex/gpt-5.5:medium` | `anthropic/claude-sonnet-5:medium` | normal source tasks |
@@ -63,6 +63,6 @@ Output:
63
63
  - Current model/context if available.
64
64
  - How to open/select/change model in Pi UI.
65
65
  - What `enabledModels`/scope contains.
66
- - Codex family options.
67
- - Claude family options.
66
+ - Codex model family options.
67
+ - Claude model family options.
68
68
  - How to inspect/re-apply model scope from terminal.