@thenavidm/slipway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +757 -0
  5. package/SECURITY.md +33 -0
  6. package/SKILL.md +136 -0
  7. package/dist/app.d.ts +210 -0
  8. package/dist/app.js +364 -0
  9. package/dist/bin.d.ts +14 -0
  10. package/dist/bin.js +178 -0
  11. package/dist/check.d.ts +52 -0
  12. package/dist/check.js +313 -0
  13. package/dist/cli/completion.d.ts +5 -0
  14. package/dist/cli/completion.js +72 -0
  15. package/dist/cli/context.d.ts +97 -0
  16. package/dist/cli/context.js +94 -0
  17. package/dist/cli/data.d.ts +17 -0
  18. package/dist/cli/data.js +120 -0
  19. package/dist/cli/flags.d.ts +35 -0
  20. package/dist/cli/flags.js +208 -0
  21. package/dist/cli/help.d.ts +15 -0
  22. package/dist/cli/help.js +213 -0
  23. package/dist/cli/install.d.ts +10 -0
  24. package/dist/cli/install.js +65 -0
  25. package/dist/cli/output.d.ts +22 -0
  26. package/dist/cli/output.js +159 -0
  27. package/dist/cli/run.d.ts +10 -0
  28. package/dist/cli/run.js +343 -0
  29. package/dist/confirm.d.ts +90 -0
  30. package/dist/confirm.js +166 -0
  31. package/dist/data.d.ts +109 -0
  32. package/dist/data.js +324 -0
  33. package/dist/docs.d.ts +13 -0
  34. package/dist/docs.js +66 -0
  35. package/dist/doctor.d.ts +12 -0
  36. package/dist/doctor.js +100 -0
  37. package/dist/entry.d.ts +11 -0
  38. package/dist/entry.js +34 -0
  39. package/dist/errors.d.ts +96 -0
  40. package/dist/errors.js +153 -0
  41. package/dist/guard.d.ts +46 -0
  42. package/dist/guard.js +89 -0
  43. package/dist/index.d.ts +27 -0
  44. package/dist/index.js +16 -0
  45. package/dist/install.d.ts +98 -0
  46. package/dist/install.js +325 -0
  47. package/dist/jobs.d.ts +143 -0
  48. package/dist/jobs.js +247 -0
  49. package/dist/openapi.d.ts +127 -0
  50. package/dist/openapi.js +549 -0
  51. package/dist/pages.d.ts +22 -0
  52. package/dist/pages.js +61 -0
  53. package/dist/policy.d.ts +66 -0
  54. package/dist/policy.js +74 -0
  55. package/dist/redact.d.ts +18 -0
  56. package/dist/redact.js +60 -0
  57. package/dist/result.d.ts +44 -0
  58. package/dist/result.js +74 -0
  59. package/dist/rpc.d.ts +69 -0
  60. package/dist/rpc.js +120 -0
  61. package/dist/schema.d.ts +99 -0
  62. package/dist/schema.js +192 -0
  63. package/dist/search.d.ts +18 -0
  64. package/dist/search.js +119 -0
  65. package/dist/serve.d.ts +30 -0
  66. package/dist/serve.js +149 -0
  67. package/dist/server.d.ts +20 -0
  68. package/dist/server.js +244 -0
  69. package/dist/sync.d.ts +35 -0
  70. package/dist/sync.js +119 -0
  71. package/dist/testing.d.ts +35 -0
  72. package/dist/testing.js +41 -0
  73. package/dist/tool.d.ts +194 -0
  74. package/dist/tool.js +151 -0
  75. package/dist/util.d.ts +12 -0
  76. package/dist/util.js +35 -0
  77. package/package.json +89 -0
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How a result is printed.
3
+ *
4
+ * A person reads text, a script reads JSON, a spreadsheet reads CSV, and a
5
+ * model reads whatever costs it least. One result, several shapes, chosen by a
6
+ * flag, never by guessing at what the reader is.
7
+ */
8
+ import type { Tool } from "../tool.js";
9
+ export type Format = "auto" | "json" | "compact" | "jsonl" | "csv" | "tsv" | "quiet";
10
+ export type OutputOptions = {
11
+ format: Format;
12
+ select?: string[];
13
+ };
14
+ /**
15
+ * `--select a,b.c` keeps only those fields. Dotted paths descend, arrays are
16
+ * walked element by element, which is what makes a long listing affordable.
17
+ */
18
+ export declare function selectFields(data: unknown, paths: readonly string[]): unknown;
19
+ /** What a result is as data: a content result's data, or its parts described in words. */
20
+ export declare function terminalData(value: unknown): unknown;
21
+ /** Render a result for stdout. Always ends with a newline. */
22
+ export declare function formatOutput(value: unknown, tool: Tool | undefined, options: OutputOptions): string;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * How a result is printed.
3
+ *
4
+ * A person reads text, a script reads JSON, a spreadsheet reads CSV, and a
5
+ * model reads whatever costs it least. One result, several shapes, chosen by a
6
+ * flag, never by guessing at what the reader is.
7
+ */
8
+ import { getPath } from "../pages.js";
9
+ import { isContentResult } from "../result.js";
10
+ /**
11
+ * `--select a,b.c` keeps only those fields. Dotted paths descend, arrays are
12
+ * walked element by element, which is what makes a long listing affordable.
13
+ */
14
+ export function selectFields(data, paths) {
15
+ if (Array.isArray(data))
16
+ return data.map((item) => selectFields(item, paths));
17
+ if (data === null || typeof data !== "object")
18
+ return data;
19
+ // Grouped by first segment: assigning one path at a time let the last path win,
20
+ // so `--select posts.uri,posts.text` returned only the text.
21
+ const byHead = new Map();
22
+ for (const path of paths) {
23
+ const [head, ...rest] = path.split(".");
24
+ if (!head)
25
+ continue;
26
+ const group = byHead.get(head) ?? [];
27
+ if (rest.length)
28
+ group.push(rest.join("."));
29
+ byHead.set(head, group);
30
+ }
31
+ const out = {};
32
+ for (const [head, rest] of byHead) {
33
+ const value = data[head];
34
+ if (value === undefined)
35
+ continue;
36
+ out[head] = rest.length ? selectFields(value, rest) : value;
37
+ }
38
+ return out;
39
+ }
40
+ function describePart(part) {
41
+ if (part.type === "text")
42
+ return part.text;
43
+ const p = part;
44
+ const size = p.data ?? p.resource?.blob;
45
+ const kb = size ? `, ${Math.max(1, Math.round((size.length * 3) / 4 / 1024))} KB` : "";
46
+ const where = p.uri ?? p.resource?.uri;
47
+ return `[${p.type}${p.mimeType ?? p.resource?.mimeType ? ` ${p.mimeType ?? p.resource?.mimeType}` : ""}${kb}${where ? ` ${where}` : ""}]`;
48
+ }
49
+ /** What a result is as data: a content result's data, or its parts described in words. */
50
+ export function terminalData(value) {
51
+ if (!isContentResult(value))
52
+ return value;
53
+ if (value.data !== undefined)
54
+ return value.data;
55
+ return value.parts.map(describePart).join("\n");
56
+ }
57
+ function cell(value, separator) {
58
+ if (value === null || value === undefined)
59
+ return "";
60
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
61
+ if (separator === "\t")
62
+ return text.replace(/[\t\n\r]+/g, " ");
63
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
64
+ }
65
+ function table(data, separator) {
66
+ if (Array.isArray(data)) {
67
+ if (data.every((row) => row === null || typeof row !== "object" || Array.isArray(row))) {
68
+ return data.map((row) => cell(row, separator)).join("\n");
69
+ }
70
+ const columns = [];
71
+ for (const row of data) {
72
+ if (row && typeof row === "object")
73
+ for (const key of Object.keys(row))
74
+ if (!columns.includes(key))
75
+ columns.push(key);
76
+ }
77
+ const lines = [columns.map((column) => cell(column, separator)).join(separator)];
78
+ for (const row of data) {
79
+ lines.push(columns.map((column) => cell(row?.[column], separator)).join(separator));
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+ if (data !== null && typeof data === "object") {
84
+ // A single array field is the listing a person meant to tabulate.
85
+ const arrays = Object.entries(data).filter(([, value]) => Array.isArray(value));
86
+ if (arrays.length === 1)
87
+ return table(arrays[0][1], separator);
88
+ return Object.entries(data)
89
+ .map(([key, value]) => `${cell(key, separator)}${separator}${cell(value, separator)}`)
90
+ .join("\n");
91
+ }
92
+ return cell(data, separator);
93
+ }
94
+ /**
95
+ * The one list inside a wrapper like `{ items, cursor }`, which is what a person
96
+ * means when they ask for lines or rows of a result that is not itself a list.
97
+ */
98
+ function singleList(data) {
99
+ if (data === null || typeof data !== "object" || Array.isArray(data))
100
+ return undefined;
101
+ const arrays = Object.values(data).filter(Array.isArray);
102
+ return arrays.length === 1 ? arrays[0] : undefined;
103
+ }
104
+ const IDENTIFIERS = ["id", "uri", "url", "name", "handle", "slug", "key"];
105
+ function quiet(data, select) {
106
+ const pick = (item) => {
107
+ if (item === null || typeof item !== "object")
108
+ return String(item ?? "");
109
+ if (select?.length === 1)
110
+ return String(getPath(item, select[0]) ?? "");
111
+ const key = IDENTIFIERS.find((candidate) => item[candidate] !== undefined);
112
+ return key ? String(item[key]) : JSON.stringify(item);
113
+ };
114
+ if (Array.isArray(data))
115
+ return data.map(pick).join("\n");
116
+ if (data !== null && typeof data === "object") {
117
+ const arrays = Object.values(data).filter(Array.isArray);
118
+ if (arrays.length === 1)
119
+ return arrays[0].map(pick).join("\n");
120
+ }
121
+ return pick(data);
122
+ }
123
+ /** Render a result for stdout. Always ends with a newline. */
124
+ export function formatOutput(value, tool, options) {
125
+ let data = terminalData(value);
126
+ if (options.select?.length && data !== null && typeof data === "object")
127
+ data = selectFields(data, options.select);
128
+ let text;
129
+ switch (options.format) {
130
+ case "json":
131
+ text = JSON.stringify(data, null, 2) ?? "null";
132
+ break;
133
+ case "compact":
134
+ text = JSON.stringify(data) ?? "null";
135
+ break;
136
+ case "jsonl": {
137
+ const list = Array.isArray(data) ? data : singleList(data);
138
+ text = list ? list.map((item) => JSON.stringify(item)).join("\n") : (JSON.stringify(data) ?? "null");
139
+ break;
140
+ }
141
+ case "csv":
142
+ text = table(data, ",");
143
+ break;
144
+ case "tsv":
145
+ text = table(data, "\t");
146
+ break;
147
+ case "quiet":
148
+ text = quiet(data, options.select);
149
+ break;
150
+ default:
151
+ if (typeof data === "string")
152
+ text = data;
153
+ else if (tool?.render && !options.select?.length && data !== undefined)
154
+ text = tool.render(data);
155
+ else
156
+ text = JSON.stringify(data, null, 2) ?? "null";
157
+ }
158
+ return text.endsWith("\n") ? text : `${text}\n`;
159
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The CLI surface.
3
+ *
4
+ * Every tool is a command with the same name, the same arguments and the same
5
+ * guard as over MCP, because both run through `app.invoke`. What this module
6
+ * adds is what a terminal needs and a protocol does not: flags, help, output
7
+ * shapes, exit codes, pagination and files.
8
+ */
9
+ import type { App, CliIO } from "../app.js";
10
+ export declare function runCli(app: App, argv: readonly string[], partial?: Partial<CliIO>): Promise<number>;
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The CLI surface.
3
+ *
4
+ * Every tool is a command with the same name, the same arguments and the same
5
+ * guard as over MCP, because both run through `app.invoke`. What this module
6
+ * adds is what a terminal needs and a protocol does not: flags, help, output
7
+ * shapes, exit codes, pagination and files.
8
+ */
9
+ import { writeFileSync } from "node:fs";
10
+ import { runDoctor } from "../doctor.js";
11
+ import { EXIT, UsageError, toSlipwayError } from "../errors.js";
12
+ import { eachPage } from "../pages.js";
13
+ import { visibility, policyEnvNames } from "../policy.js";
14
+ import { outputJsonSchema } from "../schema.js";
15
+ import { didYouMean, searchTools } from "../search.js";
16
+ import { completionScript } from "./completion.js";
17
+ import { agentContext, SLIPWAY_VERSION } from "./context.js";
18
+ import { flagsFor, missingRequired, parseJsonValue, parseToolArgs } from "./flags.js";
19
+ import { BUILTINS, renderGeneralHelp, renderList, renderToolHelp, toolLine } from "./help.js";
20
+ import { formatOutput } from "./output.js";
21
+ const VALUE_FLAGS = new Set(["--select", "--out", "--input", "--max-items", "--timeout"]);
22
+ const FORMATS = {
23
+ "--json": "json",
24
+ "--compact": "compact",
25
+ "--jsonl": "jsonl",
26
+ "--csv": "csv",
27
+ "--tsv": "tsv",
28
+ "--plain": "tsv",
29
+ "--quiet": "quiet",
30
+ };
31
+ /** Accepted so scripts written for other CLIs do not break. `--yes` is accepted and never confirms anything. */
32
+ const IGNORED = new Set(["--no-color", "--no-input", "--yes"]);
33
+ const SWITCHES = new Set([...Object.keys(FORMATS), ...IGNORED, "--agent", "--dry-run", "--confirm", "--wait", "--refresh", "--all", "--help", "-h", "--version", "-v"]);
34
+ function flagOf(token) {
35
+ const eq = token.indexOf("=");
36
+ return eq === -1 ? token : token.slice(0, eq);
37
+ }
38
+ /** The index of the command word: the first bare word that is not the value of a global flag. */
39
+ function findCommand(argv) {
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const token = argv[i];
42
+ if (token === "--")
43
+ return -1;
44
+ if (token.startsWith("-")) {
45
+ if (VALUE_FLAGS.has(token))
46
+ i++;
47
+ continue;
48
+ }
49
+ return i;
50
+ }
51
+ return -1;
52
+ }
53
+ /**
54
+ * Pull the CLI's own flags out of argv. A flag the tool itself defines wins, so
55
+ * a tool with an `input` or `select` argument keeps it.
56
+ */
57
+ function extractGlobals(argv, reserved) {
58
+ const globals = { format: "auto", explicitFormat: false, agent: false, dryRun: false, confirm: false, wait: false, refresh: false, all: false, help: false, version: false };
59
+ const rest = [];
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const token = argv[i];
62
+ if (token === "--") {
63
+ rest.push(...argv.slice(i));
64
+ break;
65
+ }
66
+ const name = flagOf(token);
67
+ if (reserved.has(name) || (!SWITCHES.has(name) && !VALUE_FLAGS.has(name))) {
68
+ rest.push(token);
69
+ continue;
70
+ }
71
+ if (VALUE_FLAGS.has(name)) {
72
+ const value = token.includes("=") ? token.slice(token.indexOf("=") + 1) : argv[++i];
73
+ if (value === undefined)
74
+ throw new UsageError(`${name} expects a value.`);
75
+ if (name === "--select")
76
+ globals.select = value.split(",").map((part) => part.trim()).filter(Boolean);
77
+ else if (name === "--out")
78
+ globals.out = value;
79
+ else if (name === "--input")
80
+ globals.input = value;
81
+ else {
82
+ const number = Number(value);
83
+ if (!Number.isInteger(number) || number < 1)
84
+ throw new UsageError(`${name} expects a positive whole number, got '${value}'.`);
85
+ if (name === "--max-items")
86
+ globals.maxItems = number;
87
+ else
88
+ globals.timeoutMs = number;
89
+ }
90
+ continue;
91
+ }
92
+ if (FORMATS[name]) {
93
+ globals.format = FORMATS[name];
94
+ globals.explicitFormat = true;
95
+ }
96
+ else if (name === "--agent")
97
+ globals.agent = true;
98
+ else if (name === "--dry-run")
99
+ globals.dryRun = true;
100
+ else if (name === "--confirm")
101
+ globals.confirm = true;
102
+ else if (name === "--wait")
103
+ globals.wait = true;
104
+ else if (name === "--refresh")
105
+ globals.refresh = true;
106
+ else if (name === "--all")
107
+ globals.all = true;
108
+ else if (name === "--help" || name === "-h")
109
+ globals.help = true;
110
+ else if (name === "--version" || name === "-v")
111
+ globals.version = true;
112
+ }
113
+ // Agent mode means one line of JSON, unless a format was asked for by name.
114
+ if (globals.agent && !globals.explicitFormat)
115
+ globals.format = "compact";
116
+ return { globals, rest };
117
+ }
118
+ function defaultIO(app, partial) {
119
+ return {
120
+ stdout: (text) => void process.stdout.write(text),
121
+ stderr: (text) => void process.stderr.write(text),
122
+ stdin: async () => {
123
+ const chunks = [];
124
+ for await (const chunk of process.stdin)
125
+ chunks.push(chunk);
126
+ return Buffer.concat(chunks).toString("utf8");
127
+ },
128
+ env: process.env,
129
+ isTTY: Boolean(process.stdout.isTTY),
130
+ bin: app.bins.cli,
131
+ ...partial,
132
+ };
133
+ }
134
+ function emitError(io, app, error, agent) {
135
+ const payload = app.secrets.redactDeep(error.toJSON());
136
+ io.stderr(`${agent ? JSON.stringify(payload) : JSON.stringify(payload, null, 2)}\n`);
137
+ }
138
+ export async function runCli(app, argv, partial = {}) {
139
+ const io = defaultIO(app, partial);
140
+ const at = findCommand(argv);
141
+ const command = at === -1 ? undefined : argv[at];
142
+ const builtin = command !== undefined && BUILTINS.includes(command);
143
+ const tool = command !== undefined && !builtin ? app.find(command) : undefined;
144
+ const reserved = new Set(tool ? flagsFor(tool.jsonSchema).map((flag) => flag.flag) : []);
145
+ let agent = argv.includes("--agent");
146
+ try {
147
+ const { globals, rest } = extractGlobals(at === -1 ? argv : [...argv.slice(0, at), ...argv.slice(at + 1)], reserved);
148
+ agent = globals.agent;
149
+ if (command === undefined) {
150
+ if (globals.version)
151
+ return printVersion(app, io);
152
+ if (globals.help)
153
+ return print(io, renderGeneralHelp(app, io.bin));
154
+ return print(io, renderList(app, app.tools(io.env), io.bin));
155
+ }
156
+ if (builtin)
157
+ return await runBuiltin(app, io, command, rest, globals);
158
+ if (!tool) {
159
+ const candidates = [...app.tools(io.env).map((t) => t.command), ...BUILTINS];
160
+ const guess = didYouMean(command, candidates);
161
+ throw new UsageError(`Unknown command '${command}'.${guess ? ` Did you mean '${guess}'?` : ""}`, {
162
+ hint: `Run \`${io.bin}\` to list commands, or \`${io.bin} which <words>\` to find one.`,
163
+ });
164
+ }
165
+ const seen = visibility(tool, app.policy(io.env));
166
+ if (!seen.visible) {
167
+ const names = policyEnvNames(app.envPrefix);
168
+ throw new UsageError(seen.reason === "read-only"
169
+ ? `${tool.command} is unavailable: ${names.readOnly}=1 hides every write.`
170
+ : `${tool.command} is in a toolset that is off: ${tool.tags.join(", ")}.`, { hint: seen.reason === "read-only" ? `Unset ${names.readOnly} to allow writes.` : `Add one of them to ${names.toolsets}, or set ${names.toolsets}=all.` });
171
+ }
172
+ if (globals.help)
173
+ return print(io, renderToolHelp(tool, io.bin));
174
+ return await runTool(app, io, tool, rest, globals);
175
+ }
176
+ catch (error) {
177
+ const failure = toSlipwayError(error);
178
+ emitError(io, app, failure, agent);
179
+ if (failure.code === "usage" && tool && !agent && io.isTTY)
180
+ io.stderr(renderToolHelp(tool, io.bin));
181
+ return failure.exitCode;
182
+ }
183
+ }
184
+ function print(io, text) {
185
+ io.stdout(text.endsWith("\n") ? text : `${text}\n`);
186
+ return EXIT.ok;
187
+ }
188
+ function printVersion(app, io) {
189
+ return print(io, `${app.name} ${app.version} (slipway ${SLIPWAY_VERSION})`);
190
+ }
191
+ function json(globals, value) {
192
+ return globals.format === "compact" ? JSON.stringify(value) : JSON.stringify(value, null, 2);
193
+ }
194
+ async function runBuiltin(app, io, command, rest, globals) {
195
+ const target = rest.find((token) => !token.startsWith("-"));
196
+ switch (command) {
197
+ case "tools":
198
+ return print(io, renderList(app, app.tools(io.env), io.bin));
199
+ case "version":
200
+ return printVersion(app, io);
201
+ case "help": {
202
+ if (!target)
203
+ return print(io, renderGeneralHelp(app, io.bin));
204
+ const tool = app.find(target);
205
+ if (!tool)
206
+ throw new UsageError(`Unknown command '${target}'.`, { hint: `Run \`${io.bin}\` to list commands.` });
207
+ return print(io, renderToolHelp(tool, io.bin));
208
+ }
209
+ case "schema": {
210
+ const tool = target ? app.find(target) : undefined;
211
+ if (!tool)
212
+ throw new UsageError(`schema expects a command${target ? `; '${target}' is not one` : ""}.`, { hint: `Run \`${io.bin}\` to list commands.` });
213
+ if (rest.includes("--output")) {
214
+ if (!tool.output)
215
+ throw new UsageError(`${tool.command} declares no output schema.`);
216
+ return print(io, json(globals, outputJsonSchema(tool.output)));
217
+ }
218
+ return print(io, json(globals, tool.jsonSchema));
219
+ }
220
+ case "agent-context":
221
+ return print(io, json(globals, agentContext(app, io.env, io.bin, { brief: rest.includes("--brief") })));
222
+ case "which": {
223
+ const query = rest.filter((token) => !token.startsWith("-")).join(" ");
224
+ if (!query)
225
+ throw new UsageError("which expects the words for what you want to do: which schedule a post");
226
+ const matches = searchTools(app.tools(io.env), query, 10);
227
+ if (globals.format !== "auto") {
228
+ return print(io, json(globals, matches.map(({ tool, score }) => ({ command: tool.command, title: tool.title, risk: tool.risk, score: Number(score.toFixed(2)) }))));
229
+ }
230
+ if (!matches.length)
231
+ return print(io, `No command matches '${query}'. Run \`${io.bin}\` to see them all.`);
232
+ return print(io, matches.map(({ tool }) => toolLine(tool)).join("\n"));
233
+ }
234
+ case "doctor":
235
+ return runDoctor(app, io, { network: rest.includes("--network"), json: globals.format !== "auto" });
236
+ case "login": {
237
+ const login = app.definition.login;
238
+ if (typeof login === "function")
239
+ return await login(io);
240
+ if (typeof login === "string")
241
+ return print(io, login);
242
+ return print(io, `${app.title} reads its credentials from the environment. Run \`${io.bin} doctor\` to see what is missing.`);
243
+ }
244
+ case "completion":
245
+ return print(io, completionScript(app, target, io.bin, io.env));
246
+ case "install": {
247
+ const { runInstall } = await import("./install.js");
248
+ return runInstall(app, io, rest, { agent: globals.agent, dryRun: globals.dryRun, format: globals.format });
249
+ }
250
+ case "data": {
251
+ const { runData } = await import("./data.js");
252
+ return runData(app, io, rest, { format: globals.format, ...(globals.select ? { select: globals.select } : {}), agent: globals.agent });
253
+ }
254
+ default:
255
+ throw new UsageError(`Unknown command '${command}'.`);
256
+ }
257
+ }
258
+ async function readInput(io, raw) {
259
+ const value = raw === "-" ? parseJsonValue(await io.stdin(), "--input") : parseJsonValue(raw, "--input");
260
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
261
+ throw new UsageError("--input expects a JSON object of arguments.");
262
+ }
263
+ return value;
264
+ }
265
+ async function runTool(app, io, tool, rest, globals) {
266
+ const flags = flagsFor(tool.jsonSchema);
267
+ const base = globals.input ? await readInput(io, globals.input) : {};
268
+ const args = { ...base, ...parseToolArgs(rest, flags, tool.positional) };
269
+ if (globals.confirm && tool.requireConfirm)
270
+ args.confirm = true;
271
+ const missing = missingRequired(flags, args).filter((flag) => !(globals.all && tool.paginate && flag.key === tool.paginate.cursorArg));
272
+ if (missing.length && !globals.dryRun) {
273
+ throw new UsageError(`Missing ${missing.map((flag) => flag.flag).join(", ")}.`, { hint: `Run \`${io.bin} ${tool.command} --help\`.` });
274
+ }
275
+ if ((globals.all || globals.maxItems) && !tool.paginate) {
276
+ throw new UsageError(`${tool.command} does not page, so --all and --max-items do not apply.`);
277
+ }
278
+ if (globals.wait && !tool.job) {
279
+ throw new UsageError(`${tool.command} does not start a job, so --wait does not apply.`);
280
+ }
281
+ const controller = new AbortController();
282
+ const onInterrupt = () => controller.abort(new DOMException("Interrupted", "AbortError"));
283
+ const listens = partialIsProcess(io);
284
+ if (listens)
285
+ process.once("SIGINT", onInterrupt);
286
+ const signal = globals.timeoutMs ? AbortSignal.any([controller.signal, AbortSignal.timeout(globals.timeoutMs)]) : controller.signal;
287
+ const options = {
288
+ surface: "cli",
289
+ confirmed: globals.confirm,
290
+ dryRun: globals.dryRun,
291
+ ...(globals.wait ? { waitMs: Number.POSITIVE_INFINITY } : {}),
292
+ ...(globals.refresh ? { refresh: true } : {}),
293
+ ...(io.isTTY && !globals.agent && tool.cache
294
+ ? { onCache: ({ ageSeconds }) => io.stderr(`(from the local cache, ${ageSeconds} s old; --refresh fetches it again)\n`) }
295
+ : {}),
296
+ signal,
297
+ env: io.env,
298
+ onProgress: io.isTTY && !globals.agent
299
+ ? ({ progress, total, message }) => io.stderr(`… ${total ? `${progress}/${total}` : progress}${message ? ` ${message}` : ""}\n`)
300
+ : undefined,
301
+ };
302
+ try {
303
+ let result;
304
+ if (tool.paginate && (globals.all || globals.maxItems) && !globals.dryRun) {
305
+ result = await allPages(app, tool, args, options, globals.maxItems ?? Number.POSITIVE_INFINITY);
306
+ }
307
+ else {
308
+ result = await app.invoke(tool.name, args, options);
309
+ }
310
+ if (globals.out)
311
+ return writeOut(io, globals, tool, result);
312
+ io.stdout(formatOutput(result, tool, { format: globals.format, select: globals.select }));
313
+ return EXIT.ok;
314
+ }
315
+ finally {
316
+ if (listens)
317
+ process.removeListener("SIGINT", onInterrupt);
318
+ }
319
+ }
320
+ /** Only the real process gets a Ctrl-C handler; a test harness passing its own stdout does not. */
321
+ function partialIsProcess(io) {
322
+ return io.env === process.env;
323
+ }
324
+ /** Follow a cursor until the pages run out or enough items arrived, and return them as one list. */
325
+ async function allPages(app, tool, args, options, max) {
326
+ const items = [];
327
+ const run = await eachPage(app, tool, args, options, tool.paginate.items, max, (page) => void items.push(...page));
328
+ return { items, count: run.count, pages: run.pages, next_cursor: run.next_cursor };
329
+ }
330
+ /** Write to a new file only, readable by its owner, so an export never replaces something or leaks to other users. */
331
+ function writeOut(io, globals, tool, result) {
332
+ const format = globals.format === "auto" ? "json" : globals.format;
333
+ const text = formatOutput(result, tool, { format, select: globals.select });
334
+ try {
335
+ writeFileSync(globals.out, text, { flag: "wx", mode: 0o600 });
336
+ }
337
+ catch (error) {
338
+ const code = error.code;
339
+ throw new UsageError(code === "EEXIST" ? `${globals.out} already exists. Choose a new file name.` : `Could not write ${globals.out}: ${error.message}`);
340
+ }
341
+ io.stdout(`${JSON.stringify({ saved: globals.out, bytes: Buffer.byteLength(text) })}\n`);
342
+ return EXIT.ok;
343
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Confirmation a model cannot fake.
3
+ *
4
+ * `confirm: true` is something the model types, so on its own it proves only
5
+ * that the model meant it. Where the client can put a person in front of the
6
+ * call, Slipway asks the person instead, and the model's flag stops counting:
7
+ *
8
+ * - Claude Code shows its own approval prompt for a tool marked as needing a
9
+ * person, on every call and in every permission mode. A call that arrives
10
+ * from it has already been approved, so nothing else is asked.
11
+ * - Any other client that supports elicitation is sent an approval form. The
12
+ * tool runs only on an explicit yes.
13
+ * - A client that can do neither falls back to `confirm: true`.
14
+ *
15
+ * An approval form's answer comes back from the client as data, and a client
16
+ * could attach an answer to a call nobody was asked about. So an answer only
17
+ * counts next to the signed state Slipway minted when it asked, which names
18
+ * the exact tool and arguments and can be used once.
19
+ */
20
+ import { inputRequired, type McpServer, type ServerContext } from "@modelcontextprotocol/server";
21
+ import type { App } from "./app.js";
22
+ import type { ConfirmMode } from "./policy.js";
23
+ import type { Tool } from "./tool.js";
24
+ /** The key of Slipway's approval form among a call's input requests. */
25
+ export declare const APPROVAL_KEY = "slipway_approval";
26
+ export type ClientView = {
27
+ name?: string;
28
+ version?: string;
29
+ capabilities?: Record<string, unknown>;
30
+ };
31
+ /**
32
+ * Who is calling and what it can do. On the 2026-07-28 revision every request
33
+ * carries this itself; on earlier revisions it was said once, at initialize.
34
+ */
35
+ export declare function clientView(server: McpServer, ctx: ServerContext): ClientView;
36
+ /**
37
+ * Whether the client can show a person a form. A bare `elicitation: {}` is the
38
+ * 2025 way of saying so; naming only `url` mode is not.
39
+ */
40
+ export declare function canAskPerson(client: ClientView): boolean;
41
+ /** Whether the client shows its own approval prompt for a tool that asks for a person. */
42
+ export declare function promptsItself(client: ClientView): boolean;
43
+ /**
44
+ * How this call gets confirmed.
45
+ *
46
+ * - `client`: the client already asked a person, so the call runs.
47
+ * - `person`: Slipway asks the person with an approval form.
48
+ * - `flag`: the call runs only with `confirm: true`.
49
+ */
50
+ export type ConfirmRoute = "client" | "person" | "flag";
51
+ export declare function confirmRoute(mode: ConfirmMode, client: ClientView, listedForPerson: boolean): ConfirmRoute;
52
+ type Pending = {
53
+ t: string;
54
+ h: string;
55
+ n: string;
56
+ };
57
+ /** For `ServerOptions.requestState.verify`: rejects state Slipway did not sign, or signed more than ten minutes ago. */
58
+ export declare const verifyApprovalState: (state: string, ctx: ServerContext) => Promise<Pending>;
59
+ /**
60
+ * The words and the one field an approval form shows.
61
+ *
62
+ * The field is required, starts unticked, and only an explicit true counts.
63
+ * Accepting is not enough on its own: a client with nobody to ask may accept a
64
+ * form by itself (Codex accepts a form that has no fields), and one that fills
65
+ * in defaults would otherwise approve with them.
66
+ */
67
+ export declare function approvalForm(appTitle: string, tool: Pick<Tool, "risk">, summary: string): {
68
+ message: string;
69
+ requestedSchema: {
70
+ type: "object";
71
+ properties: {
72
+ approve: {
73
+ type: "boolean";
74
+ title: string;
75
+ description: string;
76
+ default: boolean;
77
+ };
78
+ };
79
+ required: string[];
80
+ };
81
+ };
82
+ /**
83
+ * Ask a person to approve the call, or read their answer.
84
+ *
85
+ * Returns the input-required result to send while the person has not answered
86
+ * yet, and nothing once they approved. Throws a refusal for a no, a closed
87
+ * form, or an answer that does not belong to this exact call.
88
+ */
89
+ export declare function personApproval<Ctx>(app: App<Ctx>, tool: Tool<Ctx>, rawArgs: Record<string, unknown>, ctx: ServerContext, env: NodeJS.ProcessEnv): Promise<ReturnType<typeof inputRequired> | undefined>;
90
+ export {};