@protoboardai/cli 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * The Protoboard MCP server's tools (PB-930, docs/specs/agents-and-mcp.md
3
+ * §2.3). Each is a thin, typed wrapper over a CLI command: it runs the CLI
4
+ * as a child process and returns the JSON it printed, so the CLI's own
5
+ * command layer (the sign-in gate included) decides what happens, and a
6
+ * project's own CLI version is the one that works on it.
7
+ *
8
+ * The tools are a list of specifications. `cliTools()` is the only source
9
+ * today; a bridge to a VS Code window would add a second one beside it
10
+ * (§7), with handlers that talk to the window instead of the CLI.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { z, type ZodRawShape } from "zod";
16
+ import { LIBRARY_SORTS, findProjectRoot, packageRoot } from "@protoboardai/core";
17
+
18
+ /** Whether a project's packages are installed: the framework resolves from it (its own node_modules, or a workspace's further up). */
19
+ const installedIn = (root: string) => !!packageRoot(root, "@protoboardai/core");
20
+ import { DESCRIPTION_LIMIT } from "./instructions.js";
21
+
22
+ /** What a tool hands back: data (sent as JSON text) and whether it is a failure. */
23
+ export interface ToolOutcome {
24
+ data: unknown;
25
+ isError?: boolean;
26
+ }
27
+
28
+ export interface CliRun {
29
+ code: number | null;
30
+ stdout: string;
31
+ stderr: string;
32
+ }
33
+
34
+ /** What a tool's handler works with. */
35
+ export interface ToolContext {
36
+ /** The folder a tool works in: its `dir` argument resolved against the server's folder. */
37
+ dir(given?: string): Promise<string>;
38
+ /** Run the CLI in a folder: the project's own when it has one, else the one carrying the server. */
39
+ cli(dir: string, args: string[], options?: { timeoutMs?: number }): Promise<CliRun>;
40
+ /** Which CLI `cli` would run for a folder. */
41
+ cliFor(dir: string): { entry: string; from: "project" | "server" };
42
+ /** The server's own version and instructions. */
43
+ version: string;
44
+ about: string;
45
+ }
46
+
47
+ export interface ToolSpec<S extends ZodRawShape = ZodRawShape> {
48
+ name: string;
49
+ title: string;
50
+ description: string;
51
+ input: S;
52
+ /** MCP tool annotations: what a client may tell the user before running it. */
53
+ annotations: { readOnlyHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint: boolean };
54
+ handler(args: z.objectOutputType<S, z.ZodTypeAny>, ctx: ToolContext): Promise<ToolOutcome>;
55
+ }
56
+
57
+ const tool = <S extends ZodRawShape>(spec: ToolSpec<S>): ToolSpec => spec as unknown as ToolSpec;
58
+
59
+ /** What every tool's description ends with, and why none is marked read-only. */
60
+ export const RUNS_PROJECT_CODE = "It runs the project's own code (protoboard.config.ts and its model files) to do so.";
61
+
62
+ const DIR = z.string().optional().describe("The project folder, absolute or relative to the folder the agent works in. Default: that folder. A client that gives the server no folder (a desktop app) starts it somewhere else: pass an absolute path then.");
63
+
64
+ const SIGN_IN = /protoboard login|not signed in|sign[- ]in (is )?(required|needed)|needs? (a|an|your)? ?(signed-in account|sign-in|account)|sign in (first|again|to )/i;
65
+ /** How the user signs in. The extension's route comes first: a user who has only the extension has no `protoboard` command outside a VS Code terminal. */
66
+ export const SIGN_IN_ACTION =
67
+ 'Ask the user to sign in, then call this tool again. In VS Code: the command "Protoboard: Sign In to Protoboard Cloud" (or the account on the page "Protoboard: Open Settings"). In a terminal: `protoboard login` where that command exists (a VS Code terminal, or a machine with the CLI installed), or `npx --no-install protoboard login` in a project whose packages are installed; `--device` gives a code in place of a browser. Do not sign in for them, and never write a sign-in to a file.';
68
+
69
+ /** The JSON a `--format json` command printed, or why there is none. */
70
+ export function readCliJson(r: CliRun): { ok: true; data: unknown } | { ok: false; message: string; code?: string } {
71
+ let data: unknown;
72
+ try {
73
+ data = JSON.parse(r.stdout);
74
+ } catch {
75
+ // commands that print lines of JSON (none of the tools' do) or nothing at all
76
+ const detail = (r.stderr.trim() || r.stdout.trim()).split("\n").slice(-6).join("\n").replace(/^protoboard: /, "");
77
+ return { ok: false, message: detail || `the command printed nothing (exit ${r.code})` };
78
+ }
79
+ // a failed command prints {"error": …}; the sign-in gate adds its code (SIGN_IN_REQUIRED) and what it found
80
+ const d = data as { error?: unknown; code?: unknown } | null;
81
+ if (d && typeof d === "object" && !Array.isArray(d) && typeof d.error === "string" && (Object.keys(d).length === 1 || typeof d.code === "string")) {
82
+ return { ok: false, message: d.error, ...(typeof d.code === "string" ? { code: d.code } : {}) };
83
+ }
84
+ return { ok: true, data };
85
+ }
86
+
87
+ /** A failed command as a tool result: a sign-in the user has to do is a normal result an agent can act on. */
88
+ export function failure(message: string, dir: string, code?: string): ToolOutcome {
89
+ if (code === "SIGN_IN_REQUIRED" || SIGN_IN.test(message)) return { data: { signInRequired: true, message, action: SIGN_IN_ACTION } };
90
+ const root = findProjectRoot(dir);
91
+ const hint = !root
92
+ ? /no protoboard\.config\.ts|not a protoboard project|protoboard init/i.test(message)
93
+ ? "This folder is not a Protoboard project. Call project_status; project_init creates one."
94
+ : undefined
95
+ : !installedIn(root)
96
+ ? `The project's packages are not set up on this machine. Run \`protoboard stack link\` in ${root} (it links them to the Protoboard installed here; no npm), or open the folder in VS Code with the Protoboard extension, then call this tool again.`
97
+ : undefined;
98
+ return { data: { error: message, ...(hint ? { hint } : {}) }, isError: true };
99
+ }
100
+
101
+ /**
102
+ * A command line for the CLI from a tool's arguments. What an agent gave can
103
+ * never be read as an option of the CLI: an option's value is joined to its
104
+ * name (`--library=<value>`) and may not start with a dash, and everything
105
+ * that is not an option (search words, part ids, an instance path, a folder)
106
+ * comes after `--`, where the CLI reads no options at all.
107
+ */
108
+ export function cliArgs(command: string[], options: Record<string, string | number | undefined> = {}, positionals: string[] = []): string[] {
109
+ const flags: string[] = [];
110
+ for (const [name, value] of Object.entries(options)) {
111
+ if (value === undefined || value === "") continue;
112
+ const text = String(value);
113
+ if (typeof value === "string" && /^\s*-/.test(text)) throw new Error(`\`${name}\` cannot start with a dash ("${text.slice(0, 40)}"): it would be read as an option of the CLI, and a tool's arguments are values only`);
114
+ if (/[\r\n\0]/.test(text)) throw new Error(`\`${name}\` cannot hold a line break`);
115
+ flags.push(`--${name}=${text}`);
116
+ }
117
+ const words = positionals.filter((w) => w !== "");
118
+ for (const w of words) if (/\0/.test(w)) throw new Error("an argument cannot hold a NUL character");
119
+ return [...command, ...flags, "--format=json", "--", ...words];
120
+ }
121
+
122
+ /** Run one command and return its JSON, or its failure. `args` is a `cliArgs` line. `map` shapes the data. */
123
+ async function command(ctx: ToolContext, dir: string, args: string[], map: (data: unknown) => unknown = (d) => d): Promise<ToolOutcome> {
124
+ const r = readCliJson(await ctx.cli(dir, args));
125
+ return r.ok ? { data: map(r.data) } : failure(r.message, dir, r.code);
126
+ }
127
+
128
+ /**
129
+ * The bill of materials as an agent reads it (PB-933's model): every line
130
+ * with the offer chosen for it and its quote, without the list of every
131
+ * other offer and quote (most of the text of a real project's bill), the
132
+ * whole rate table (the rates used stay), fields that hold nothing, and
133
+ * what a line repeats (its definition when that is its key, its group, its
134
+ * single instance).
135
+ */
136
+ export function compactBom(data: unknown): unknown {
137
+ if (!data || typeof data !== "object") return data;
138
+ const bom = data as { groups?: { lines?: Record<string, unknown>[] }[]; fx?: Record<string, unknown> };
139
+ const held = (v: unknown): boolean => v !== null && v !== undefined && !(Array.isArray(v) && !v.length);
140
+ const prune = (o: Record<string, unknown>): Record<string, unknown> => Object.fromEntries(Object.entries(o).filter(([, v]) => held(v)));
141
+ const line = ({ offers, quotes: _quotes, quote, group: _group, definition, instances, ...rest }: Record<string, unknown>) =>
142
+ prune({
143
+ ...rest,
144
+ ...(definition !== rest.key ? { definition } : {}),
145
+ ...(Array.isArray(instances) && instances.length > 1 ? { instances } : {}),
146
+ ...(quote && typeof quote === "object" ? { quote: prune(quote as Record<string, unknown>) } : {}),
147
+ offerCount: Array.isArray(offers) ? offers.length : 0,
148
+ });
149
+ const { rates: _rates, ...fx } = bom.fx ?? {};
150
+ return {
151
+ ...bom,
152
+ ...(bom.fx ? { fx } : {}),
153
+ ...(Array.isArray(bom.groups) ? { groups: bom.groups.map((g) => ({ ...g, ...(Array.isArray(g.lines) ? { lines: g.lines.map(line) } : {}) })) } : {}),
154
+ };
155
+ }
156
+
157
+ const SEVERITY = ["error", "warning", "info"] as const;
158
+
159
+ export function cliTools(templates: string[]): ToolSpec[] {
160
+ const specs: ToolSpec[] = [
161
+ tool({
162
+ name: "project_status",
163
+ title: "Protoboard status",
164
+ description:
165
+ "Call this first. Says what Protoboard is (`about`), whether the folder is a Protoboard project, which versions are in use, whether the user is signed in to the parts library (the library needs it, searching included; work on the project does not), and in a project what `protoboard doctor` finds (extensions, tools, runtimes, skills); outside one, the templates a project can start from. `next` lists what to do. Reads only.",
166
+ input: { dir: DIR },
167
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
168
+ async handler({ dir: given }, ctx) {
169
+ const dir = await ctx.dir(given);
170
+ const root = findProjectRoot(dir);
171
+ const used = ctx.cliFor(dir);
172
+ const who = readCliJson(await ctx.cli(dir, cliArgs(["whoami"])));
173
+ const account = who.ok ? who.data : { error: who.message };
174
+ // a project's own CLI from before 0.2.0 refuses every command signed out (it reports `access.required`): that comes first
175
+ const access = who.ok ? (who.data as { access?: { required?: boolean; allowed?: boolean; message?: string } }).access : undefined;
176
+ const olderGate = (!who.ok && who.code === "SIGN_IN_REQUIRED") || (access?.required === true && access.allowed === false);
177
+ const signedIn = who.ok && (who.data as { signedIn?: boolean }).signedIn === true;
178
+ const open = who.ok && (who.data as { needsAccount?: boolean }).needsAccount === false;
179
+ const next: string[] = [];
180
+ let project: unknown = null;
181
+ let found: unknown;
182
+ if (root) {
183
+ const installed = installedIn(root);
184
+ const doctor = installed ? readCliJson(await ctx.cli(root, cliArgs(["doctor"]))) : undefined;
185
+ project = { root, installed, agentsFile: existsSync(join(root, "AGENTS.md")) ? join(root, "AGENTS.md") : null, ...(doctor ? (doctor.ok ? { doctor: doctor.data } : { doctorError: doctor.message }) : {}) };
186
+ if (!installed) next.push(`Run \`protoboard stack link\` in ${root}: the project's packages are not set up on this machine, so nothing else works yet (it links them to the Protoboard installed here; never \`npm install\`).`);
187
+ next.push("Read AGENTS.md in the project, then the build-a-project skill (skill_read with id build-a-project).", "Run project_check before changing anything, and after every change.");
188
+ } else {
189
+ const list = readCliJson(await ctx.cli(dir, cliArgs(["init", "--list"])));
190
+ found = list.ok ? (list.data as { templates?: unknown }).templates : { error: list.message };
191
+ next.push(
192
+ `This folder is not a Protoboard project. Create one with project_init: \`dir\` is the folder to create ("." for this folder when it is empty), \`template\` one of ${templates.join(", ") || "(none installed)"} or left out for a blank project.`,
193
+ "Then call project_status with the new folder as `dir`: the project uses the Protoboard installed on this machine, with nothing to install.",
194
+ );
195
+ }
196
+ // a client with no workspace (a desktop app) starts servers in / or the home folder: that is not where the user works
197
+ if (!given && !root && (dir === homedir() || dirname(dir) === dir)) {
198
+ next.unshift(`No folder was given and this server was started in ${dir}, which is not a folder the user chose. Ask the user which folder to work in, and pass it as an absolute \`dir\` to every tool (project_init included; never ".").`);
199
+ }
200
+ // only the cloud needs an account (PB-928): said once, at the end, never as a blocker
201
+ if (olderGate) next.unshift(`${access?.message ?? "This project's Protoboard needs a sign-in before anything else works."} ${SIGN_IN_ACTION}`);
202
+ else if (!signedIn && !open) next.push(`The user is not signed in. Everything on the project works without an account (check, build, layout, the viewer, release). The Protoboard library needs one, browsing included: searching it (parts_search), adding library parts, update checks, prices and publishing. When a tool says so (signInRequired), ${SIGN_IN_ACTION.charAt(0).toLowerCase()}${SIGN_IN_ACTION.slice(1)}`);
203
+ return { data: { about: ctx.about, protoboard: { version: ctx.version, cli: used.entry, cliFrom: used.from }, folder: dir, project, account, ...(found !== undefined ? { templates: found } : {}), next } };
204
+ },
205
+ }),
206
+ tool({
207
+ name: "project_init",
208
+ title: "Create a Protoboard project",
209
+ description:
210
+ `Creates a Protoboard project (\`protoboard init\`). CHANGES FILES: writes the project's folder (protoboard.config.ts, package.json, AGENTS.md, CLAUDE.md, src/, parts/, docs/) and, for a template, downloads its catalog parts into parts/catalog (that part needs a sign-in; without one the project is still created and the result says which command adds them). Refuses a folder that already has a project. Templates: ${templates.join(", ") || "none installed"}; leave \`template\` out for a blank project. It uses the Protoboard installed on this machine (its packages are links to it) and exports the skills: there is nothing to install afterwards, and you never run \`npm install\`. A folder that holds only a CLAUDE.md (a workspace made it) is used, and its CLAUDE.md kept. Publishes nothing.`,
211
+ input: {
212
+ dir: z.string().describe('The folder to create the project in, absolute or relative to the folder the agent works in. "." is that folder itself.'),
213
+ template: z.string().optional().describe(`A starting system: ${templates.join(", ") || "none installed"}. Left out: a blank project.`),
214
+ name: z.string().optional().describe("The project's name. Default: the folder's name."),
215
+ },
216
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
217
+ async handler({ dir: target, template, name }, ctx) {
218
+ const base = await ctx.dir();
219
+ const where = resolve(base, target);
220
+ const r = readCliJson(await ctx.cli(base, cliArgs(["init"], { template, name }, [where]), { timeoutMs: 300_000 }));
221
+ if (!r.ok) return failure(r.message, base, r.code);
222
+ const d = r.data as { stackMode?: string; stack?: { linked?: string[] }; stackError?: string; partsSkipped?: string; partsCommand?: string };
223
+ const setUp = d.stack?.linked?.length
224
+ ? []
225
+ : d.stackMode === "project"
226
+ ? [`This project installs its own packages (a project-local stack): ask the user before running \`npm install\` in ${where}.`]
227
+ : [`Its packages were not linked${d.stackError ? ` (${d.stackError})` : ""}: run \`protoboard stack link\` in ${where}, or open it in VS Code with the Protoboard extension.`];
228
+ // signed out, a template's library parts wait for a sign-in; the project itself is made and works
229
+ const parts = d.partsSkipped ? [`The template's library parts were not added: ${d.partsSkipped} Work on the project meanwhile; once the user has signed in, call parts_add with them (\`${d.partsCommand}\`). ${SIGN_IN_ACTION}`] : [];
230
+ return { data: { ...(r.data as object), next: [...setUp, ...parts, "Call project_status with that folder as `dir`, and read its AGENTS.md."] } };
231
+ },
232
+ }),
233
+ tool({
234
+ name: "project_check",
235
+ title: "Check the project",
236
+ description:
237
+ "Resolves the project and runs every available validator (`protoboard check`). Returns the validators that ran or were skipped, the diagnostics at or above `severity` (default warning) with their code, message and source location, and the counts of all of them. `ok` is false when there are errors. Run it before and after every change. A passing check validates the model only; it does not establish physical readiness. Reads only.",
238
+ input: { dir: DIR, severity: z.enum(SEVERITY).optional().describe("The lowest severity to return: error, warning (default) or info.") },
239
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
240
+ async handler({ dir: given, severity }, ctx) {
241
+ const dir = await ctx.dir(given);
242
+ const rank = SEVERITY.indexOf(severity ?? "warning");
243
+ return command(ctx, dir, cliArgs(["check"]), (data) => {
244
+ const d = data as { checks?: unknown; diagnostics?: { severity: (typeof SEVERITY)[number] }[] };
245
+ const all = d.diagnostics ?? [];
246
+ const counts = Object.fromEntries(SEVERITY.map((s) => [s, all.filter((x) => x.severity === s).length]));
247
+ const shown = all.filter((x) => SEVERITY.indexOf(x.severity) <= rank);
248
+ return { ok: !counts.error, counts, checks: d.checks, diagnostics: shown, ...(shown.length < all.length ? { notShown: `${all.length - shown.length} below ${severity ?? "warning"}; pass severity: "info" for all` } : {}) };
249
+ });
250
+ },
251
+ }),
252
+ tool({
253
+ name: "project_inspect",
254
+ title: "Inspect the system or an instance",
255
+ description:
256
+ "The system as it resolved (`protoboard inspect`): instances, definitions by tier (catalog, project-researched, project-specific), links, artifacts and what each validator found. With `instance` (a path such as board/mcu or arm_fl/motor): that instance's definition, origin and pinned revision, interfaces, children, links, artifacts and diagnostics. Reads only.",
257
+ input: { dir: DIR, instance: z.string().optional().describe("An instance path, as the summary lists them. Left out: the whole system.") },
258
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
259
+ handler: async ({ dir, instance }, ctx) => command(ctx, await ctx.dir(dir), cliArgs(["inspect"], {}, instance ? [instance] : [])),
260
+ }),
261
+ tool({
262
+ name: "project_build",
263
+ title: "Build the project",
264
+ description:
265
+ "Writes the resolved project and the UHD bundle the viewer reads (`protoboard build`). CHANGES FILES: only derived data under .protoboard/build/ in the project, which is rebuilt from the sources every time and is not committed. Returns the files written and the diagnostic counts. Publishes nothing.",
266
+ input: { dir: DIR },
267
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
268
+ handler: async ({ dir }, ctx) => command(ctx, await ctx.dir(dir), cliArgs(["build"])),
269
+ }),
270
+ tool({
271
+ name: "project_layout",
272
+ title: "Measure and tidy the diagram's layout",
273
+ description:
274
+ "The diagram's layout measured level by level, as the viewer draws it (`protoboard layout`): line crossings, overlapping cards, the longest row or column of cards (a strip), the aspect ratio, lines under cards, and what to fix. Run it before you call a build done, and fix what it reports. `mode: \"apply\"` lays the level(s) out afresh and `mode: \"tidy\"` keeps the saved arrangement and fixes it; either CHANGES FILES: it writes the diagram's layout to .protoboard/display.json (the one file under .protoboard/ an agent may write), and only for levels where the result reads clearly better and that already have a saved layout. `pin` keeps cards where they are. Look at the result in the viewer afterwards.",
275
+ input: {
276
+ dir: DIR,
277
+ level: z.string().optional().describe("One level: the instance path of the module it shows (board, arm_fl). Left out: every level, the top included."),
278
+ mode: z.enum(["report", "apply", "tidy"]).optional().describe("report (default) writes nothing; apply lays out afresh; tidy fixes the saved arrangement."),
279
+ pin: z.array(z.string()).optional().describe("Cards (node ids on the level) that keep their places."),
280
+ },
281
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
282
+ handler: async ({ dir, level, mode, pin }, ctx) =>
283
+ command(ctx, await ctx.dir(dir), cliArgs(["layout", ...(mode === "apply" ? ["--apply"] : mode === "tidy" ? ["--tidy"] : [])], { pin: pin?.length ? pin.join(",") : undefined }, level ? [level] : [])),
284
+ }),
285
+ tool({
286
+ name: "parts_search",
287
+ title: "Search the part libraries",
288
+ description:
289
+ "Searches the project's part libraries (`protoboard parts search`), the Protoboard library first. Returns each part's id, recommended revision, manufacturer, part number, protocols, domains and the library it comes from; `nextCursor` gives the next page. Needs the user to be signed in, as everything in the Protoboard library does: without a sign-in the result is `signInRequired` and says what to ask the user. Reaches the libraries over the network; writes nothing.",
290
+ input: {
291
+ dir: DIR,
292
+ query: z.string().optional().describe('Words to search for: a part number, a function ("buck-boost 3.3V"), a manufacturer.'),
293
+ taxonomy: z.string().optional().describe("A category path of the library's taxonomy."),
294
+ domain: z.string().optional(),
295
+ protocol: z.string().optional().describe("A protocol the part speaks, such as i2c or spi."),
296
+ tag: z.string().optional(),
297
+ sort: z.enum(LIBRARY_SORTS as unknown as [string, ...string[]]).optional().describe("Order: relevance (the default), name, manufacturer or updated."),
298
+ limit: z.number().int().min(1).max(50).optional().describe("Parts per page (default 20 here)."),
299
+ cursor: z.string().optional().describe("The previous page's nextCursor."),
300
+ library: z.string().optional().describe("Search only this library id."),
301
+ },
302
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
303
+ async handler({ dir, query, taxonomy, domain, protocol, tag, sort, limit, cursor, library }, ctx) {
304
+ // the words are the query, whatever they look like: "-5V regulator" and "--help" are searches
305
+ return command(ctx, await ctx.dir(dir), cliArgs(["parts", "search"], { taxonomy, domain, protocol, tag, sort, limit: limit ?? 20, cursor, library }, query ? query.split(/\s+/) : []));
306
+ },
307
+ }),
308
+ tool({
309
+ name: "parts_add",
310
+ title: "Add library parts to the project",
311
+ description:
312
+ "Vendors library parts into the project (`protoboard parts add`). CHANGES FILES: writes each part's definition, its .uhd.ts source, its files and the parts it contains into parts/catalog, and pins the revision in parts/origins.json. It adds; it does not change the project's own sources. Needs the user to be signed in: without a sign-in the result says so and what to ask the user. Vendored parts are immutable: never edit parts/catalog afterwards. Orders and publishes nothing.",
313
+ input: {
314
+ dir: DIR,
315
+ parts: z.array(z.string()).min(1).describe('Part ids from parts_search, each optionally pinned to a revision: "ti-tps63020dsjr" or "ti-tps63020dsjr@3". Default revision: the recommended one.'),
316
+ library: z.string().optional().describe("Take them from this library id instead of the first that has them."),
317
+ },
318
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
319
+ async handler({ dir: given, parts, library }, ctx) {
320
+ const dir = await ctx.dir(given);
321
+ const r = await command(ctx, dir, cliArgs(["parts", "add"], { library }, parts));
322
+ // a part the service refused for want of an account comes back as a result line, not as a failed command
323
+ const lines = (r.data as { results?: { status?: string; message?: string }[] }).results;
324
+ const blocked = lines?.find((x) => x.message && SIGN_IN.test(x.message));
325
+ return blocked ? { data: { ...(r.data as object), signInRequired: true, action: SIGN_IN_ACTION } } : r;
326
+ },
327
+ }),
328
+ tool({
329
+ name: "parts_list",
330
+ title: "List the project's parts",
331
+ description:
332
+ "The project's parts (`protoboard parts list`): library parts with their pinned revision (and `diverged` when their vendored files were edited), parts researched in the project (parts/local) and modules designed in it (src/). Offline unless `checkUpdates` asks the libraries which parts have a newer recommended revision. Reads only.",
333
+ input: { dir: DIR, checkUpdates: z.boolean().optional().describe("Ask the libraries for updates (network).") },
334
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
335
+ handler: async ({ dir, checkUpdates }, ctx) => command(ctx, await ctx.dir(dir), cliArgs(["parts", "list", ...(checkUpdates ? ["--check"] : [])])),
336
+ }),
337
+ tool({
338
+ name: "skills_list",
339
+ title: "List the installed skills",
340
+ description:
341
+ "The skills the project's extensions ship (`protoboard skills`): each one's id, a one-line summary, the extension it belongs to and its SKILL.md file. A skill is the procedure for one stage of the work (research a part, compose the system, lay out a board, write the release). Start with build-a-project. Reads only.",
342
+ input: { dir: DIR },
343
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
344
+ handler: async ({ dir }, ctx) => command(ctx, await ctx.dir(dir), cliArgs(["skills"])),
345
+ }),
346
+ tool({
347
+ name: "skill_read",
348
+ title: "Read a skill",
349
+ description: "The full text of one installed skill (its SKILL.md), by the id skills_list gives, such as build-a-project or uhd-part-research. Follow it for that stage of the work. Reads only.",
350
+ input: { dir: DIR, id: z.string().describe("The skill's id, from skills_list.") },
351
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
352
+ async handler({ dir: given, id }, ctx) {
353
+ const dir = await ctx.dir(given);
354
+ const r = readCliJson(await ctx.cli(dir, cliArgs(["skills"])));
355
+ if (!r.ok) return failure(r.message, dir, r.code);
356
+ const skills = (Array.isArray(r.data) ? r.data : []) as { id: string; file: string | null; extension?: string; summary?: string }[];
357
+ const skill = skills.find((s) => s.id === id);
358
+ if (!skill) return { data: { error: `no skill "${id}" is installed in this project`, installed: skills.map((s) => s.id) }, isError: true };
359
+ if (!skill.file) return { data: { error: `the skill "${id}" is declared by ${skill.extension} but its file was not found; \`protoboard doctor\` in the project says why (\`protoboard stack link\` links missing packages)` }, isError: true };
360
+ // only a file the CLI itself named is read
361
+ return { data: `# Skill ${skill.id} (${skill.extension}): ${skill.file}\n\n${readFileSync(skill.file, "utf8")}` };
362
+ },
363
+ }),
364
+ tool({
365
+ name: "bom",
366
+ title: "Bill of materials",
367
+ description:
368
+ "The design's bill of materials in US dollars (`protoboard bom`, schema protoboard.bom/v0): lines grouped by top-level module, each with the offer chosen for it, what must be ordered (packs, minimum orders, price breaks) and its cost; the totals per unit and per order; counts of unpriced and stale lines. `units` is how many to build (default: the project's parts/bom.json). `release` reads a release's ordering list instead of the design. Each line says how many offers it has (`offerCount`); the offers themselves are listed by `protoboard bom offers <line>` in a shell. Offline: it fetches no prices. Reads only.",
369
+ input: {
370
+ dir: DIR,
371
+ units: z.number().int().min(1).optional().describe("How many units to build. Default: the units in the project's parts/bom.json, else 1."),
372
+ release: z.string().optional().describe("A release version, such as 0.1.0: that release's ordering list instead of the design."),
373
+ },
374
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
375
+ handler: async ({ dir, units, release }, ctx) => command(ctx, await ctx.dir(dir), cliArgs(["bom"], { units, release }), compactBom),
376
+ }),
377
+ tool({
378
+ name: "release_check",
379
+ title: "Would a release be accepted?",
380
+ description:
381
+ "Runs a release's preconditions only (`protoboard release --check`): the check has no errors, no output is stale, the project has no uncommitted changes. Returns whether a release would be written and every reason it would be refused. Writes nothing: no package, no operation record. The release itself (`protoboard release`) is a shell command for the user to approve; this server has no tool that writes, publishes or uploads one.",
382
+ input: { dir: DIR, version: z.string().optional().describe("The release version to check for. Default: the root module's version.") },
383
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
384
+ handler: async ({ dir, version }, ctx) => command(ctx, await ctx.dir(dir), cliArgs(["release", "--check"], { version })),
385
+ }),
386
+ ];
387
+ // every tool runs the Protoboard CLI in the project, which evaluates the project's TypeScript (protoboard.config.ts, its
388
+ // model files) and may be the project's own CLI from node_modules: none is read-only, so a client asks before it runs (PB-1048)
389
+ for (const s of specs) if (!s.description.includes(RUNS_PROJECT_CODE)) s.description = `${s.description} ${RUNS_PROJECT_CODE}`;
390
+ for (const s of specs) if (s.description.length > DESCRIPTION_LIMIT) throw new Error(`the description of ${s.name} is ${s.description.length} characters; clients cut it at ${DESCRIPTION_LIMIT}`);
391
+ return specs;
392
+ }
package/src/openUrl.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { browserUrlProblem } from '@protoboardai/core';
3
+
4
+ /** A program and its arguments, started without a shell. */
5
+ export interface OpenCommand {
6
+ command: string;
7
+ args: string[];
8
+ /** Windows: the arguments go to the program exactly as written here (no quoting added by Node). */
9
+ verbatim?: boolean;
10
+ }
11
+
12
+ /**
13
+ * How the platform's default browser is given a URL: the command, and one to
14
+ * try when the first cannot be started.
15
+ *
16
+ * Windows: never through `cmd /c start`. cmd reads `&` as the end of the
17
+ * command, so a URL with a query was cut at its first `&` (the sign-in link
18
+ * arrived without its `state` and `service`; first builds review, item 22).
19
+ * `rundll32 url.dll,FileProtocolHandler <url>` hands the rest of its command
20
+ * line to the shell's URL handler with no interpreter in between; PowerShell's
21
+ * `Start-Process` with the URL in single quotes is the fallback. Neither goes
22
+ * through cmd, and a URL has no spaces or quotes for either to trip on (`'`
23
+ * is doubled for PowerShell; a `"` or a space is percent-encoded first).
24
+ */
25
+ export function openCommands(url: string, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): OpenCommand[] {
26
+ if (platform === 'win32') {
27
+ // what a URL may not hold raw anyway: nothing for a command line to split on or to close a quote with
28
+ const safe = url.replace(/[\s"<>^|]/g, (c) => encodeURIComponent(c));
29
+ const root = env.SystemRoot ?? env.windir ?? 'C:\\Windows';
30
+ return [
31
+ { command: `${root}\\System32\\rundll32.exe`, args: ['url.dll,FileProtocolHandler', safe], verbatim: true },
32
+ {
33
+ command: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
34
+ args: ['-NoProfile', '-NonInteractive', '-Command', `Start-Process '${safe.replace(/'/g, "''")}'`],
35
+ },
36
+ ];
37
+ }
38
+ return [{ command: platform === 'darwin' ? 'open' : 'xdg-open', args: [url] }];
39
+ }
40
+
41
+ /**
42
+ * Hands a URL to the platform's default browser and detaches, so the browser
43
+ * outlives the command that opened it (`protoboard dev --open`, `protoboard
44
+ * login`). Failures are swallowed: the caller prints the URL as well.
45
+ * Only an https page, or an http one on this machine, is handed over
46
+ * (PB-1048): the system's opener runs whatever is registered for a scheme
47
+ * (`file:`, an app's own), so anything else is refused and false returned.
48
+ */
49
+ export function openUrl(url: string, run: typeof spawn = spawn): boolean {
50
+ if (browserUrlProblem(url)) return false;
51
+ const [first, ...rest] = openCommands(url);
52
+ const start = (c: OpenCommand, next: OpenCommand[]) => {
53
+ try {
54
+ const child = run(c.command, c.args, {
55
+ detached: true,
56
+ stdio: 'ignore',
57
+ windowsHide: true,
58
+ ...(c.verbatim ? { windowsVerbatimArguments: true } : {}),
59
+ });
60
+ child.on('error', () => {
61
+ if (next.length) start(next[0]!, next.slice(1));
62
+ });
63
+ child.unref();
64
+ } catch {
65
+ // Ignore - opening a browser is a convenience, not a requirement.
66
+ if (next.length) start(next[0]!, next.slice(1));
67
+ }
68
+ };
69
+ if (first) start(first, rest);
70
+ return true;
71
+ }