From 489d1639c2aace3f2a2cde4216ad904507cc9f4b Mon Sep 17 00:00:00 2001 From: Sil Date: Tue, 13 Oct 2026 23:08:13 +0200 Subject: [PATCH 1/5] Send and read exact text with --text-file and --raw; add Meet --text-file = puts a file's exact UTF-8 text, or stdin with -, in one dotted field of a call or api input. Unsafe, duplicate, overlapping or existing fields, missing array items and competing stdin readers are usage errors before anything is sent. --raw prints one text field of the result exactly, or nothing on stdout when it is not text. API errors keep the server's details. Meet joins the apps. Co-Authored-By: Claude Opus 5.5 --- .changeset/exact-text-io.md | 5 + README.md | 27 +++- dist/cli.mjs | 187 ++++++++++++++++++++++-- package.json | 1 + skills/fulldev/SKILL.md | 27 +++- src/api.ts | 29 ++-- src/apps.test.ts | 3 +- src/apps.ts | 6 + src/args.ts | 87 ++++++++++- src/fields.ts | 96 ++++++++++++ src/input.test.ts | 130 ++++++++++++++++- src/input.ts | 89 +++++++++++- src/main.ts | 11 +- src/text-io.test.ts | 281 ++++++++++++++++++++++++++++++++++++ 14 files changed, 933 insertions(+), 46 deletions(-) create mode 100644 .changeset/exact-text-io.md create mode 100644 src/fields.ts create mode 100644 src/text-io.test.ts diff --git a/.changeset/exact-text-io.md b/.changeset/exact-text-io.md new file mode 100644 index 0000000..c3d6de4 --- /dev/null +++ b/.changeset/exact-text-io.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Send and read exact text. `--text-file =` puts a file's exact UTF-8 content, or stdin with `-`, in one field of a `call` or `api` input, such as `changes.0.content`; `--raw ` prints one text field of the result without JSON quotes or an added newline. Conflicting, unsafe or ambiguous fields are refused before anything is sent. `fulldev api` errors keep the server's details, such as `status` and a conflict's `data`. Add Fulldev Meet as the app `meet`. diff --git a/README.md b/README.md index 81d1d9b..6ef9964 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Use Fulldev apps from a terminal or an AI agent. The CLI discovers the full HTTP | `pages` | Reports and plans as web pages with [Fulldev Pages](https://pages.full.dev), for administrators only | | `contacts` | [Daily synchronization of your Google contacts across multiple accounts with Import and Export](https://contacts.full.dev) | | `send` | [Customer message scenarios and deliveries](https://send.full.dev) | +| `meet` | Your meetings, transcripts and screenshots for follow-up with [Fulldev Meet](https://meet.full.dev) | | `work` | Tasks, projects and goals in your organization | ## Install @@ -89,6 +90,30 @@ On the server the organization is the tool's `organization` argument, which ever `call` prints the tool's result as JSON on stdout. When the tool fails, it prints the error on stderr and exits with 1. +## Exact text + +For Markdown and other text, `--text-file =` puts a file's exact UTF-8 content in one field of the input, and `--raw ` prints one text field of the result as it is, without JSON quotes or an added newline. Both work with `call` and `api`. Nothing is trimmed, and line endings stay as they are. + +```sh +fulldev cms call commit_files '{"branchId":"","expectedRevision":"","changes":[{"path":"src/content/home.md"}]}' \ + --text-file changes.0.content=home.md +fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' --raw content > home.md +fulldev pages call export_page_markdown '{"pageId":""}' --raw markdown > page.md +``` + +To change a task's notes only when nobody else did, read them first and send them back as `expectedNotes`; without it, the update works as before: + +```sh +fulldev work call get_task '{"taskId":""}' --raw notes > base.md +cp base.md notes.md # edit notes.md +fulldev work call update_task '{"taskId":""}' --text-file expectedNotes=base.md --text-file notes=notes.md +``` + +- A field is a dotted path, such as `content.blocks.0.markdown`. Objects on the way are made; arrays must be in the JSON, with the item. +- The JSON leaves that field out, and each field is named once. `-` reads stdin, for one input only. +- Names such as `__proto__` and `constructor`, an existing field, a missing array item, and text that is not UTF-8 are usage errors (exit 64), before anything is sent. +- `--raw` exits with 1 and prints nothing on stdout when the field is missing or not text. It does not work with `--all-orgs`. + `fulldev cms form wait` keeps calling `cms_wait_for_form` until the person sends the form, with a progress line on stderr each round, then prints the result. After `--timeout` minutes (default 30) it stops, also in the middle of a request, prints the form id, and exits with 2, so you can run it again. ## Output and exit codes @@ -152,4 +177,4 @@ fulldev api contacts.accounts.list '{}' --org my-company fulldev contacts api accounts.list --file input.json --org my-company ``` -Use an operation id from `fulldev api `. Input is a JSON object, a file with `--file`, or stdin with `-`. Reads send query parameters; changes send JSON. Existing OAuth credentials and organization grants cover both interfaces. The CLI refuses redirects and paths outside the selected app before sending credentials. Servers without OpenAPI continue to support the existing MCP commands. +Use an operation id from `fulldev api `. Input is a JSON object, a file with `--file`, or stdin with `-`, with `--text-file` and `--raw` as for `call`. Reads send query parameters; changes send JSON. An error keeps the server's details, such as `status` and a conflict's `data`. Existing OAuth credentials and organization grants cover both interfaces. The CLI refuses redirects and paths outside the selected app before sending credentials. Servers without OpenAPI continue to support the existing MCP commands. diff --git a/dist/cli.mjs b/dist/cli.mjs index b053f5a..12c6174 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -4,7 +4,7 @@ import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node: import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { Client, OAuthError, OAuthErrorCode, ProtocolError, SdkError, StreamableHTTPClientTransport, UnauthorizedError, auth, checkResourceAllowed, discoverAuthorizationServerMetadata, discoverOAuthServerInfo, refreshAuthorization, resourceUrlFromServerUrl } from "@modelcontextprotocol/client"; -import { text } from "node:stream/consumers"; +import { buffer } from "node:stream/consumers"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; @@ -313,9 +313,110 @@ var CredentialStore = class { //#region package.json var version = "0.6.1"; //#endregion +//#region src/fields.ts +/** Names that would reach an object's prototype instead of its own data. */ +const unsafe = /* @__PURE__ */ new Set([ + "__proto__", + "prototype", + "constructor" +]); +const index = /^(?:0|[1-9]\d*)$/; +const fieldName = (field) => field.join("."); +function parseField(text, flag) { + const field = text.split("."); + if (field.some((segment) => segment === "")) throw new UsageError(`${flag} needs a field such as content or changes.0.content, not "${text}".`); + const bad = field.find((segment) => unsafe.has(segment)); + if (bad) throw new UsageError(`${flag} cannot use the field name ${bad}.`); + return field; +} +const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +/** +* Sets `text` at `field` of the tool input. Objects on the way are created +* when missing; arrays must already be there. A field the input already has +* is a conflict: the person names each field once. +*/ +function setText(input, field, text) { + const name = fieldName(field); + const fail = (reason) => { + throw new UsageError(`Cannot set --text-file ${name}: ${reason}`); + }; + let container = input; + for (const [position, segment] of field.entries()) { + const last = position === field.length - 1; + const at = fieldName(field.slice(0, position + 1)); + if (Array.isArray(container)) { + if (last) fail(`${at} is an array element; set a field inside it.`); + if (!index.test(segment) || Number(segment) >= container.length) fail(`${fieldName(field.slice(0, position))} has no item ${segment}.`); + container = container[Number(segment)]; + continue; + } + if (!isRecord(container)) return fail(`${fieldName(field.slice(0, position))} is not an object.`); + if (last) { + if (Object.hasOwn(container, segment)) fail(`the input already has ${at}. Leave it out of the JSON.`); + container[segment] = text; + return; + } + if (!Object.hasOwn(container, segment)) { + if (index.test(field[position + 1])) fail(`${at} is missing; give the array in the JSON input.`); + container[segment] = {}; + } + container = container[segment]; + } +} +/** The text at `field` of a result, or an error that names what is there. */ +function getText(value, field) { + let current = value; + for (const segment of field) { + current = Array.isArray(current) && index.test(segment) ? current[Number(segment)] : isRecord(current) && Object.hasOwn(current, segment) ? current[segment] : void 0; + if (current === void 0) break; + } + if (typeof current === "string") return current; + const kind = current === void 0 ? "missing" : current === null ? "null" : Array.isArray(current) ? "an array" : typeof current === "object" ? "an object" : `a ${typeof current}`; + throw new CliError("RAW_NOT_TEXT", `--raw ${fieldName(field)} needs text in the result, and it is ${kind}.`); +} +//#endregion //#region src/input.ts -/** Reads a tool's arguments: a JSON object from the argument, a file or stdin. */ -async function readToolArguments({ json, file, readStdin = () => text(process.stdin) }) { +/** Strict UTF-8 that keeps a byte order mark, so the text stays exactly as written. */ +const exact = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true +}); +/** Parses the --text-file flags; each field once, and stdin for one input only. */ +function parseTextFiles(specs, json) { + const textFiles = specs.map((spec) => { + const split = spec.indexOf("="); + if (split <= 0 || split === spec.length - 1) throw new UsageError(`--text-file needs =, such as content=page.md, not "${spec}".`); + return { + field: parseField(spec.slice(0, split), "--text-file"), + path: spec.slice(split + 1) + }; + }); + for (const [position, { field }] of textFiles.entries()) for (const other of textFiles.slice(position + 1)) { + const [short, long] = field.length <= other.field.length ? [field, other.field] : [other.field, field]; + if (short.every((segment, at) => segment === long[at])) throw new UsageError(short.length === long.length ? `--text-file names ${fieldName(short)} twice.` : `--text-file sets ${fieldName(short)} and ${fieldName(long)}, which is inside it.`); + } + if (textFiles.filter(({ path }) => path === "-").length + (json === "-" ? 1 : 0) > 1) throw new UsageError("Only one input can come from stdin: the JSON with - or one --text-file."); + return textFiles; +} +/** A text file's exact content; no trimming and no newline changes. */ +async function readText(path, readStdin) { + let bytes; + try { + bytes = path === "-" ? await readStdin() : await readFile(path); + } catch (error) { + throw new UsageError(`Cannot read ${path}: ${error.code ?? error.message}`); + } + try { + return exact.decode(bytes); + } catch { + throw new UsageError(`${path === "-" ? "stdin" : path} is not valid UTF-8 text.`); + } +} +/** +* Reads a tool's arguments: a JSON object from the argument, a file or stdin, +* with the exact text of each --text-file in its field. +*/ +async function readToolArguments({ json, file, textFiles = [], readStdin = () => buffer(process.stdin) }) { if (json !== void 0 && file !== void 0) throw new UsageError("Give the JSON as an argument or with --file, not both."); let source = "{}"; let from = "the argument"; @@ -328,7 +429,7 @@ async function readToolArguments({ json, file, readStdin = () => text(process.st } } else if (json === "-") { from = "stdin"; - source = await readStdin(); + source = new TextDecoder().decode(await readStdin()); } else if (json !== void 0) source = json; if (!source.trim()) source = "{}"; let value; @@ -338,7 +439,9 @@ async function readToolArguments({ json, file, readStdin = () => text(process.st throw new UsageError(`The tool input from ${from} is not valid JSON: ${error.message}`); } if (!value || typeof value !== "object" || Array.isArray(value)) throw new UsageError(`The tool input from ${from} must be a JSON object.`); - return value; + const input = value; + for (const { field, path } of textFiles) setText(input, field, await readText(path, readStdin)); + return input; } //#endregion //#region src/oauth.ts @@ -660,7 +763,7 @@ async function readJson(response) { return {}; } } -const text$1 = (value) => typeof value === "string" ? value : void 0; +const text = (value) => typeof value === "string" ? value : void 0; /** * Refreshes the target's tokens. Call it while holding the store's lock. * A refresh token the server no longer accepts is dropped, and the command @@ -724,7 +827,7 @@ async function revokeTokens(target, credentials, fetchFn = fetch) { }); if (!response.ok) { const answer = await readJson(response); - failures.push(`${hint}: ${[text$1(answer.error) ?? `HTTP ${response.status}`, text$1(answer.error_description)].filter(Boolean).join(": ")}`); + failures.push(`${hint}: ${[text(answer.error) ?? `HTTP ${response.status}`, text(answer.error_description)].filter(Boolean).join(": ")}`); } } return failures.length ? { @@ -802,7 +905,8 @@ async function runApi(command, io, fetchFn = fetch) { if (!operation) throw new CliError("NOT_FOUND", `Unknown API operation: ${command.operation}. Run fulldev api ${app.name}.`); const input = await readToolArguments({ json: command.json, - file: command.file + file: command.file, + textFiles: command.textFiles }); const payload = { ...input }; const path = operation.path.replace(/\{([^{}]+)\}/g, (_, name) => { @@ -858,9 +962,12 @@ async function runApi(command, io, fetchFn = fetch) { throw new CliError("API_FAILED", `API returned a non-JSON response (${response.status}).`); }); if (!response.ok) { - const failure = body; - const error = failure?.error ?? failure ?? {}; - throw new CliError(error.code ?? "API_FAILED", error.message ?? `API request failed (${response.status}).`); + const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0; + const { code, message, defined: _defined, ...details } = record(record(body)?.error) ?? record(body) ?? {}; + throw new CliError(typeof code === "string" ? code : "API_FAILED", typeof message === "string" ? message : `API request failed (${response.status}).`, exitCodes.error, { + status: response.status, + ...details + }); } return body; } @@ -910,6 +1017,11 @@ const apps = [ title: "Fulldev Send", description: "Manage customer message scenarios and their deliveries." }, + { + name: "meet", + title: "Fulldev Meet", + description: "Read your meetings, transcripts, recording coverage and screenshots for follow-up." + }, { name: "work", title: "Fulldev Work", @@ -944,17 +1056,28 @@ function resolveTarget(flagUrl, env) { } //#endregion //#region src/args.ts +const textDetails = ` +--text-file = puts a file's exact text in one field of the +input, such as content=page.md or changes.0.content=page.md; - reads stdin. +Nothing is trimmed or changed. The JSON must leave that field out; objects +on the way are made, arrays must be in the JSON. --raw prints one +text field of the result exactly, without JSON quotes or an added newline.`; const apiTopic = { usage: "fulldev api [.] [JSON | -] [--file ] [--org ]", summary: "List or call an operation of the app's OpenAPI API", options: [ "url", "file", + "text-file", + "raw", "org", "login", "browser" ], - details: "Without an operation, lists the API operations and their schemas. Calls use the same login and organization grants as MCP. Data is JSON on stdout; errors go to stderr." + details: `Without an operation, lists the API operations and their schemas. Calls +use the same login and organization grants as MCP. Data is JSON on stdout; +errors go to stderr. +${textDetails}` }; const organizationDetails = ` When the sign-in is for more than one organization, --org names the one to @@ -1023,6 +1146,8 @@ annotations. Each app's tools start with its name, such as cms_.` summary: "Call a tool with a JSON object", options: [ "file", + "text-file", + "raw", "org", "all-orgs", "url", @@ -1032,6 +1157,7 @@ annotations. Each app's tools start with its name, such as cms_.` details: `The tool's input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}` }, help: { @@ -1073,6 +1199,8 @@ description, input schema, output schema and annotations.` summary: "Call one of the app's tools with a JSON object", options: [ "file", + "text-file", + "raw", "org", "all-orgs", "url", @@ -1084,6 +1212,7 @@ list_repositories calls cms_list_repositories. Its input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}` } }; @@ -1104,6 +1233,9 @@ line on stderr each round, then prints the result. After --timeout minutes const optionHelp = { url: "--url Use another server, such as a deploy preview's", file: "-f, --file Read the tool's JSON input from a file", + "text-file": `--text-file = + Put a file's exact text in a field; - reads stdin`, + raw: "--raw Print a text field of the result as it is", timeout: "--timeout How long to wait (default 30)", login: "--no-login Fail with exit code 3 instead of signing in", browser: "--no-browser Print the sign-in link, for a browser here or elsewhere", @@ -1219,6 +1351,18 @@ function closestTopic(words) { return ""; } const helpFor = (topic) => topic ? `fulldev ${topic} --help` : "fulldev --help"; +/** --text-file and --raw, with errors that point to the command's help. */ +function textOptions(values, json, topic) { + try { + return { + textFiles: parseTextFiles(values["text-file"] ?? [], json), + raw: values.raw === void 0 ? void 0 : parseField(values.raw, "--raw") + }; + } catch (error) { + if (error instanceof UsageError) throw new UsageError(error.message, helpFor(topic)); + throw error; + } +} function parseCommandLine(argv, env = process.env) { let parsed; try { @@ -1233,6 +1377,11 @@ function parseCommandLine(argv, env = process.env) { type: "string", short: "f" }, + "text-file": { + type: "string", + multiple: true + }, + raw: { type: "string" }, org: { type: "string" }, "all-orgs": { type: "boolean" }, timeout: { type: "string" }, @@ -1305,7 +1454,7 @@ function parseCommandLine(argv, env = process.env) { const words = first === "api" ? rest : rest.slice(1); const operation = first === "api" ? words[0]?.split(".").slice(1).join(".") : words[0]; if (words.length > 2) throw new UsageError(`Usage: ${apiTopic.usage}`); - if (!operation && (words[1] || values.file)) throw new UsageError("Choose an API operation before giving arguments."); + if (!operation && (words[1] || values.file || values["text-file"] || values.raw)) throw new UsageError("Choose an API operation before giving arguments."); return { kind: "api", target, @@ -1314,6 +1463,7 @@ function parseCommandLine(argv, env = process.env) { operation: operation || void 0, json: words[1], file: values.file, + ...textOptions(values, words[1], "api"), organization: values.org }; } @@ -1356,6 +1506,7 @@ function parseCommandLine(argv, env = process.env) { case "call": usage(args.length === 1 || args.length === 2); if (values.org !== void 0 && values["all-orgs"]) throw new UsageError("Use --org or --all-orgs, not both.", helpFor(topic)); + if (values.raw !== void 0 && values["all-orgs"]) throw new UsageError("--raw prints one result; use it with --org, not --all-orgs.", helpFor(topic)); return { kind: "call", target, @@ -1363,6 +1514,7 @@ function parseCommandLine(argv, env = process.env) { tool: app ? toolName(app, args[0]) : args[0], json: args[1], file: values.file, + ...textOptions(values, args[1], topic), organization: values.org, allOrganizations: values["all-orgs"] }; @@ -1493,6 +1645,8 @@ async function waitForForm(callTool, { branchId, formId, timeoutMs, app = "cms", /** A tool call can wait on the server for up to a minute. */ const requestTimeoutMs = 12e4; const json = (value) => `${JSON.stringify(value, null, 2)}\n`; +/** A result as JSON, or with --raw one text field of it exactly as it is. */ +const output = (value, raw) => raw ? getText(value, raw) : json(value); /** A rejected or expired sign-in, from any layer, becomes SignInRequiredError. */ function signInError(error, target) { if (isSignInRequired(error)) return error; @@ -1727,7 +1881,7 @@ async function run(command, io) { case "status": return status(command.target, io); case "api": { const data = await runApi(command, io); - io.stdout(json(data)); + io.stdout(output(data, command.raw)); return exitCodes.ok; } case "instructions": { @@ -1763,7 +1917,8 @@ async function run(command, io) { case "call": { const args = await readToolArguments({ json: command.json, - file: command.file + file: command.file, + textFiles: command.textFiles }); if (command.allOrganizations) return callInEvery(command, args, io); if (command.organization !== void 0) args.organization = command.organization; @@ -1775,7 +1930,7 @@ async function run(command, io) { io.stderr(json(toolError(result))); return exitCodes.error; } - io.stdout(json(toolOutput(result))); + io.stdout(output(toolOutput(result), command.raw)); return exitCodes.ok; } case "form-wait": { diff --git a/package.json b/package.json index 69d255b..d60eb94 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "pages", "scan", "send", + "meet", "sites", "work", "mcp", diff --git a/skills/fulldev/SKILL.md b/skills/fulldev/SKILL.md index c54b454..80bce14 100644 --- a/skills/fulldev/SKILL.md +++ b/skills/fulldev/SKILL.md @@ -1,6 +1,6 @@ --- name: fulldev -description: Use the Fulldev CLI to work with Fulldev apps for the person. Use cms when they want to change their Fulldev website, such as text, pages, images, prices, opening hours, or contact details, or publish a change. Use connect when they want to use their business tools, such as Shopify, through Fulldev Connect. Use scan to scan websites, only for Fulldev administrators. Use sites when they want Fulldev to make a new website from a brief, only for Fulldev administrators for now. Use pages to publish a report or plan as a clear web page, only for Fulldev administrators for now. Use send for customer message scenarios and deliveries. Use work for tasks, projects and goals in their organization. Use contacts when they want to check, start, pause or resume the daily synchronization of their Google contacts across multiple accounts with Import and Export in Fulldev Contacts, or answer its look-alike questions. +description: Use the Fulldev CLI to work with Fulldev apps for the person. Use cms when they want to change their Fulldev website, such as text, pages, images, prices, opening hours, or contact details, or publish a change. Use connect when they want to use their business tools, such as Shopify, through Fulldev Connect. Use scan to scan websites, only for Fulldev administrators. Use sites when they want Fulldev to make a new website from a brief, only for Fulldev administrators for now. Use pages to publish a report or plan as a clear web page, only for Fulldev administrators for now. Use send for customer message scenarios and deliveries. Use meet to read their meetings, transcripts and screenshots for follow-up. Use work for tasks, projects and goals in their organization. Use contacts when they want to check, start, pause or resume the daily synchronization of their Google contacts across multiple accounts with Import and Export in Fulldev Contacts, or answer its look-alike questions. license: MIT compatibility: Needs Node.js 24 or later and network access to app.full.dev. --- @@ -18,6 +18,7 @@ The `fulldev` CLI uses the shared HTTP APIs and the Fulldev MCP server, which ha | `pages` | Publishing a report or plan as a web page with Fulldev Pages. Only for Fulldev administrators for now. | | `contacts` | Daily synchronization of Google contacts across multiple accounts with Import and Export: runs, look-alike questions, pausing and resuming. | | `send` | Customer message scenarios and deliveries. | +| `meet` | The person's meetings, transcripts, recording coverage and screenshots, for follow-up. | | `work` | Tasks, projects and goals in their organization. | ## HTTP API @@ -81,7 +82,7 @@ Read `fulldev tools ` before the first call to a tool. ## Call tools -Give a tool's input as a JSON argument, from a file with `--file`, or from stdin with `-`. Use stdin or `--file` for long or quoted text, so the shell does not change it. +Give a tool's input as a JSON argument, from a file with `--file`, or from stdin with `-`. Put Markdown and other long text in its field with `--text-file =`, so it arrives exactly as written. ```sh fulldev cms call list_repositories @@ -91,6 +92,28 @@ fulldev cms call commit_files - <<'JSON' JSON ``` +## Exact text + +`--text-file =` puts a file's exact text in one field of the input; `-` reads stdin. Leave that field out of the JSON; arrays and their items must be in it. `--raw ` prints one text field of the result as it is, for a file. Both work with `call` and `api`. + +```sh +fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' --raw content > home.md +fulldev cms call commit_files '{"branchId":"","expectedRevision":"","changes":[{"path":"src/content/home.md"}]}' --text-file changes.0.content=home.md +fulldev pages call export_page_markdown '{"pageId":""}' --raw markdown > page.md +``` + +The page export is for reading: it does not hold every block, so never publish it back as the page. + +To change a task's notes without overwriting someone else's change, send the notes you read as `expectedNotes`: + +```sh +fulldev work call get_task '{"taskId":""}' --raw notes > base.md +cp base.md notes.md # edit notes.md +fulldev work call update_task '{"taskId":""}' --text-file expectedNotes=base.md --text-file notes=notes.md +``` + +On a conflict, read again and redo the change; never drop `expectedNotes`, `expectedVersion` or `expectedRevision` to force it. + ## Forms in the CMS After `create_form`, give the person the form link. Then run: diff --git a/src/api.ts b/src/api.ts index 4b82434..ec8c36d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,6 @@ import type { Command } from "./args.ts" import type { Io } from "./main.ts" -import { CliError, SignInRequiredError } from "./errors.ts" +import { CliError, SignInRequiredError, exitCodes } from "./errors.ts" import { readToolArguments } from "./input.ts" import { StoredTokenAuth, browserLogin } from "./oauth.ts" @@ -61,6 +61,7 @@ export async function runApi( const input = await readToolArguments({ json: command.json, file: command.file, + textFiles: command.textFiles, }) const payload = { ...input } const path = operation.path.replace(/\{([^{}]+)\}/g, (_, name: string) => { @@ -154,15 +155,25 @@ export async function runApi( ) }) if (!response.ok) { - const failure = body as { - code?: string - message?: string - error?: { code?: string; message?: string } - } | null - const error = failure?.error ?? failure ?? {} + // {"error":{...}} from the server, or an oRPC error with its data, such as + // a conflict's current version: everything but code and message stays. + const record = (value: unknown) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined + const { + code, + message, + defined: _defined, + ...details + } = record(record(body)?.error) ?? record(body) ?? {} throw new CliError( - error.code ?? "API_FAILED", - error.message ?? `API request failed (${response.status}).`, + typeof code === "string" ? code : "API_FAILED", + typeof message === "string" + ? message + : `API request failed (${response.status}).`, + exitCodes.error, + { status: response.status, ...details }, ) } return body diff --git a/src/apps.test.ts b/src/apps.test.ts index dbcda59..2cf6dc3 100644 --- a/src/apps.test.ts +++ b/src/apps.test.ts @@ -5,7 +5,7 @@ import { UsageError } from "./errors.ts" import { apps, findApp, resolveTarget, server, toolName } from "./apps.ts" describe("apps", () => { - it("has cms, connect, scan, sites, pages and contacts on the one server", () => { + it("has every app of the one server", () => { expect(apps.map(({ name }) => name)).toEqual([ "cms", "connect", @@ -14,6 +14,7 @@ describe("apps", () => { "pages", "contacts", "send", + "meet", "work", ]) expect(server.url).toBe("https://app.full.dev/mcp") diff --git a/src/apps.ts b/src/apps.ts index 424c13c..45615bc 100644 --- a/src/apps.ts +++ b/src/apps.ts @@ -63,6 +63,12 @@ export const apps: Array = [ title: "Fulldev Send", description: "Manage customer message scenarios and their deliveries.", }, + { + name: "meet", + title: "Fulldev Meet", + description: + "Read your meetings, transcripts, recording coverage and screenshots for follow-up.", + }, { name: "work", title: "Fulldev Work", diff --git a/src/args.ts b/src/args.ts index fd73236..e754706 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,6 +1,10 @@ import { parseArgs } from "node:util" import { UsageError } from "./errors.ts" +import { parseField } from "./fields.ts" +import type { Field } from "./fields.ts" +import { parseTextFiles } from "./input.ts" +import type { TextFile } from "./input.ts" import { apps, findApp, @@ -20,6 +24,9 @@ export type Command = operation?: string json?: string file?: string + textFiles?: Array + /** Print this text field of the result as it is, instead of JSON. */ + raw?: Field organization?: string } | { kind: "help"; topic: string } @@ -45,6 +52,9 @@ export type Command = tool: string json?: string file?: string + textFiles?: Array + /** Print this text field of the result as it is, instead of JSON. */ + raw?: Field /** The organization to work in, by slug or id. */ organization?: string /** Every organization of the sign-in that has the tool's app. */ @@ -70,6 +80,8 @@ export interface Session { type Option = | "url" | "file" + | "text-file" + | "raw" | "timeout" | "login" | "browser" @@ -83,13 +95,22 @@ interface Topic { details?: string } +const textDetails = ` +--text-file = puts a file's exact text in one field of the +input, such as content=page.md or changes.0.content=page.md; - reads stdin. +Nothing is trimmed or changed. The JSON must leave that field out; objects +on the way are made, arrays must be in the JSON. --raw prints one +text field of the result exactly, without JSON quotes or an added newline.` + const apiTopic: Topic = { usage: "fulldev api [.] [JSON | -] [--file ] [--org ]", summary: "List or call an operation of the app's OpenAPI API", - options: ["url", "file", "org", "login", "browser"], - details: - "Without an operation, lists the API operations and their schemas. Calls use the same login and organization grants as MCP. Data is JSON on stdout; errors go to stderr.", + options: ["url", "file", "text-file", "raw", "org", "login", "browser"], + details: `Without an operation, lists the API operations and their schemas. Calls +use the same login and organization grants as MCP. Data is JSON on stdout; +errors go to stderr. +${textDetails}`, } const organizationDetails = ` @@ -151,10 +172,20 @@ annotations. Each app's tools start with its name, such as cms_.`, call: { usage: "fulldev call [json | -] [--file ] [--org ]", summary: "Call a tool with a JSON object", - options: ["file", "org", "all-orgs", "url", "login", "browser"], + options: [ + "file", + "text-file", + "raw", + "org", + "all-orgs", + "url", + "login", + "browser", + ], details: `The tool's input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}`, }, help: { @@ -185,12 +216,22 @@ description, input schema, output schema and annotations.`, usage: "fulldev call [json | -] [--file ] [--org ]", summary: "Call one of the app's tools with a JSON object", - options: ["file", "org", "all-orgs", "url", "login", "browser"], + options: [ + "file", + "text-file", + "raw", + "org", + "all-orgs", + "url", + "login", + "browser", + ], details: `The tool's name may leave out the app's prefix: fulldev cms call list_repositories calls cms_list_repositories. Its input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints its error on stderr and exits with 1. +${textDetails} ${organizationDetails}`, }, } @@ -208,6 +249,9 @@ line on stderr each round, then prints the result. After --timeout minutes const optionHelp: Record = { url: "--url Use another server, such as a deploy preview's", file: "-f, --file Read the tool's JSON input from a file", + "text-file": `--text-file = + Put a file's exact text in a field; - reads stdin`, + raw: "--raw Print a text field of the result as it is", timeout: "--timeout How long to wait (default 30)", login: "--no-login Fail with exit code 3 instead of signing in", browser: @@ -347,6 +391,25 @@ function closestTopic(words: Array) { const helpFor = (topic: string) => topic ? `fulldev ${topic} --help` : "fulldev --help" +/** --text-file and --raw, with errors that point to the command's help. */ +function textOptions( + values: { "text-file"?: Array; raw?: string }, + json: string | undefined, + topic: string, +) { + try { + return { + textFiles: parseTextFiles(values["text-file"] ?? [], json), + raw: + values.raw === undefined ? undefined : parseField(values.raw, "--raw"), + } + } catch (error) { + if (error instanceof UsageError) + throw new UsageError(error.message, helpFor(topic)) + throw error + } +} + export function parseCommandLine( argv: Array, env: NodeJS.ProcessEnv = process.env, @@ -361,6 +424,8 @@ export function parseCommandLine( options: { url: { type: "string" }, file: { type: "string", short: "f" }, + "text-file": { type: "string", multiple: true }, + raw: { type: "string" }, org: { type: "string" }, "all-orgs": { type: "boolean" }, timeout: { type: "string" }, @@ -439,7 +504,10 @@ export function parseCommandLine( const operation = first === "api" ? words[0]?.split(".").slice(1).join(".") : words[0] if (words.length > 2) throw new UsageError(`Usage: ${apiTopic.usage}`) - if (!operation && (words[1] || values.file)) + if ( + !operation && + (words[1] || values.file || values["text-file"] || values.raw) + ) throw new UsageError("Choose an API operation before giving arguments.") return { kind: "api", @@ -449,6 +517,7 @@ export function parseCommandLine( operation: operation || undefined, json: words[1], file: values.file, + ...textOptions(values, words[1], "api"), organization: values.org, } } @@ -488,6 +557,11 @@ export function parseCommandLine( "Use --org or --all-orgs, not both.", helpFor(topic), ) + if (values.raw !== undefined && values["all-orgs"]) + throw new UsageError( + "--raw prints one result; use it with --org, not --all-orgs.", + helpFor(topic), + ) return { kind: "call", target, @@ -495,6 +569,7 @@ export function parseCommandLine( tool: app ? toolName(app, args[0]!) : args[0]!, json: args[1], file: values.file, + ...textOptions(values, args[1], topic), organization: values.org, allOrganizations: values["all-orgs"], } diff --git a/src/fields.ts b/src/fields.ts new file mode 100644 index 0000000..47f4e91 --- /dev/null +++ b/src/fields.ts @@ -0,0 +1,96 @@ +import { CliError, UsageError } from "./errors.ts" + +/** A field of a JSON value by its dotted path, such as content.blocks.0.markdown. */ +export type Field = Array + +/** Names that would reach an object's prototype instead of its own data. */ +const unsafe = new Set(["__proto__", "prototype", "constructor"]) + +const index = /^(?:0|[1-9]\d*)$/ + +export const fieldName = (field: Field) => field.join(".") + +export function parseField(text: string, flag: string): Field { + const field = text.split(".") + if (field.some((segment) => segment === "")) + throw new UsageError( + `${flag} needs a field such as content or changes.0.content, not "${text}".`, + ) + const bad = field.find((segment) => unsafe.has(segment)) + if (bad) throw new UsageError(`${flag} cannot use the field name ${bad}.`) + return field +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +/** + * Sets `text` at `field` of the tool input. Objects on the way are created + * when missing; arrays must already be there. A field the input already has + * is a conflict: the person names each field once. + */ +export function setText( + input: Record, + field: Field, + text: string, +) { + const name = fieldName(field) + const fail = (reason: string): never => { + throw new UsageError(`Cannot set --text-file ${name}: ${reason}`) + } + let container: unknown = input + for (const [position, segment] of field.entries()) { + const last = position === field.length - 1 + const at = fieldName(field.slice(0, position + 1)) + if (Array.isArray(container)) { + if (last) fail(`${at} is an array element; set a field inside it.`) + if (!index.test(segment) || Number(segment) >= container.length) + fail(`${fieldName(field.slice(0, position))} has no item ${segment}.`) + container = container[Number(segment)] + continue + } + if (!isRecord(container)) + return fail(`${fieldName(field.slice(0, position))} is not an object.`) + if (last) { + if (Object.hasOwn(container, segment)) + fail(`the input already has ${at}. Leave it out of the JSON.`) + container[segment] = text + return + } + if (!Object.hasOwn(container, segment)) { + if (index.test(field[position + 1]!)) + fail(`${at} is missing; give the array in the JSON input.`) + container[segment] = {} + } + container = container[segment] + } +} + +/** The text at `field` of a result, or an error that names what is there. */ +export function getText(value: unknown, field: Field): string { + let current = value + for (const segment of field) { + current = + Array.isArray(current) && index.test(segment) + ? current[Number(segment)] + : isRecord(current) && Object.hasOwn(current, segment) + ? current[segment] + : undefined + if (current === undefined) break + } + if (typeof current === "string") return current + const kind = + current === undefined + ? "missing" + : current === null + ? "null" + : Array.isArray(current) + ? "an array" + : typeof current === "object" + ? "an object" + : `a ${typeof current}` + throw new CliError( + "RAW_NOT_TEXT", + `--raw ${fieldName(field)} needs text in the result, and it is ${kind}.`, + ) +} diff --git a/src/input.test.ts b/src/input.test.ts index e2a38c0..1cf5008 100644 --- a/src/input.test.ts +++ b/src/input.test.ts @@ -5,9 +5,11 @@ import { join } from "node:path" import { describe, expect, it } from "vite-plus/test" import { UsageError } from "./errors.ts" -import { readToolArguments } from "./input.ts" +import { parseTextFiles, readToolArguments } from "./input.ts" const noStdin = () => Promise.reject(new Error("stdin was read")) +const stdin = (text: string) => () => + Promise.resolve(new TextEncoder().encode(text)) describe("readToolArguments", () => { it("defaults to an empty object", async () => { @@ -24,13 +26,13 @@ describe("readToolArguments", () => { expect( await readToolArguments({ json: "-", - readStdin: () => Promise.resolve('{"n":1}\n'), + readStdin: stdin('{"n":1}\n'), }), ).toEqual({ n: 1 }) expect( await readToolArguments({ json: "-", - readStdin: () => Promise.resolve(""), + readStdin: stdin(""), }), ).toEqual({}) }) @@ -58,3 +60,125 @@ describe("readToolArguments", () => { await expect(result).rejects.toThrow(message) }) }) + +describe("--text-file", () => { + const folder = mkdtemp(join(tmpdir(), "fulldev-text-")) + const file = async (content: string | Uint8Array) => { + const path = join(await folder, `${Math.random()}.md`) + await writeFile(path, content) + return path + } + const read = async ( + json: string | undefined, + specs: Array, + readStdin: () => Promise = noStdin, + ) => + readToolArguments({ + json, + textFiles: parseTextFiles(specs, json), + readStdin, + }) + + it.each([ + ["CRLF", "# Title\r\n\r\nLine one\r\nLine two\r\n"], + ["Unicode", "Café ✓ 👩🏽‍💻 e\u0301 日本語"], + ["indentation", " - item\n\t- tab\n code block\n"], + ["empty", ""], + ["a trailing newline", "text\n\n"], + ["no trailing newline", "text"], + ["a byte order mark", "\uFEFFtext"], + ["only whitespace", " \n\t \n"], + ])("keeps %s exactly", async (_, text) => { + const input = await read("{}", [`content=${await file(text)}`]) + expect(input.content).toBe(text) + const piped = await read("{}", ["content=-"], stdin(text)) + expect(piped.content).toBe(text) + }) + + it("sets fields inside existing arrays and new objects, keeping the rest", async () => { + const path = await file("Hello\n") + const input = await read( + '{"pageId":"p","expectedVersion":3,"content":{"blocks":[{"type":"text"}]},"changes":[{"path":"a.md"},{"path":"b.md"}]}', + [ + `content.blocks.0.markdown=${path}`, + `changes.1.content=${path}`, + `brief.about=${path}`, + ], + ) + expect(input).toEqual({ + pageId: "p", + expectedVersion: 3, + content: { blocks: [{ type: "text", markdown: "Hello\n" }] }, + changes: [{ path: "a.md" }, { path: "b.md", content: "Hello\n" }], + brief: { about: "Hello\n" }, + }) + }) + + it("reads the JSON from a file and the text from stdin", async () => { + const json = await file('{"taskId":"t"}') + expect( + await readToolArguments({ + file: json, + textFiles: parseTextFiles(["notes=-"], undefined), + readStdin: stdin("New notes\n"), + }), + ).toEqual({ taskId: "t", notes: "New notes\n" }) + }) + + it.each([ + ["__proto__.polluted=x"], + ["constructor.prototype.polluted=x"], + ["a.__proto__=x"], + ["prototype=x"], + ])("refuses the prototype field %s", (spec) => { + expect(() => parseTextFiles([spec], undefined)).toThrow(UsageError) + expect(({} as Record).polluted).toBeUndefined() + }) + + it.each([ + [["content"], /=/], + [["=a.md"], /=/], + [["content="], /=/], + [["a..b=x"], /needs a field/], + [["content=a.md", "content=b.md"], /twice/], + [["content=a.md", "content.text=b.md"], /inside it/], + [["a=-", "b=-"], /one input can come from stdin/], + ])("refuses %j", (specs, message) => { + expect(() => parseTextFiles(specs, undefined)).toThrow(message) + }) + + it("refuses stdin for both the JSON and a text", () => { + expect(() => parseTextFiles(["content=-"], "-")).toThrow( + /one input can come from stdin/, + ) + }) + + it.each([ + ['{"content":"old"}', "content", /already has content/], + ['{"content":null}', "content", /already has content/], + [ + '{"changes":[{"path":"a","content":"x"}]}', + "changes.0.content", + /already/, + ], + ['{"changes":[{"path":"a"}]}', "changes.1.content", /no item 1/], + ['{"changes":[{"path":"a"}]}', "changes.first.content", /no item first/], + ['{"changes":[{"path":"a"}]}', "changes.0", /array element/], + ["{}", "changes.0.content", /changes is missing/], + ['{"title":"x"}', "title.text", /title is not an object/], + ['{"changes":["a.md"]}', "changes.0.content", /changes.0 is not an object/], + ])("refuses to set into %s at %s", async (json, field, message) => { + const path = await file("text") + await expect(read(json, [`${field}=${path}`])).rejects.toThrow(message) + }) + + it("refuses text that is not UTF-8 or a file that is missing", async () => { + const path = await file(new Uint8Array([0x61, 0xff, 0x62])) + await expect(read("{}", [`content=${path}`])).rejects.toThrow( + /not valid UTF-8/, + ) + await expect(read("{}", ["content=/does/not/exist.md"])).rejects.toThrow( + /Cannot read/, + ) + }) +}) diff --git a/src/input.ts b/src/input.ts index 2b003d8..0706177 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,20 +1,93 @@ import { readFile } from "node:fs/promises" -import { text } from "node:stream/consumers" +import { buffer } from "node:stream/consumers" import { UsageError } from "./errors.ts" +import { fieldName, parseField, setText } from "./fields.ts" +import type { Field } from "./fields.ts" + +/** --text-file =: a file's exact text as one field of the input. */ +export interface TextFile { + field: Field + /** The file, or "-" for stdin. */ + path: string +} export interface ToolInputSource { /** The JSON argument; "-" reads stdin. */ json?: string | undefined file?: string | undefined - readStdin?: () => Promise + textFiles?: Array | undefined + readStdin?: () => Promise +} + +/** Strict UTF-8 that keeps a byte order mark, so the text stays exactly as written. */ +const exact = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + +/** Parses the --text-file flags; each field once, and stdin for one input only. */ +export function parseTextFiles( + specs: Array, + json: string | undefined, +): Array { + const textFiles = specs.map((spec) => { + const split = spec.indexOf("=") + if (split <= 0 || split === spec.length - 1) + throw new UsageError( + `--text-file needs =, such as content=page.md, not "${spec}".`, + ) + return { + field: parseField(spec.slice(0, split), "--text-file"), + path: spec.slice(split + 1), + } + }) + for (const [position, { field }] of textFiles.entries()) + for (const other of textFiles.slice(position + 1)) { + const [short, long] = + field.length <= other.field.length + ? [field, other.field] + : [other.field, field] + if (short.every((segment, at) => segment === long[at])) + throw new UsageError( + short.length === long.length + ? `--text-file names ${fieldName(short)} twice.` + : `--text-file sets ${fieldName(short)} and ${fieldName(long)}, which is inside it.`, + ) + } + const stdin = textFiles.filter(({ path }) => path === "-").length + if (stdin + (json === "-" ? 1 : 0) > 1) + throw new UsageError( + "Only one input can come from stdin: the JSON with - or one --text-file.", + ) + return textFiles } -/** Reads a tool's arguments: a JSON object from the argument, a file or stdin. */ +/** A text file's exact content; no trimming and no newline changes. */ +async function readText(path: string, readStdin: () => Promise) { + let bytes: Uint8Array + try { + bytes = path === "-" ? await readStdin() : await readFile(path) + } catch (error) { + throw new UsageError( + `Cannot read ${path}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`, + ) + } + try { + return exact.decode(bytes) + } catch { + throw new UsageError( + `${path === "-" ? "stdin" : path} is not valid UTF-8 text.`, + ) + } +} + +/** + * Reads a tool's arguments: a JSON object from the argument, a file or stdin, + * with the exact text of each --text-file in its field. + */ export async function readToolArguments({ json, file, - readStdin = () => text(process.stdin), + textFiles = [], + readStdin = () => buffer(process.stdin), }: ToolInputSource): Promise> { if (json !== undefined && file !== undefined) throw new UsageError( @@ -33,7 +106,7 @@ export async function readToolArguments({ } } else if (json === "-") { from = "stdin" - source = await readStdin() + source = new TextDecoder().decode(await readStdin()) } else if (json !== undefined) source = json if (!source.trim()) source = "{}" @@ -47,5 +120,9 @@ export async function readToolArguments({ } if (!value || typeof value !== "object" || Array.isArray(value)) throw new UsageError(`The tool input from ${from} must be a JSON object.`) - return value as Record + const input = value as Record + + for (const { field, path } of textFiles) + setText(input, field, await readText(path, readStdin)) + return input } diff --git a/src/main.ts b/src/main.ts index a4548da..c687648 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,8 @@ import { exitCodes, isSignInRequired, } from "./errors.ts" +import { getText } from "./fields.ts" +import type { Field } from "./fields.ts" import { waitForForm } from "./form-wait.ts" import { readToolArguments } from "./input.ts" import { @@ -45,6 +47,10 @@ const requestTimeoutMs = 120_000 const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n` +/** A result as JSON, or with --raw one text field of it exactly as it is. */ +const output = (value: unknown, raw: Field | undefined) => + raw ? getText(value, raw) : json(value) + /** A rejected or expired sign-in, from any layer, becomes SignInRequiredError. */ function signInError(error: unknown, target: Target) { if (isSignInRequired(error)) return error @@ -345,7 +351,7 @@ async function run(command: Command, io: Io): Promise { return status(command.target, io) case "api": { const data = await runApi(command, io) - io.stdout(json(data)) + io.stdout(output(data, command.raw)) return exitCodes.ok } case "instructions": { @@ -402,6 +408,7 @@ async function run(command: Command, io: Io): Promise { const args = await readToolArguments({ json: command.json, file: command.file, + textFiles: command.textFiles, }) if (command.allOrganizations) return callInEvery(command, args, io) if (command.organization !== undefined) @@ -420,7 +427,7 @@ async function run(command: Command, io: Io): Promise { io.stderr(json(toolError(result))) return exitCodes.error } - io.stdout(json(toolOutput(result))) + io.stdout(output(toolOutput(result), command.raw)) return exitCodes.ok } case "form-wait": { diff --git a/src/text-io.test.ts b/src/text-io.test.ts new file mode 100644 index 0000000..233352b --- /dev/null +++ b/src/text-io.test.ts @@ -0,0 +1,281 @@ +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import type * as McpClient from "@modelcontextprotocol/client" +import type { CallToolResult } from "@modelcontextprotocol/client" +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" + +import type { CredentialStore } from "./credentials.ts" +import { main } from "./main.ts" +import type * as OAuth from "./oauth.ts" + +const callTool = vi.hoisted(() => vi.fn()) +const connect = vi.hoisted(() => vi.fn()) +vi.mock("@modelcontextprotocol/client", async (original) => ({ + ...(await original()), + Client: class { + connect = connect + callTool = callTool + close = async () => {} + }, + StreamableHTTPClientTransport: class {}, +})) +vi.mock("./oauth.ts", async (original) => ({ + ...(await original()), + StoredTokenAuth: class { + token = async () => "private-token" + onUnauthorized = async () => {} + }, +})) + +const signedIn = { + get: async () => ({ tokens: { access_token: "t", token_type: "Bearer" } }), +} as unknown as CredentialStore + +async function run(argv: Array) { + let stdout = "" + let stderr = "" + const code = await main(argv, { + stdout: (text) => { + stdout += text + }, + stderr: (text) => { + stderr += text + }, + interactive: false, + env: {}, + store: signedIn, + }) + return { code, stdout, stderr } +} + +const markdown = "# Plan\r\n\r\n\t- Café ✓ 👩🏽‍💻\n indented\n\n" + +async function textFile(content: string) { + const path = join(await mkdtemp(join(tmpdir(), "fulldev-text-")), "page.md") + await writeFile(path, content) + return path +} + +const structured = (data: Record): CallToolResult => ({ + content: [{ type: "text", text: JSON.stringify(data) }], + structuredContent: data, +}) + +beforeEach(() => { + connect.mockReset().mockResolvedValue(undefined) + callTool.mockReset() +}) + +describe("call with --text-file and --raw", () => { + it("sends the exact text with the JSON input, its version and organization", async () => { + callTool.mockResolvedValue(structured({ pageId: "p", version: 8 })) + const path = await textFile(markdown) + const { code, stdout } = await run([ + "pages", + "call", + "update_page", + '{"pageId":"p","expectedVersion":7,"content":{"blocks":[{"type":"text"}]}}', + "--text-file", + `content.blocks.0.markdown=${path}`, + "--org", + "acme", + ]) + expect(code).toBe(0) + expect(callTool.mock.calls[0]![0]).toEqual({ + name: "pages_update_page", + arguments: { + pageId: "p", + expectedVersion: 7, + content: { blocks: [{ type: "text", markdown }] }, + organization: "acme", + }, + }) + expect(stdout).toBe( + `${JSON.stringify({ pageId: "p", version: 8 }, null, 2)}\n`, + ) + }) + + it("prints a text field of the result exactly, from structured or text content", async () => { + callTool.mockResolvedValueOnce(structured({ markdown, version: 3 })) + expect( + await run(["pages", "call", "export_page_markdown", "--raw", "markdown"]), + ).toEqual({ code: 0, stdout: markdown, stderr: "" }) + callTool.mockResolvedValueOnce({ + content: [{ type: "text", text: JSON.stringify({ file: { markdown } }) }], + }) + expect( + (await run(["cms", "call", "read_file", "--raw", "file.markdown"])) + .stdout, + ).toBe(markdown) + callTool.mockResolvedValueOnce(structured({ notes: "" })) + expect(await run(["work", "call", "get_task", "--raw", "notes"])).toEqual({ + code: 0, + stdout: "", + stderr: "", + }) + }) + + it.each([ + [{ version: 3 }, /it is missing/], + [{ markdown: 3 }, /it is a number/], + [{ markdown: null }, /it is null/], + ])( + "prints nothing on stdout when the field is not text: %j", + async (data, message) => { + callTool.mockResolvedValue(structured(data)) + const { code, stdout, stderr } = await run([ + "pages", + "call", + "export_page_markdown", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error).toMatchObject({ + code: "RAW_NOT_TEXT", + message: expect.stringMatching(message), + }) + }, + ) + + it("prints a tool's error with its details and nothing on stdout", async () => { + const error = { + code: "VERSION_CONFLICT", + message: "The page changed.", + currentVersion: 9, + } + callTool.mockResolvedValue({ + isError: true, + content: [{ type: "text", text: JSON.stringify({ error }) }], + }) + const { code, stdout, stderr } = await run([ + "pages", + "call", + "export_page_markdown", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr)).toEqual({ error }) + }) + + it.each([ + [['{"content":"old"}', "--text-file", "content=PATH"], /already has/], + [["{}", "--text-file", "__proto__.x=PATH"], /__proto__/], + [["-", "--text-file", "content=-"], /stdin/], + [ + ["{}", "--text-file", "content=PATH", "--text-file", "content=PATH"], + /twice/, + ], + [["--raw", "markdown", "--all-orgs"], /--raw/], + ])("refuses %j before connecting", async (args, message) => { + const path = await textFile("text") + const { code, stdout, stderr } = await run([ + "pages", + "call", + "update_page", + ...args.map((arg) => arg.replace("PATH", path)), + ]) + expect(code).toBe(64) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error.message).toMatch(message) + expect(connect).not.toHaveBeenCalled() + expect(callTool).not.toHaveBeenCalled() + }) +}) + +describe("api with --text-file and --raw", () => { + const spec = { + paths: { + "/pages/update": { post: { operationId: "pages.update" } }, + "/pages/exportMarkdown": { + get: { operationId: "pages.exportMarkdown" }, + }, + }, + } + const fetchFn = vi.fn() + beforeEach(() => { + fetchFn.mockReset() + vi.stubGlobal("fetch", fetchFn) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + const answer = (body: unknown, status = 200) => + fetchFn.mockImplementation(async (url) => + new Request(url).url.endsWith("openapi.json") + ? Response.json(spec) + : Response.json(body, { status }), + ) + + it("posts the exact text and prints a raw field", async () => { + answer({ markdown, version: 4 }) + const path = await textFile(markdown) + const { code, stdout } = await run([ + "api", + "pages.pages.update", + '{"pageId":"p","expectedVersion":3}', + "--text-file", + `markdown=${path}`, + "--raw", + "markdown", + ]) + expect(code).toBe(0) + expect(stdout).toBe(markdown) + const init = fetchFn.mock.calls[1]![1] + expect(JSON.parse(init!.body as string)).toEqual({ + pageId: "p", + expectedVersion: 3, + markdown, + }) + }) + + it("keeps a conflict's details and prints nothing on stdout", async () => { + answer( + { + defined: true, + code: "CONFLICT", + status: 409, + message: "The page changed.", + data: { currentVersion: 9 }, + }, + 409, + ) + const { code, stdout, stderr } = await run([ + "api", + "pages.pages.exportMarkdown", + '{"pageId":"p"}', + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr)).toEqual({ + error: { + code: "CONFLICT", + message: "The page changed.", + status: 409, + data: { currentVersion: 9 }, + }, + }) + }) + + it("refuses a conflicting text before sending the change", async () => { + answer({}) + const path = await textFile("text") + const { code } = await run([ + "api", + "pages.pages.update", + '{"markdown":"old"}', + "--text-file", + `markdown=${path}`, + ]) + expect(code).toBe(64) + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(new Request(fetchFn.mock.calls[0]![0]).url).toMatch(/openapi\.json$/) + }) +}) From 5795c382ba5a8e537f2b2e9225ff7cf990584a28 Mon Sep 17 00:00:00 2001 From: Sil Date: Tue, 13 Oct 2026 23:19:51 +0200 Subject: [PATCH 2/5] Check --raw against declared output schemas; keep receipts on late failures --raw now refuses before sending when the MCP tool's output schema or the API's response schema proves the field can never be text. It reads only what a schema proves: types, closed properties, array items, local refs and unions whose every branch rules it out. MCP tools are listed only with --raw. When a request succeeded but its field is not text, the error says so and keeps the full result under "result" with "succeeded": true. API errors keep the response's own HTTP status over a status in the body. Co-Authored-By: Claude Opus 5.5 --- .changeset/exact-text-io.md | 2 +- README.md | 3 +- dist/cli.mjs | 118 ++++++++++++++++++++---- skills/fulldev/SKILL.md | 2 +- src/api.test.ts | 22 +++++ src/api.ts | 30 ++++++- src/args.ts | 5 +- src/fields.test.ts | 104 ++++++++++++++++++++++ src/fields.ts | 112 +++++++++++++++++++++-- src/main.ts | 43 ++++++--- src/text-io.test.ts | 173 ++++++++++++++++++++++++++++++++++-- 11 files changed, 562 insertions(+), 52 deletions(-) create mode 100644 src/fields.test.ts diff --git a/.changeset/exact-text-io.md b/.changeset/exact-text-io.md index c3d6de4..cff342f 100644 --- a/.changeset/exact-text-io.md +++ b/.changeset/exact-text-io.md @@ -2,4 +2,4 @@ "fulldev": minor --- -Send and read exact text. `--text-file =` puts a file's exact UTF-8 content, or stdin with `-`, in one field of a `call` or `api` input, such as `changes.0.content`; `--raw ` prints one text field of the result without JSON quotes or an added newline. Conflicting, unsafe or ambiguous fields are refused before anything is sent. `fulldev api` errors keep the server's details, such as `status` and a conflict's `data`. Add Fulldev Meet as the app `meet`. +Send and read exact text. `--text-file =` puts a file's exact UTF-8 content, or stdin with `-`, in one field of a `call` or `api` input, such as `changes.0.content`; `--raw ` prints one text field of the result without JSON quotes or an added newline. Conflicting, unsafe or ambiguous fields, and a `--raw` field the declared output schema rules out, are refused before anything is sent; when a request succeeded but its field is not text, the error says so and keeps the full result. `fulldev api` errors keep the server's details, such as `status` and a conflict's `data`. Add Fulldev Meet as the app `meet`. diff --git a/README.md b/README.md index 6ef9964..51c9c4b 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,8 @@ fulldev work call update_task '{"taskId":""}' --text-file expectedNotes=base - A field is a dotted path, such as `content.blocks.0.markdown`. Objects on the way are made; arrays must be in the JSON, with the item. - The JSON leaves that field out, and each field is named once. `-` reads stdin, for one input only. - Names such as `__proto__` and `constructor`, an existing field, a missing array item, and text that is not UTF-8 are usage errors (exit 64), before anything is sent. -- `--raw` exits with 1 and prints nothing on stdout when the field is missing or not text. It does not work with `--all-orgs`. +- When the tool's output schema or the API's response schema shows the field can never be text, `--raw` refuses before sending (exit 64). It does not work with `--all-orgs`. +- When the request succeeded but its field is missing or not text, `--raw` prints nothing on stdout and exits with 1. The error has `"succeeded": true` and the full `result`, such as a new page's id: use it, and do not send the request again. `fulldev cms form wait` keeps calling `cms_wait_for_form` until the person sends the form, with a progress line on stderr each round, then prints the result. After `--timeout` minutes (default 30) it stops, also in the middle of a request, prints the form id, and exits with 2, so you can run it again. diff --git a/dist/cli.mjs b/dist/cli.mjs index 12c6174..1317033 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -363,16 +363,83 @@ function setText(input, field, text) { container = container[segment]; } } -/** The text at `field` of a result, or an error that names what is there. */ -function getText(value, field) { - let current = value; +/** +* --raw: the text at `field` of a successful request's result. When it is +* not text, the error says the request succeeded and keeps its result, such +* as a new page's id, so nobody sends a change twice. +*/ +function rawText(result, field, readOnly) { + let current = result; for (const segment of field) { current = Array.isArray(current) && index.test(segment) ? current[Number(segment)] : isRecord(current) && Object.hasOwn(current, segment) ? current[segment] : void 0; if (current === void 0) break; } if (typeof current === "string") return current; const kind = current === void 0 ? "missing" : current === null ? "null" : Array.isArray(current) ? "an array" : typeof current === "object" ? "an object" : `a ${typeof current}`; - throw new CliError("RAW_NOT_TEXT", `--raw ${fieldName(field)} needs text in the result, and it is ${kind}.`); + const name = fieldName(field); + throw new CliError("RAW_NOT_TEXT", `The request succeeded${readOnly ? "" : " and any change it made is kept"}, but --raw ${name} needs text, and the result's ${name} is ${kind}. The full result is under "result"${readOnly ? "." : "; do not send the request again."}`, exitCodes.error, { + succeeded: true, + result + }); +} +const article = (type) => type === "null" ? "null" : /^[aeiou]/.test(type) ? `an ${type}` : `a ${type}`; +/** A local reference such as #/$defs/page or #/components/schemas/Page. */ +function resolve(root, ref) { + if (ref !== "#" && !ref.startsWith("#/")) return void 0; + let node = root; + for (const part of ref === "#" ? [] : ref.slice(2).split("/")) { + const key = part.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!isRecord(node) && !Array.isArray(node) || !Object.hasOwn(node, key)) return void 0; + node = node[key]; + } + return node; +} +/** +* Why a declared output schema rules out text at `field`, or undefined when +* it may be text. It trusts only what the schema proves: types, properties +* closed with additionalProperties false, array items, local $refs, and +* unions whose every branch rules it out. An open object, a pattern or an +* unknown keyword may hold text. +*/ +function ruledOutText(schema, field, root = schema) { + let budget = 200; + const visit = (node, position) => { + if (--budget < 0 || !isRecord(node)) return void 0; + if (typeof node.$ref === "string") return visit(resolve(root, node.$ref), position); + if (Array.isArray(node.allOf)) { + const ruled = node.allOf.map((branch) => visit(branch, position)).find((reason) => reason !== void 0); + if (ruled) return ruled; + } + for (const branches of [node.anyOf, node.oneOf]) { + if (!Array.isArray(branches) || branches.length === 0) continue; + const reasons = branches.map((branch) => visit(branch, position)); + if (reasons.every((reason) => reason !== void 0)) return reasons[0]; + } + const types = typeof node.type === "string" ? [node.type] : Array.isArray(node.type) ? node.type.filter((type) => typeof type === "string") : void 0; + const at = fieldName(field.slice(0, position)) || "the result"; + const is = () => `${at} is ${types.map(article).join(" or ")}`; + const segment = field[position]; + if (segment === void 0) return types && !types.includes("string") ? is() : void 0; + const reasons = []; + if (!types || types.includes("object")) { + const properties = isRecord(node.properties) ? node.properties : {}; + const composed = [ + "allOf", + "anyOf", + "oneOf", + "patternProperties" + ].some((key) => node[key] !== void 0); + reasons.push(Object.hasOwn(properties, segment) ? visit(properties[segment], position + 1) : composed ? void 0 : node.additionalProperties === false ? `${at} has no ${segment}` : visit(node.additionalProperties, position + 1)); + } + if (index.test(segment) && (!types || types.includes("array"))) { + const item = Number(segment); + const prefix = Array.isArray(node.prefixItems) ? node.prefixItems : []; + reasons.push(item < prefix.length ? visit(prefix[item], position + 1) : node.items === false ? `${at} has no item ${segment}` : visit(node.items, position + 1)); + } + if (reasons.length === 0) return `${is()}, so it has no ${segment}`; + return reasons.every((reason) => reason !== void 0) ? reasons[0] : void 0; + }; + return visit(schema, 0); } //#endregion //#region src/input.ts @@ -899,10 +966,16 @@ async function runApi(command, io, fetchFn = fetch) { signal: AbortSignal.timeout(3e4) }); if (!specResponse.ok) throw new CliError("API_UNAVAILABLE", "This server does not expose the app's OpenAPI specification."); - const operations = apiOperations(await specResponse.json()); + const spec = await specResponse.json(); + const operations = apiOperations(spec); if (!command.operation) return operations; const operation = operations.find((candidate) => candidate.operationId === command.operation); if (!operation) throw new CliError("NOT_FOUND", `Unknown API operation: ${command.operation}. Run fulldev api ${app.name}.`); + const { raw } = command; + if (raw) { + const reasons = Object.entries(operation.responses ?? {}).filter(([status]) => status.startsWith("2")).map(([, response]) => ruledOutText(response.content?.["application/json"]?.schema, raw, spec)); + if (reasons.length > 0 && reasons.every((reason) => reason !== void 0)) throw new UsageError(`--raw ${fieldName(raw)} can never be text: the API's response schema says ${reasons[0]}. Nothing was sent.`, `fulldev api ${app.name}`); + } const input = await readToolArguments({ json: command.json, file: command.file, @@ -965,11 +1038,11 @@ async function runApi(command, io, fetchFn = fetch) { const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0; const { code, message, defined: _defined, ...details } = record(record(body)?.error) ?? record(body) ?? {}; throw new CliError(typeof code === "string" ? code : "API_FAILED", typeof message === "string" ? message : `API request failed (${response.status}).`, exitCodes.error, { - status: response.status, - ...details + ...details, + status: response.status }); } - return body; + return raw ? rawText(body, raw, reads) : body; } //#endregion //#region src/apps.ts @@ -1061,7 +1134,10 @@ const textDetails = ` input, such as content=page.md or changes.0.content=page.md; - reads stdin. Nothing is trimmed or changed. The JSON must leave that field out; objects on the way are made, arrays must be in the JSON. --raw prints one -text field of the result exactly, without JSON quotes or an added newline.`; +text field of the result exactly, without JSON quotes or an added newline. +It refuses before sending when the output schema rules that field out. When +a request succeeded but the field is not text, the error has "succeeded" +and the full "result": do not send it again.`; const apiTopic = { usage: "fulldev api [.] [JSON | -] [--file ] [--org ]", summary: "List or call an operation of the app's OpenAPI API", @@ -1645,8 +1721,6 @@ async function waitForForm(callTool, { branchId, formId, timeoutMs, app = "cms", /** A tool call can wait on the server for up to a minute. */ const requestTimeoutMs = 12e4; const json = (value) => `${JSON.stringify(value, null, 2)}\n`; -/** A result as JSON, or with --raw one text field of it exactly as it is. */ -const output = (value, raw) => raw ? getText(value, raw) : json(value); /** A rejected or expired sign-in, from any layer, becomes SignInRequiredError. */ function signInError(error, target) { if (isSignInRequired(error)) return error; @@ -1881,7 +1955,7 @@ async function run(command, io) { case "status": return status(command.target, io); case "api": { const data = await runApi(command, io); - io.stdout(output(data, command.raw)); + io.stdout(command.raw ? data : json(data)); return exitCodes.ok; } case "instructions": { @@ -1922,15 +1996,25 @@ async function run(command, io) { }); if (command.allOrganizations) return callInEvery(command, args, io); if (command.organization !== void 0) args.organization = command.organization; - const result = await withClient(command.target, command.session, io, (client) => client.callTool({ - name: command.tool, - arguments: args - }, { timeout: requestTimeoutMs })); + const { raw } = command; + const { result, readOnly } = await withClient(command.target, command.session, io, async (client) => { + const tool = raw ? (await listTools(client)).find(({ name }) => name === command.tool) : void 0; + const ruledOut = raw && ruledOutText(tool?.outputSchema, raw); + if (raw && ruledOut) throw new UsageError(`--raw ${fieldName(raw)} can never be text: the tool's output schema says ${ruledOut}. Nothing was sent.`, `fulldev tools ${command.tool}`); + return { + result: await client.callTool({ + name: command.tool, + arguments: args + }, { timeout: requestTimeoutMs }), + readOnly: tool?.annotations?.readOnlyHint === true + }; + }); if (result.isError) { io.stderr(json(toolError(result))); return exitCodes.error; } - io.stdout(output(toolOutput(result), command.raw)); + const value = toolOutput(result); + io.stdout(raw ? rawText(value, raw, readOnly) : json(value)); return exitCodes.ok; } case "form-wait": { diff --git a/skills/fulldev/SKILL.md b/skills/fulldev/SKILL.md index 80bce14..cd53dbe 100644 --- a/skills/fulldev/SKILL.md +++ b/skills/fulldev/SKILL.md @@ -94,7 +94,7 @@ JSON ## Exact text -`--text-file =` puts a file's exact text in one field of the input; `-` reads stdin. Leave that field out of the JSON; arrays and their items must be in it. `--raw ` prints one text field of the result as it is, for a file. Both work with `call` and `api`. +`--text-file =` puts a file's exact text in one field of the input; `-` reads stdin. Leave that field out of the JSON; arrays and their items must be in it. `--raw ` prints one text field of the result as it is, for a file. Both work with `call` and `api`. When `--raw` fails with `"succeeded": true`, the request went through: take what you need from `result`, such as a new id, and never send it again. ```sh fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' --raw content > home.md diff --git a/src/api.test.ts b/src/api.test.ts index e07dbdf..c233eb4 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -180,6 +180,28 @@ describe("HTTP API command", () => { }) expect(fetchFn).toHaveBeenCalledTimes(2) }) + it("reports the response's own HTTP status, whatever the body says", async () => { + const fetchFn = vi.fn(async (url) => + new Request(url).url.endsWith("openapi.json") + ? Response.json(spec) + : Response.json( + { + defined: true, + code: "CONFLICT", + status: 200, + message: "The record changed.", + data: { currentVersion: 4 }, + }, + { status: 409 }, + ), + ) + await expect( + runApi(command(["api", "contacts.records.update", "{}"]), io, fetchFn), + ).rejects.toMatchObject({ + code: "CONFLICT", + details: { status: 409, data: { currentVersion: 4 } }, + }) + }) it("rejects unknown operations and paths outside the app before obtaining credentials", async () => { const fetchFn = vi.fn(async () => Response.json(spec)) await expect( diff --git a/src/api.ts b/src/api.ts index ec8c36d..efba033 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,12 @@ import type { Command } from "./args.ts" import type { Io } from "./main.ts" -import { CliError, SignInRequiredError, exitCodes } from "./errors.ts" +import { + CliError, + SignInRequiredError, + UsageError, + exitCodes, +} from "./errors.ts" +import { fieldName, rawText, ruledOutText } from "./fields.ts" import { readToolArguments } from "./input.ts" import { StoredTokenAuth, browserLogin } from "./oauth.ts" @@ -12,6 +18,7 @@ interface Operation { operationId?: string summary?: string parameters?: Array + responses?: Record }> } interface Specification { paths?: Record> @@ -48,7 +55,8 @@ export async function runApi( "API_UNAVAILABLE", "This server does not expose the app's OpenAPI specification.", ) - const operations = apiOperations((await specResponse.json()) as Specification) + const spec = (await specResponse.json()) as Specification + const operations = apiOperations(spec) if (!command.operation) return operations const operation = operations.find( (candidate) => candidate.operationId === command.operation, @@ -58,6 +66,20 @@ export async function runApi( "NOT_FOUND", `Unknown API operation: ${command.operation}. Run fulldev api ${app.name}.`, ) + const { raw } = command + if (raw) { + // Before anything is sent: does every success response rule out text? + const reasons = Object.entries(operation.responses ?? {}) + .filter(([status]) => status.startsWith("2")) + .map(([, response]) => + ruledOutText(response.content?.["application/json"]?.schema, raw, spec), + ) + if (reasons.length > 0 && reasons.every((reason) => reason !== undefined)) + throw new UsageError( + `--raw ${fieldName(raw)} can never be text: the API's response schema says ${reasons[0]}. Nothing was sent.`, + `fulldev api ${app.name}`, + ) + } const input = await readToolArguments({ json: command.json, file: command.file, @@ -173,8 +195,8 @@ export async function runApi( ? message : `API request failed (${response.status}).`, exitCodes.error, - { status: response.status, ...details }, + { ...details, status: response.status }, ) } - return body + return raw ? rawText(body, raw, reads) : body } diff --git a/src/args.ts b/src/args.ts index e754706..1f88de3 100644 --- a/src/args.ts +++ b/src/args.ts @@ -100,7 +100,10 @@ const textDetails = ` input, such as content=page.md or changes.0.content=page.md; - reads stdin. Nothing is trimmed or changed. The JSON must leave that field out; objects on the way are made, arrays must be in the JSON. --raw prints one -text field of the result exactly, without JSON quotes or an added newline.` +text field of the result exactly, without JSON quotes or an added newline. +It refuses before sending when the output schema rules that field out. When +a request succeeded but the field is not text, the error has "succeeded" +and the full "result": do not send it again.` const apiTopic: Topic = { usage: diff --git a/src/fields.test.ts b/src/fields.test.ts new file mode 100644 index 0000000..bbda14f --- /dev/null +++ b/src/fields.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vite-plus/test" + +import { parseField, ruledOutText } from "./fields.ts" + +const field = (text: string) => parseField(text, "--raw") + +describe("ruledOutText", () => { + const receipt = { + type: "object", + properties: { + pageId: { type: "string" }, + version: { type: "integer" }, + blocks: { type: "array", items: { $ref: "#/$defs/block" } }, + submittedAt: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + $defs: { + block: { + type: "object", + properties: { markdown: { type: "string" } }, + additionalProperties: false, + }, + }, + } + + it.each([ + ["version", "version is an integer"], + ["pageId.sha", "pageId is a string, so it has no sha"], + ["blocks.0.title", "blocks.0 has no title"], + ["blocks.0.markdown.x", "blocks.0.markdown is a string, so it has no x"], + ["blocks.first", "blocks is an array, so it has no first"], + ])("rules out %s", (path, reason) => { + expect(ruledOutText(receipt, field(path))).toBe(reason) + }) + + it.each([ + "pageId", + "blocks.0.markdown", + "submittedAt", + // Not declared, and the object is open. + "markdown", + "anything.deeper", + ])("allows %s", (path) => { + expect(ruledOutText(receipt, field(path))).toBeUndefined() + }) + + it.each([ + ["no schema", undefined], + ["an empty schema", {}], + ["the server's open response schema", { anyOf: [{}, { not: {} }] }], + ["an unknown reference", { $ref: "https://example.com/page.json" }], + ["a reference cycle", { $ref: "#" }], + [ + "a union with an open branch", + { anyOf: [{ type: "integer" }, { type: "object" }] }, + ], + [ + "a pattern of properties", + { + type: "object", + patternProperties: { "^m": { type: "string" } }, + additionalProperties: false, + }, + ], + [ + "properties from a composition", + { + type: "object", + allOf: [{ properties: { markdown: { type: "string" } } }], + additionalProperties: false, + }, + ], + ])("allows any field under %s", (_, schema) => { + expect(ruledOutText(schema, field("markdown"))).toBeUndefined() + }) + + it("rules out a field only when every branch of a union does", () => { + expect( + ruledOutText( + { anyOf: [{ type: "integer" }, { type: "null" }] }, + field("markdown"), + ), + ).toBe("the result is an integer, so it has no markdown") + }) + + it("resolves OpenAPI component references against the whole document", () => { + const spec = { + components: { + schemas: { + Receipt: { + type: "object", + properties: { version: { type: "integer" } }, + }, + }, + }, + } + expect( + ruledOutText( + { $ref: "#/components/schemas/Receipt" }, + field("version"), + spec, + ), + ).toBe("version is an integer") + }) +}) diff --git a/src/fields.ts b/src/fields.ts index 47f4e91..e6db38c 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -1,4 +1,4 @@ -import { CliError, UsageError } from "./errors.ts" +import { CliError, UsageError, exitCodes } from "./errors.ts" /** A field of a JSON value by its dotted path, such as content.blocks.0.markdown. */ export type Field = Array @@ -66,9 +66,17 @@ export function setText( } } -/** The text at `field` of a result, or an error that names what is there. */ -export function getText(value: unknown, field: Field): string { - let current = value +/** + * --raw: the text at `field` of a successful request's result. When it is + * not text, the error says the request succeeded and keeps its result, such + * as a new page's id, so nobody sends a change twice. + */ +export function rawText( + result: unknown, + field: Field, + readOnly: boolean, +): string { + let current = result for (const segment of field) { current = Array.isArray(current) && index.test(segment) @@ -89,8 +97,102 @@ export function getText(value: unknown, field: Field): string { : typeof current === "object" ? "an object" : `a ${typeof current}` + const name = fieldName(field) throw new CliError( "RAW_NOT_TEXT", - `--raw ${fieldName(field)} needs text in the result, and it is ${kind}.`, + `The request succeeded${readOnly ? "" : " and any change it made is kept"}, but --raw ${name} needs text, and the result's ${name} is ${kind}. The full result is under "result"${readOnly ? "." : "; do not send the request again."}`, + exitCodes.error, + { succeeded: true, result }, ) } + +const article = (type: string) => + type === "null" ? "null" : /^[aeiou]/.test(type) ? `an ${type}` : `a ${type}` + +/** A local reference such as #/$defs/page or #/components/schemas/Page. */ +function resolve(root: unknown, ref: string): unknown { + if (ref !== "#" && !ref.startsWith("#/")) return undefined + let node = root + for (const part of ref === "#" ? [] : ref.slice(2).split("/")) { + const key = part.replaceAll("~1", "/").replaceAll("~0", "~") + if ((!isRecord(node) && !Array.isArray(node)) || !Object.hasOwn(node, key)) + return undefined + node = (node as Record)[key] + } + return node +} + +/** + * Why a declared output schema rules out text at `field`, or undefined when + * it may be text. It trusts only what the schema proves: types, properties + * closed with additionalProperties false, array items, local $refs, and + * unions whose every branch rules it out. An open object, a pattern or an + * unknown keyword may hold text. + */ +export function ruledOutText( + schema: unknown, + field: Field, + root: unknown = schema, +): string | undefined { + // Bounds reference cycles; running out means "may be text". + let budget = 200 + const visit = (node: unknown, position: number): string | undefined => { + if (--budget < 0 || !isRecord(node)) return undefined + if (typeof node.$ref === "string") + return visit(resolve(root, node.$ref), position) + if (Array.isArray(node.allOf)) { + const ruled = node.allOf + .map((branch) => visit(branch, position)) + .find((reason) => reason !== undefined) + if (ruled) return ruled + } + for (const branches of [node.anyOf, node.oneOf]) { + if (!Array.isArray(branches) || branches.length === 0) continue + const reasons = branches.map((branch) => visit(branch, position)) + if (reasons.every((reason) => reason !== undefined)) return reasons[0] + } + const types = + typeof node.type === "string" + ? [node.type] + : Array.isArray(node.type) + ? node.type.filter((type): type is string => typeof type === "string") + : undefined + const at = fieldName(field.slice(0, position)) || "the result" + const is = () => `${at} is ${types!.map(article).join(" or ")}` + const segment = field[position] + if (segment === undefined) + return types && !types.includes("string") ? is() : undefined + const reasons: Array = [] + if (!types || types.includes("object")) { + const properties = isRecord(node.properties) ? node.properties : {} + const composed = ["allOf", "anyOf", "oneOf", "patternProperties"].some( + (key) => node[key] !== undefined, + ) + reasons.push( + Object.hasOwn(properties, segment) + ? visit(properties[segment], position + 1) + : composed + ? undefined + : node.additionalProperties === false + ? `${at} has no ${segment}` + : visit(node.additionalProperties, position + 1), + ) + } + if (index.test(segment) && (!types || types.includes("array"))) { + const item = Number(segment) + const prefix = Array.isArray(node.prefixItems) ? node.prefixItems : [] + reasons.push( + item < prefix.length + ? visit(prefix[item], position + 1) + : node.items === false + ? `${at} has no item ${segment}` + : visit(node.items, position + 1), + ) + } + if (reasons.length === 0) return `${is()}, so it has no ${segment}` + return reasons.every((reason) => reason !== undefined) + ? reasons[0] + : undefined + } + return visit(schema, 0) +} diff --git a/src/main.ts b/src/main.ts index c687648..c9eabcc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,8 +19,7 @@ import { exitCodes, isSignInRequired, } from "./errors.ts" -import { getText } from "./fields.ts" -import type { Field } from "./fields.ts" +import { fieldName, rawText, ruledOutText } from "./fields.ts" import { waitForForm } from "./form-wait.ts" import { readToolArguments } from "./input.ts" import { @@ -47,10 +46,6 @@ const requestTimeoutMs = 120_000 const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n` -/** A result as JSON, or with --raw one text field of it exactly as it is. */ -const output = (value: unknown, raw: Field | undefined) => - raw ? getText(value, raw) : json(value) - /** A rejected or expired sign-in, from any layer, becomes SignInRequiredError. */ function signInError(error: unknown, target: Target) { if (isSignInRequired(error)) return error @@ -350,8 +345,9 @@ async function run(command: Command, io: Io): Promise { case "status": return status(command.target, io) case "api": { + // With --raw, runApi selects the text, since it knows whether it read. const data = await runApi(command, io) - io.stdout(output(data, command.raw)) + io.stdout(command.raw ? (data as string) : json(data)) return exitCodes.ok } case "instructions": { @@ -413,21 +409,40 @@ async function run(command: Command, io: Io): Promise { if (command.allOrganizations) return callInEvery(command, args, io) if (command.organization !== undefined) args.organization = command.organization - const result = await withClient( + const { raw } = command + const { result, readOnly } = await withClient( command.target, command.session, io, - (client) => - client.callTool( - { name: command.tool, arguments: args }, - { timeout: requestTimeoutMs }, - ), + async (client) => { + // Only --raw lists the tools, to check its field against the + // tool's output schema before a call that may change data. + const tool = raw + ? (await listTools(client)).find( + ({ name }) => name === command.tool, + ) + : undefined + const ruledOut = raw && ruledOutText(tool?.outputSchema, raw) + if (raw && ruledOut) + throw new UsageError( + `--raw ${fieldName(raw)} can never be text: the tool's output schema says ${ruledOut}. Nothing was sent.`, + `fulldev tools ${command.tool}`, + ) + return { + result: await client.callTool( + { name: command.tool, arguments: args }, + { timeout: requestTimeoutMs }, + ), + readOnly: tool?.annotations?.readOnlyHint === true, + } + }, ) if (result.isError) { io.stderr(json(toolError(result))) return exitCodes.error } - io.stdout(output(toolOutput(result), command.raw)) + const value = toolOutput(result) + io.stdout(raw ? rawText(value, raw, readOnly) : json(value)) return exitCodes.ok } case "form-wait": { diff --git a/src/text-io.test.ts b/src/text-io.test.ts index 233352b..fbc8944 100644 --- a/src/text-io.test.ts +++ b/src/text-io.test.ts @@ -11,12 +11,14 @@ import { main } from "./main.ts" import type * as OAuth from "./oauth.ts" const callTool = vi.hoisted(() => vi.fn()) +const listTools = vi.hoisted(() => vi.fn()) const connect = vi.hoisted(() => vi.fn()) vi.mock("@modelcontextprotocol/client", async (original) => ({ ...(await original()), Client: class { connect = connect callTool = callTool + listTools = listTools close = async () => {} }, StreamableHTTPClientTransport: class {}, @@ -66,8 +68,29 @@ const structured = (data: Record): CallToolResult => ({ beforeEach(() => { connect.mockReset().mockResolvedValue(undefined) callTool.mockReset() + listTools.mockReset().mockResolvedValue({ tools: [] }) }) +/** cms_commit_files as the server lists it: revision is a string, more may come. */ +const commitFiles = { + name: "cms_commit_files", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { + revision: { type: "string" }, + pullRequest: { + type: "object", + properties: { number: { type: "number" } }, + additionalProperties: {}, + }, + }, + required: ["revision"], + additionalProperties: {}, + }, + annotations: { readOnlyHint: false, destructiveHint: true }, +} + describe("call with --text-file and --raw", () => { it("sends the exact text with the JSON input, its version and organization", async () => { callTool.mockResolvedValue(structured({ pageId: "p", version: 8 })) @@ -95,6 +118,7 @@ describe("call with --text-file and --raw", () => { expect(stdout).toBe( `${JSON.stringify({ pageId: "p", version: 8 }, null, 2)}\n`, ) + expect(listTools).not.toHaveBeenCalled() }) it("prints a text field of the result exactly, from structured or text content", async () => { @@ -118,29 +142,98 @@ describe("call with --text-file and --raw", () => { }) it.each([ - [{ version: 3 }, /it is missing/], - [{ markdown: 3 }, /it is a number/], - [{ markdown: null }, /it is null/], + [{ pageId: "new", version: 1 }, /markdown is missing/], + [{ markdown: 3 }, /markdown is a number/], + [{ markdown: null }, /markdown is null/], ])( - "prints nothing on stdout when the field is not text: %j", + "keeps the result of a call that succeeded when the field is not text: %j", async (data, message) => { callTool.mockResolvedValue(structured(data)) const { code, stdout, stderr } = await run([ "pages", "call", - "export_page_markdown", + "create_page", "--raw", "markdown", ]) expect(code).toBe(1) expect(stdout).toBe("") - expect(JSON.parse(stderr).error).toMatchObject({ + expect(JSON.parse(stderr).error).toEqual({ code: "RAW_NOT_TEXT", message: expect.stringMatching(message), + succeeded: true, + result: data, }) + expect(JSON.parse(stderr).error.message).toMatch( + /^The request succeeded and any change it made is kept.*do not send the request again\.$/, + ) + expect(callTool).toHaveBeenCalledTimes(1) }, ) + it("says a read-only tool changed nothing when its field is not text", async () => { + listTools.mockResolvedValue({ + tools: [ + { + name: "pages_get_page", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + ], + }) + callTool.mockResolvedValue(structured({ pageId: "p" })) + const { stderr } = await run(["pages", "call", "get_page", "--raw", "x"]) + expect(JSON.parse(stderr).error.message).toMatch( + /^The request succeeded, but --raw x needs text.*under "result"\.$/, + ) + }) + + it("refuses a field the output schema rules out before calling the tool", async () => { + listTools.mockResolvedValue({ tools: [commitFiles] }) + for (const field of ["revision.sha", "pullRequest.number"]) { + const { code, stdout, stderr } = await run([ + "cms", + "call", + "commit_files", + '{"branchId":"b","expectedRevision":"old","changes":[{"path":"a.md","content":"x"}]}', + "--raw", + field, + ]) + expect(code).toBe(64) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error).toMatchObject({ + code: "USAGE", + message: expect.stringMatching( + /can never be text.*Nothing was sent\.$/, + ), + help: "fulldev tools cms_commit_files", + }) + } + expect(callTool).not.toHaveBeenCalled() + }) + + it("calls the tool when its output schema allows text at the field", async () => { + listTools.mockResolvedValue({ tools: [commitFiles] }) + callTool.mockResolvedValue(structured({ revision: "abc123" })) + expect( + await run(["cms", "call", "commit_files", "--raw", "revision"]), + ).toEqual({ code: 0, stdout: "abc123", stderr: "" }) + // Not declared, but the schema allows more properties: the call goes out. + const { code, stderr } = await run([ + "cms", + "call", + "commit_files", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(JSON.parse(stderr).error).toMatchObject({ + succeeded: true, + result: { revision: "abc123" }, + }) + expect(callTool).toHaveBeenCalledTimes(2) + }) + it("prints a tool's error with its details and nothing on stdout", async () => { const error = { code: "VERSION_CONFLICT", @@ -196,6 +289,17 @@ describe("api with --text-file and --raw", () => { get: { operationId: "pages.exportMarkdown" }, }, }, + components: { + schemas: { + Receipt: { + type: "object", + properties: { + pageId: { type: "string" }, + version: { type: "integer" }, + }, + }, + }, + }, } const fetchFn = vi.fn() beforeEach(() => { @@ -205,12 +309,19 @@ describe("api with --text-file and --raw", () => { afterEach(() => { vi.unstubAllGlobals() }) - const answer = (body: unknown, status = 200) => + const answer = (body: unknown, status = 200, create?: unknown) => fetchFn.mockImplementation(async (url) => new Request(url).url.endsWith("openapi.json") - ? Response.json(spec) + ? Response.json({ + ...spec, + paths: { ...spec.paths, "/pages/create": { post: create } }, + }) : Response.json(body, { status }), ) + const creates = (schema: unknown) => ({ + operationId: "pages.create", + responses: { "200": { content: { "application/json": { schema } } } }, + }) it("posts the exact text and prints a raw field", async () => { answer({ markdown, version: 4 }) @@ -264,6 +375,52 @@ describe("api with --text-file and --raw", () => { }) }) + it("refuses a field the response schema rules out before sending", async () => { + answer( + { pageId: "p-new", version: 1 }, + 200, + creates({ $ref: "#/components/schemas/Receipt" }), + ) + const { code, stdout, stderr } = await run([ + "api", + "pages.pages.create", + "{}", + "--raw", + "version", + ]) + expect(code).toBe(64) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error.message).toBe( + "--raw version can never be text: the API's response schema says version is an integer. Nothing was sent.", + ) + expect(fetchFn).toHaveBeenCalledTimes(1) + }) + + it("keeps the receipt when a change succeeded under an open schema", async () => { + // How the server describes every response today: anything. + answer( + { pageId: "p-new", version: 1 }, + 200, + creates({ anyOf: [{}, { not: {} }] }), + ) + const { code, stdout, stderr } = await run([ + "api", + "pages.pages.create", + "{}", + "--raw", + "markdown", + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr).error).toMatchObject({ + code: "RAW_NOT_TEXT", + message: expect.stringMatching(/^The request succeeded and any change/), + succeeded: true, + result: { pageId: "p-new", version: 1 }, + }) + expect(fetchFn).toHaveBeenCalledTimes(2) + }) + it("refuses a conflicting text before sending the change", async () => { answer({}) const path = await textFile("text") From dc794b3486f80557412d9f9789cdea5da6c000e8 Mon Sep 17 00:00:00 2001 From: Sil Date: Tue, 13 Oct 2026 23:26:58 +0200 Subject: [PATCH 3/5] Allow raw text from default and alternate API responses --- dist/cli.mjs | 5 +++- src/api.ts | 11 +++++---- src/text-io.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/dist/cli.mjs b/dist/cli.mjs index 1317033..0af32f7 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -973,7 +973,10 @@ async function runApi(command, io, fetchFn = fetch) { if (!operation) throw new CliError("NOT_FOUND", `Unknown API operation: ${command.operation}. Run fulldev api ${app.name}.`); const { raw } = command; if (raw) { - const reasons = Object.entries(operation.responses ?? {}).filter(([status]) => status.startsWith("2")).map(([, response]) => ruledOutText(response.content?.["application/json"]?.schema, raw, spec)); + const reasons = Object.entries(operation.responses ?? {}).filter(([status]) => status.startsWith("2") || status === "default").flatMap(([, response]) => { + const representations = Object.values(response.content ?? {}); + return representations.length === 0 ? [void 0] : representations.map(({ schema }) => ruledOutText(schema, raw, spec)); + }); if (reasons.length > 0 && reasons.every((reason) => reason !== void 0)) throw new UsageError(`--raw ${fieldName(raw)} can never be text: the API's response schema says ${reasons[0]}. Nothing was sent.`, `fulldev api ${app.name}`); } const input = await readToolArguments({ diff --git a/src/api.ts b/src/api.ts index efba033..016b9ff 100644 --- a/src/api.ts +++ b/src/api.ts @@ -70,10 +70,13 @@ export async function runApi( if (raw) { // Before anything is sent: does every success response rule out text? const reasons = Object.entries(operation.responses ?? {}) - .filter(([status]) => status.startsWith("2")) - .map(([, response]) => - ruledOutText(response.content?.["application/json"]?.schema, raw, spec), - ) + .filter(([status]) => status.startsWith("2") || status === "default") + .flatMap(([, response]) => { + const representations = Object.values(response.content ?? {}) + return representations.length === 0 + ? [undefined] + : representations.map(({ schema }) => ruledOutText(schema, raw, spec)) + }) if (reasons.length > 0 && reasons.every((reason) => reason !== undefined)) throw new UsageError( `--raw ${fieldName(raw)} can never be text: the API's response schema says ${reasons[0]}. Nothing was sent.`, diff --git a/src/text-io.test.ts b/src/text-io.test.ts index fbc8944..ed3ede0 100644 --- a/src/text-io.test.ts +++ b/src/text-io.test.ts @@ -309,14 +309,22 @@ describe("api with --text-file and --raw", () => { afterEach(() => { vi.unstubAllGlobals() }) - const answer = (body: unknown, status = 200, create?: unknown) => + const answer = ( + body: unknown, + status = 200, + create?: unknown, + mediaType = "application/json", + ) => fetchFn.mockImplementation(async (url) => new Request(url).url.endsWith("openapi.json") ? Response.json({ ...spec, paths: { ...spec.paths, "/pages/create": { post: create } }, }) - : Response.json(body, { status }), + : Response.json(body, { + status, + headers: { "content-type": mediaType }, + }), ) const creates = (schema: unknown) => ({ operationId: "pages.create", @@ -421,6 +429,50 @@ describe("api with --text-file and --raw", () => { expect(fetchFn).toHaveBeenCalledTimes(2) }) + it.each([ + ["default success response", "default", "application/json", 201], + ["alternative representation", "200", "application/vnd.fulldev+json", 200], + ])( + "allows text declared by an %s", + async (_, status, mediaType, responseStatus) => { + const nontext = { + type: "object", + properties: { markdown: { type: "number" } }, + } + const text = { + type: "object", + properties: { markdown: { type: "string" } }, + } + const responses: Record< + string, + { content: Record } + > = { + "200": { content: { "application/json": { schema: nontext } } }, + } + responses[status] ??= { content: {} } + responses[status]!.content[mediaType] = { schema: text } + answer( + { markdown }, + responseStatus, + { + operationId: "pages.create", + responses, + }, + mediaType, + ) + const { code, stdout } = await run([ + "api", + "pages.pages.create", + "{}", + "--raw", + "markdown", + ]) + expect(code).toBe(0) + expect(stdout).toBe(markdown) + expect(fetchFn).toHaveBeenCalledTimes(2) + }, + ) + it("refuses a conflicting text before sending the change", async () => { answer({}) const path = await textFile("text") From 502c91e979503b3f5df6ba625877e008809690a4 Mon Sep 17 00:00:00 2001 From: Sil Date: Tue, 13 Oct 2026 23:27:42 +0200 Subject: [PATCH 4/5] Use short Work notes versions in CLI examples --- README.md | 7 +++---- skills/fulldev/SKILL.md | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 51c9c4b..7c6c2e0 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,11 @@ fulldev cms call read_file '{"branchId":"","path":"src/content/home.md"}' -- fulldev pages call export_page_markdown '{"pageId":""}' --raw markdown > page.md ``` -To change a task's notes only when nobody else did, read them first and send them back as `expectedNotes`; without it, the update works as before: +To change a task's notes only when nobody else did, read them first and send back the `notesVersion` from that same result as `expectedNotesVersion`. This short token also works for large notes; without a guard, the update works as before: ```sh -fulldev work call get_task '{"taskId":""}' --raw notes > base.md -cp base.md notes.md # edit notes.md -fulldev work call update_task '{"taskId":""}' --text-file expectedNotes=base.md --text-file notes=notes.md +fulldev work call get_task '{"taskId":""}' +fulldev work call update_task '{"taskId":"","expectedNotesVersion":""}' --text-file notes=notes.md ``` - A field is a dotted path, such as `content.blocks.0.markdown`. Objects on the way are made; arrays must be in the JSON, with the item. diff --git a/skills/fulldev/SKILL.md b/skills/fulldev/SKILL.md index cd53dbe..09b4df8 100644 --- a/skills/fulldev/SKILL.md +++ b/skills/fulldev/SKILL.md @@ -104,15 +104,14 @@ fulldev pages call export_page_markdown '{"pageId":""}' --raw markdown > pag The page export is for reading: it does not hold every block, so never publish it back as the page. -To change a task's notes without overwriting someone else's change, send the notes you read as `expectedNotes`: +To change a task's notes without overwriting someone else's change, send the `notesVersion` from the same result as the notes you read as `expectedNotesVersion`. This short token also works for large notes: ```sh -fulldev work call get_task '{"taskId":""}' --raw notes > base.md -cp base.md notes.md # edit notes.md -fulldev work call update_task '{"taskId":""}' --text-file expectedNotes=base.md --text-file notes=notes.md +fulldev work call get_task '{"taskId":""}' +fulldev work call update_task '{"taskId":"","expectedNotesVersion":""}' --text-file notes=notes.md ``` -On a conflict, read again and redo the change; never drop `expectedNotes`, `expectedVersion` or `expectedRevision` to force it. +On a conflict, read again and redo the change; never drop `expectedNotesVersion`, `expectedNotes`, `expectedVersion` or `expectedRevision` to force it. ## Forms in the CMS From 662c6abbc9bb20ef8c8b8bcb7010cb68f047eab9 Mon Sep 17 00:00:00 2001 From: Sil Date: Tue, 13 Oct 2026 23:33:33 +0200 Subject: [PATCH 5/5] Preserve stdin stream failures during text input --- dist/cli.mjs | 5 +++-- src/input.test.ts | 7 +++++++ src/input.ts | 16 +++++++++------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/dist/cli.mjs b/dist/cli.mjs index 0af32f7..6df203a 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -468,8 +468,9 @@ function parseTextFiles(specs, json) { /** A text file's exact content; no trimming and no newline changes. */ async function readText(path, readStdin) { let bytes; - try { - bytes = path === "-" ? await readStdin() : await readFile(path); + if (path === "-") bytes = await readStdin(); + else try { + bytes = await readFile(path); } catch (error) { throw new UsageError(`Cannot read ${path}: ${error.code ?? error.message}`); } diff --git a/src/input.test.ts b/src/input.test.ts index 1cf5008..e8d0503 100644 --- a/src/input.test.ts +++ b/src/input.test.ts @@ -125,6 +125,13 @@ describe("--text-file", () => { ).toEqual({ taskId: "t", notes: "New notes\n" }) }) + it("preserves stdin read failures for both input modes", async () => { + const failure = new Error("input stream failed") + const readStdin = () => Promise.reject(failure) + await expect(read("-", [], readStdin)).rejects.toBe(failure) + await expect(read("{}", ["notes=-"], readStdin)).rejects.toBe(failure) + }) + it.each([ ["__proto__.polluted=x"], ["constructor.prototype.polluted=x"], diff --git a/src/input.ts b/src/input.ts index 0706177..8057186 100644 --- a/src/input.ts +++ b/src/input.ts @@ -63,13 +63,15 @@ export function parseTextFiles( /** A text file's exact content; no trimming and no newline changes. */ async function readText(path: string, readStdin: () => Promise) { let bytes: Uint8Array - try { - bytes = path === "-" ? await readStdin() : await readFile(path) - } catch (error) { - throw new UsageError( - `Cannot read ${path}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`, - ) - } + if (path === "-") bytes = await readStdin() + else + try { + bytes = await readFile(path) + } catch (error) { + throw new UsageError( + `Cannot read ${path}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`, + ) + } try { return exact.decode(bytes) } catch {