@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +757 -0
- package/SECURITY.md +33 -0
- package/SKILL.md +136 -0
- package/dist/app.d.ts +210 -0
- package/dist/app.js +364 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.js +178 -0
- package/dist/check.d.ts +52 -0
- package/dist/check.js +313 -0
- package/dist/cli/completion.d.ts +5 -0
- package/dist/cli/completion.js +72 -0
- package/dist/cli/context.d.ts +97 -0
- package/dist/cli/context.js +94 -0
- package/dist/cli/data.d.ts +17 -0
- package/dist/cli/data.js +120 -0
- package/dist/cli/flags.d.ts +35 -0
- package/dist/cli/flags.js +208 -0
- package/dist/cli/help.d.ts +15 -0
- package/dist/cli/help.js +213 -0
- package/dist/cli/install.d.ts +10 -0
- package/dist/cli/install.js +65 -0
- package/dist/cli/output.d.ts +22 -0
- package/dist/cli/output.js +159 -0
- package/dist/cli/run.d.ts +10 -0
- package/dist/cli/run.js +343 -0
- package/dist/confirm.d.ts +90 -0
- package/dist/confirm.js +166 -0
- package/dist/data.d.ts +109 -0
- package/dist/data.js +324 -0
- package/dist/docs.d.ts +13 -0
- package/dist/docs.js +66 -0
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +100 -0
- package/dist/entry.d.ts +11 -0
- package/dist/entry.js +34 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.js +153 -0
- package/dist/guard.d.ts +46 -0
- package/dist/guard.js +89 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +16 -0
- package/dist/install.d.ts +98 -0
- package/dist/install.js +325 -0
- package/dist/jobs.d.ts +143 -0
- package/dist/jobs.js +247 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.js +549 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +61 -0
- package/dist/policy.d.ts +66 -0
- package/dist/policy.js +74 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +60 -0
- package/dist/result.d.ts +44 -0
- package/dist/result.js +74 -0
- package/dist/rpc.d.ts +69 -0
- package/dist/rpc.js +120 -0
- package/dist/schema.d.ts +99 -0
- package/dist/schema.js +192 -0
- package/dist/search.d.ts +18 -0
- package/dist/search.js +119 -0
- package/dist/serve.d.ts +30 -0
- package/dist/serve.js +149 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +119 -0
- package/dist/testing.d.ts +35 -0
- package/dist/testing.js +41 -0
- package/dist/tool.d.ts +194 -0
- package/dist/tool.js +151 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +35 -0
- package/package.json +89 -0
package/dist/check.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slipway check`: the release gate.
|
|
3
|
+
*
|
|
4
|
+
* Unit tests prove a handler works. They do not prove that what a client
|
|
5
|
+
* receives matches what the CLI offers, that a schema will be accepted, that an
|
|
6
|
+
* example in the README still runs, or that the server answers when nothing is
|
|
7
|
+
* configured. Those are the failures that reach users, so they are checked here,
|
|
8
|
+
* against the real server, over the real protocol.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { agentContext } from "./cli/context.js";
|
|
15
|
+
import { flagsFor } from "./cli/flags.js";
|
|
16
|
+
import { BUILTINS, GLOBAL_FLAGS, renderToolHelp } from "./cli/help.js";
|
|
17
|
+
import { connectInMemory } from "./rpc.js";
|
|
18
|
+
import { repeatedDefinitions, schemaBytes, validate, formatIssues } from "./schema.js";
|
|
19
|
+
import { REQUIRES_USER_INTERACTION } from "./server.js";
|
|
20
|
+
const PROPERTY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
21
|
+
const DEFAULT_BUDGET = { warnBytes: 16 * 1024, errorBytes: 128 * 1024 };
|
|
22
|
+
function strip(schema) {
|
|
23
|
+
const { $schema: _ignored, ...rest } = schema;
|
|
24
|
+
return rest;
|
|
25
|
+
}
|
|
26
|
+
function stable(value) {
|
|
27
|
+
return JSON.stringify(value, (_key, inner) => inner && typeof inner === "object" && !Array.isArray(inner)
|
|
28
|
+
? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a.localeCompare(b)))
|
|
29
|
+
: inner);
|
|
30
|
+
}
|
|
31
|
+
/** Draft 2020-12 meta-schema validation, the check Claude Code runs before accepting a tool. Needs ajv. */
|
|
32
|
+
async function metaValidator() {
|
|
33
|
+
try {
|
|
34
|
+
const module = (await import("ajv/dist/2020.js"));
|
|
35
|
+
const ajv = new module.default({ strict: false, validateFormats: false });
|
|
36
|
+
return (schema) => (ajv.validateSchema(schema) ? undefined : ajv.errorsText(ajv.errors));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function checkApp(app, options = {}) {
|
|
43
|
+
const env = options.env ?? process.env;
|
|
44
|
+
const budget = options.schemaBudget ?? DEFAULT_BUDGET;
|
|
45
|
+
const findings = [];
|
|
46
|
+
const add = (level, check, message, tool) => findings.push({ level, check, message, ...(tool ? { tool } : {}) });
|
|
47
|
+
const tools = app.tools(env);
|
|
48
|
+
const meta = await metaValidator();
|
|
49
|
+
if (!meta)
|
|
50
|
+
add("warn", "schema", "ajv is not installed, so schemas were not checked against JSON Schema 2020-12. Add ajv as a dev dependency.");
|
|
51
|
+
let totalBytes = 0;
|
|
52
|
+
let largest;
|
|
53
|
+
for (const tool of app.allTools) {
|
|
54
|
+
if (BUILTINS.includes(tool.command)) {
|
|
55
|
+
add("error", "names", `'${tool.command}' is a built-in CLI command. Rename the tool.`, tool.name);
|
|
56
|
+
}
|
|
57
|
+
const words = tool.description.split(/\s+/).filter(Boolean).length;
|
|
58
|
+
if (tool.description.length < 30 || words < 5) {
|
|
59
|
+
add("warn", "descriptions", "The description is too thin to choose this tool by. Say what it does and when to use it.", tool.name);
|
|
60
|
+
}
|
|
61
|
+
if (tool.title.length > 60)
|
|
62
|
+
add("warn", "descriptions", "The title is long for a picker; keep it under 60 characters.", tool.name);
|
|
63
|
+
if (tool.requireConfirm && !tool.summary) {
|
|
64
|
+
add("warn", "safety", "A confirmed tool with no summary shows only its name in the refusal and the audit log.", tool.name);
|
|
65
|
+
}
|
|
66
|
+
const schema = tool.jsonSchema;
|
|
67
|
+
if (schema.type !== "object")
|
|
68
|
+
add("error", "schema", "The input schema's root must be an object.", tool.name);
|
|
69
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
70
|
+
if (key in schema)
|
|
71
|
+
add("warn", "schema", `A root-level ${key} is flattened by some clients; nest it inside a property.`, tool.name);
|
|
72
|
+
}
|
|
73
|
+
const properties = schema.properties ?? {};
|
|
74
|
+
for (const name of Object.keys(properties)) {
|
|
75
|
+
if (!PROPERTY.test(name))
|
|
76
|
+
add("error", "schema", `Property '${name}' must be 1-64 letters, digits, '_', '.' or '-'.`, tool.name);
|
|
77
|
+
}
|
|
78
|
+
const undocumented = Object.entries(properties).filter(([name, prop]) => name !== "confirm" && !prop.description).map(([name]) => name);
|
|
79
|
+
if (undocumented.length)
|
|
80
|
+
add("warn", "descriptions", `No description for: ${undocumented.join(", ")}.`, tool.name);
|
|
81
|
+
const problem = meta?.(schema);
|
|
82
|
+
if (problem)
|
|
83
|
+
add("error", "schema", `Not valid JSON Schema 2020-12: ${problem}`, tool.name);
|
|
84
|
+
const bytes = schemaBytes(schema);
|
|
85
|
+
totalBytes += bytes;
|
|
86
|
+
if (!largest || bytes > largest.bytes)
|
|
87
|
+
largest = { name: tool.name, bytes };
|
|
88
|
+
if (bytes > budget.errorBytes)
|
|
89
|
+
add("error", "size", `The schema is ${Math.round(bytes / 1024)} KB. Advertise a short schema and validate the full one in the handler.`, tool.name);
|
|
90
|
+
else if (bytes > budget.warnBytes)
|
|
91
|
+
add("warn", "size", `The schema is ${Math.round(bytes / 1024)} KB, which a model pays for every time it loads this tool.`, tool.name);
|
|
92
|
+
const repeated = repeatedDefinitions(schema);
|
|
93
|
+
if (repeated.length)
|
|
94
|
+
add("warn", "size", `Definitions appear more than once: ${repeated.slice(0, 5).join(", ")}${repeated.length > 5 ? "…" : ""}.`, tool.name);
|
|
95
|
+
for (const example of tool.examples) {
|
|
96
|
+
const result = await validate(tool.schema, example.args);
|
|
97
|
+
if (!result.ok)
|
|
98
|
+
add("error", "examples", `Example "${example.description}" does not match the schema: ${formatIssues(result.issues)}`, tool.name);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
renderToolHelp(tool, app.bins.cli);
|
|
102
|
+
flagsFor(schema);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
add("error", "cli", `The CLI cannot describe this tool: ${error.message}`, tool.name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!app.definition.package) {
|
|
109
|
+
add("warn", "install", "No package is set, so `install` points clients at this copy on disk instead of the published one. Set package to the npm name.");
|
|
110
|
+
}
|
|
111
|
+
const instructions = app.instructions ?? "";
|
|
112
|
+
if (!instructions)
|
|
113
|
+
add("warn", "instructions", "No server instructions. Clients use them to decide when to reach for these tools.");
|
|
114
|
+
else if (instructions.length > 512) {
|
|
115
|
+
const opening = instructions.slice(0, 512).toLowerCase();
|
|
116
|
+
if (!opening.includes(app.name.toLowerCase()) && !opening.includes(app.title.toLowerCase())) {
|
|
117
|
+
add("warn", "instructions", `Some clients read only the first 512 characters, and those never say what ${app.title} is.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
agentContext(app, env, app.bins.cli);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
add("error", "cli", `agent-context failed: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
await checkParity(app, env, tools, add);
|
|
127
|
+
for (const file of options.docs ?? [])
|
|
128
|
+
checkDocs(app, file, add);
|
|
129
|
+
let startupMs;
|
|
130
|
+
if (options.bin)
|
|
131
|
+
startupMs = await checkStartup(options.bin, add);
|
|
132
|
+
const errors = findings.filter((finding) => finding.level === "error").length;
|
|
133
|
+
return {
|
|
134
|
+
ok: errors === 0,
|
|
135
|
+
errors,
|
|
136
|
+
warnings: findings.length - errors,
|
|
137
|
+
findings,
|
|
138
|
+
stats: {
|
|
139
|
+
tools: tools.length,
|
|
140
|
+
allTools: app.allTools.length,
|
|
141
|
+
reads: tools.filter((tool) => tool.risk === "read").length,
|
|
142
|
+
writes: tools.filter((tool) => tool.risk === "write").length,
|
|
143
|
+
irreversible: tools.filter((tool) => tool.risk === "destructive").length,
|
|
144
|
+
confirmed: tools.filter((tool) => tool.requireConfirm).length,
|
|
145
|
+
typedOutput: tools.filter((tool) => tool.output).length,
|
|
146
|
+
schemaBytes: totalBytes,
|
|
147
|
+
...(largest ? { largestTool: largest } : {}),
|
|
148
|
+
...(startupMs !== undefined ? { startupMs } : {}),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* What an MCP client receives must be exactly what the CLI offers, on both
|
|
154
|
+
* protocol revisions a client may open with.
|
|
155
|
+
*/
|
|
156
|
+
async function checkParity(app, env, tools, add) {
|
|
157
|
+
const policy = app.policy(env);
|
|
158
|
+
if (policy.surface === "search")
|
|
159
|
+
return;
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
// The same problem on both revisions is reported once.
|
|
162
|
+
const once = (level, check, message, tool) => {
|
|
163
|
+
const key = `${check}\0${message}\0${tool ?? ""}`;
|
|
164
|
+
if (seen.has(key))
|
|
165
|
+
return;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
add(level, check, message, tool);
|
|
168
|
+
};
|
|
169
|
+
for (const era of ["legacy", "modern"])
|
|
170
|
+
await checkParityIn(era, app, env, tools, once);
|
|
171
|
+
}
|
|
172
|
+
async function checkParityIn(era, app, env, tools, add) {
|
|
173
|
+
const policy = app.policy(env);
|
|
174
|
+
let client;
|
|
175
|
+
try {
|
|
176
|
+
client = await connectInMemory(app, env, { era });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
add("error", "mcp", `The MCP server did not start (${era === "modern" ? "2026-07-28" : "2025"} protocol): ${error.message}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
if ((client.initialize.instructions ?? "") !== (app.instructions ?? ""))
|
|
184
|
+
add("error", "mcp", "The instructions a client receives differ from the app's.");
|
|
185
|
+
const listed = await client.listTools();
|
|
186
|
+
const byName = new Map(listed.map((tool) => [tool.name, tool]));
|
|
187
|
+
for (const tool of tools) {
|
|
188
|
+
const wire = byName.get(tool.name);
|
|
189
|
+
if (!wire) {
|
|
190
|
+
add("error", "mcp", "The CLI offers this tool but the MCP server does not list it.", tool.name);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (stable(strip(wire.inputSchema)) !== stable(strip(tool.jsonSchema))) {
|
|
194
|
+
add("error", "mcp", "The schema a client receives differs from the one the CLI derives its flags from.", tool.name);
|
|
195
|
+
}
|
|
196
|
+
const annotations = wire.annotations ?? {};
|
|
197
|
+
if (annotations.readOnlyHint !== (tool.risk === "read") || annotations.destructiveHint !== (tool.risk === "destructive")) {
|
|
198
|
+
add("error", "mcp", "The annotations a client receives do not match the tool's risk.", tool.name);
|
|
199
|
+
}
|
|
200
|
+
if (tool.requireConfirm && policy.confirm === "human" && wire._meta?.[REQUIRES_USER_INTERACTION] !== true) {
|
|
201
|
+
add("error", "mcp", "A confirmed tool is missing its request for a person's approval.", tool.name);
|
|
202
|
+
}
|
|
203
|
+
if (tool.output && !wire.outputSchema)
|
|
204
|
+
add("error", "mcp", "The tool declares an output schema that clients never receive.", tool.name);
|
|
205
|
+
}
|
|
206
|
+
for (const wire of listed) {
|
|
207
|
+
if (!tools.some((tool) => tool.name === wire.name))
|
|
208
|
+
add("error", "mcp", "The MCP server lists a tool the CLI does not offer.", wire.name);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
add("error", "mcp", `Listing tools failed: ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await client.close().catch(() => undefined);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Every command and flag a README or SKILL.md tells someone to type must exist. */
|
|
219
|
+
function checkDocs(app, file, add) {
|
|
220
|
+
if (!existsSync(file)) {
|
|
221
|
+
add("error", "docs", `${file} does not exist.`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const text = readFileSync(file, "utf8");
|
|
225
|
+
const bins = [app.bins.cli, app.bins.mcp].map((bin) => bin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
226
|
+
const pattern = new RegExp(`(?:^|[\\s\`'"(])(?:${bins})((?:[ \\t]+[^\\s\`'"|;&)]+)*)`, "gm");
|
|
227
|
+
const globals = new Set(GLOBAL_FLAGS.flatMap(([flag]) => flag.split(" / ").map((part) => part.split(" ")[0])).concat("--help", "-h", "--version", "-v", "--plain", "--confirm", "--wait", "--refresh", "--in", "--limit", "--cache", "--scope", "--name", "--copy-env", "--local", "--all", "--max-items", "--network", "--output", "--brief", "--http", "--port", "--no-color", "--no-input", "--yes", "--agent"));
|
|
228
|
+
// Only code is something a reader copies and runs. A sentence that mentions
|
|
229
|
+
// the binary ("if it fails, STOP") is prose and is not checked.
|
|
230
|
+
for (const segment of codeSegments(text))
|
|
231
|
+
for (const match of segment.code.matchAll(pattern)) {
|
|
232
|
+
// Everything after a shell comment is prose, not arguments.
|
|
233
|
+
const raw = (match[1] ?? "").trim().split(/\s+/).filter(Boolean).map((token) => token.replace(/[.,;:]+$/, ""));
|
|
234
|
+
const comment = raw.findIndex((token) => token.startsWith("#"));
|
|
235
|
+
const tokens = comment === -1 ? raw : raw.slice(0, comment);
|
|
236
|
+
const command = tokens.find((token) => !token.startsWith("-"));
|
|
237
|
+
if (!command || /^[<$[{…]/.test(command))
|
|
238
|
+
continue;
|
|
239
|
+
// The match may begin on the character before the binary, a newline included.
|
|
240
|
+
const at = segment.offset + (match.index ?? 0) + (match[0].length - match[0].trimStart().length);
|
|
241
|
+
const line = text.slice(0, at).split("\n").length;
|
|
242
|
+
if (BUILTINS.includes(command))
|
|
243
|
+
continue;
|
|
244
|
+
const tool = app.find(command);
|
|
245
|
+
if (!tool) {
|
|
246
|
+
add("error", "docs", `${file}:${line} names '${command}', which is not a command.`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const known = new Set(flagsFor(tool.jsonSchema).flatMap((flag) => [flag.flag, `--${flag.key}`, ...(flag.kind === "boolean" ? [`--no-${flag.flag.slice(2)}`] : [])]));
|
|
250
|
+
for (const token of tokens) {
|
|
251
|
+
if (!token.startsWith("--"))
|
|
252
|
+
continue;
|
|
253
|
+
const flag = token.split("=")[0];
|
|
254
|
+
if (!known.has(flag) && !globals.has(flag))
|
|
255
|
+
add("error", "docs", `${file}:${line} passes ${flag} to '${command}', which does not take it.`, tool.name);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Fenced code blocks and inline code spans, with where each starts in the file. */
|
|
260
|
+
function codeSegments(text) {
|
|
261
|
+
const segments = [];
|
|
262
|
+
const fenced = [];
|
|
263
|
+
for (const match of text.matchAll(/^(```|~~~)[^\n]*\n([\s\S]*?)^\1[ \t]*$/gm)) {
|
|
264
|
+
const start = (match.index ?? 0) + match[0].indexOf("\n") + 1;
|
|
265
|
+
segments.push({ code: match[2] ?? "", offset: start });
|
|
266
|
+
fenced.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
|
|
267
|
+
}
|
|
268
|
+
for (const match of text.matchAll(/`([^`\n]+)`/g)) {
|
|
269
|
+
const at = match.index ?? 0;
|
|
270
|
+
if (fenced.some(([start, end]) => at >= start && at < end))
|
|
271
|
+
continue;
|
|
272
|
+
segments.push({ code: match[1] ?? "", offset: at + 1 });
|
|
273
|
+
}
|
|
274
|
+
return segments;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Start the built server the way a client does, with nothing configured, and
|
|
278
|
+
* expect an answer. A server that exits or hangs here is broken for every new
|
|
279
|
+
* user, however good its tests are.
|
|
280
|
+
*/
|
|
281
|
+
function checkStartup(bin, add) {
|
|
282
|
+
return new Promise((resolve) => {
|
|
283
|
+
const home = mkdtempSync(join(tmpdir(), "slipway-check-"));
|
|
284
|
+
const started = performance.now();
|
|
285
|
+
const child = spawn(process.execPath, [bin], { cwd: home, env: { PATH: process.env.PATH ?? "", HOME: home, TMPDIR: home }, stdio: ["pipe", "pipe", "pipe"] });
|
|
286
|
+
let output = "";
|
|
287
|
+
let done = false;
|
|
288
|
+
const finish = (ms) => {
|
|
289
|
+
if (done)
|
|
290
|
+
return;
|
|
291
|
+
done = true;
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
child.kill("SIGKILL");
|
|
294
|
+
resolve(ms);
|
|
295
|
+
};
|
|
296
|
+
const timer = setTimeout(() => {
|
|
297
|
+
add("error", "startup", "The server did not answer initialize within 10 seconds with nothing configured.");
|
|
298
|
+
finish();
|
|
299
|
+
}, 10_000);
|
|
300
|
+
child.on("exit", (code) => {
|
|
301
|
+
if (!done) {
|
|
302
|
+
add("error", "startup", `The server exited with code ${code} before answering. It must start and explain what is missing instead.`);
|
|
303
|
+
finish();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
child.stdout.on("data", (chunk) => {
|
|
307
|
+
output += chunk.toString();
|
|
308
|
+
if (output.includes('"protocolVersion"'))
|
|
309
|
+
finish(Math.round(performance.now() - started));
|
|
310
|
+
});
|
|
311
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "slipway-check", version: "0" } } })}\n`);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab completion, generated from the same tool list as everything else.
|
|
3
|
+
*/
|
|
4
|
+
import { UsageError } from "../errors.js";
|
|
5
|
+
import { flagsFor } from "./flags.js";
|
|
6
|
+
import { BUILTINS, GLOBAL_FLAGS } from "./help.js";
|
|
7
|
+
function globalFlags() {
|
|
8
|
+
return GLOBAL_FLAGS.flatMap(([flag]) => flag.split(" / ").map((part) => part.split(" ")[0])).concat("--help");
|
|
9
|
+
}
|
|
10
|
+
function toolFlags(tool) {
|
|
11
|
+
return flagsFor(tool.jsonSchema).map((flag) => flag.flag);
|
|
12
|
+
}
|
|
13
|
+
function bash(bin, tools) {
|
|
14
|
+
const fn = `_${bin.replace(/[^A-Za-z0-9]/g, "_")}`;
|
|
15
|
+
const commands = [...tools.map((tool) => tool.command), ...BUILTINS].join(" ");
|
|
16
|
+
const globals = globalFlags().join(" ");
|
|
17
|
+
const cases = tools
|
|
18
|
+
.map((tool) => ` ${tool.command}) COMPREPLY=( $(compgen -W "${[...toolFlags(tool), ...(tool.paginate ? ["--all", "--max-items"] : [])].join(" ")} ${globals}" -- "$cur") ) ;;`)
|
|
19
|
+
.join("\n");
|
|
20
|
+
return `# ${bin} completion for bash
|
|
21
|
+
${fn}() {
|
|
22
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
23
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
24
|
+
COMPREPLY=( $(compgen -W "${commands}" -- "$cur") )
|
|
25
|
+
return
|
|
26
|
+
fi
|
|
27
|
+
case "\${COMP_WORDS[1]}" in
|
|
28
|
+
${cases}
|
|
29
|
+
schema|help) COMPREPLY=( $(compgen -W "${tools.map((tool) => tool.command).join(" ")}" -- "$cur") ) ;;
|
|
30
|
+
completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
|
|
31
|
+
doctor) COMPREPLY=( $(compgen -W "--network --json" -- "$cur") ) ;;
|
|
32
|
+
*) COMPREPLY=( $(compgen -W "${globals}" -- "$cur") ) ;;
|
|
33
|
+
esac
|
|
34
|
+
}
|
|
35
|
+
complete -F ${fn} ${bin}
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
function zsh(bin, tools) {
|
|
39
|
+
return `#compdef ${bin}
|
|
40
|
+
# ${bin} completion for zsh, through zsh's bash compatibility layer.
|
|
41
|
+
autoload -U +X bashcompinit && bashcompinit
|
|
42
|
+
${bash(bin, tools)}`;
|
|
43
|
+
}
|
|
44
|
+
function fishEscape(text) {
|
|
45
|
+
return text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
46
|
+
}
|
|
47
|
+
function fish(bin, tools) {
|
|
48
|
+
const lines = [`# ${bin} completion for fish`, `complete -c ${bin} -f`];
|
|
49
|
+
for (const tool of tools) {
|
|
50
|
+
lines.push(`complete -c ${bin} -n '__fish_use_subcommand' -a '${tool.command}' -d '${fishEscape(tool.title)}'`);
|
|
51
|
+
for (const flag of flagsFor(tool.jsonSchema)) {
|
|
52
|
+
lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${tool.command}' -l '${flag.flag.slice(2)}' -d '${fishEscape(flag.help.slice(0, 80))}'`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const builtin of BUILTINS)
|
|
56
|
+
lines.push(`complete -c ${bin} -n '__fish_use_subcommand' -a '${builtin}'`);
|
|
57
|
+
for (const flag of globalFlags())
|
|
58
|
+
lines.push(`complete -c ${bin} -l '${flag.slice(2)}'`);
|
|
59
|
+
return `${lines.join("\n")}\n`;
|
|
60
|
+
}
|
|
61
|
+
export function completionScript(app, shell, bin, env) {
|
|
62
|
+
const tools = app.tools(env);
|
|
63
|
+
if (shell === "bash")
|
|
64
|
+
return bash(bin, tools);
|
|
65
|
+
if (shell === "zsh")
|
|
66
|
+
return zsh(bin, tools);
|
|
67
|
+
if (shell === "fish")
|
|
68
|
+
return fish(bin, tools);
|
|
69
|
+
throw new UsageError(`completion expects bash, zsh or fish${shell ? `, got '${shell}'` : ""}.`, {
|
|
70
|
+
hint: `Add \`source <(${bin} completion bash)\` to your shell's startup file.`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI described to an agent in one read.
|
|
3
|
+
*
|
|
4
|
+
* An agent that has to run `--help` on every command before using one spends a
|
|
5
|
+
* round trip per command. `agent-context` hands it every command, flag, risk,
|
|
6
|
+
* example, exit code and setting at once, as JSON it can act on.
|
|
7
|
+
*/
|
|
8
|
+
import type { App } from "../app.js";
|
|
9
|
+
export declare const SLIPWAY_VERSION: string;
|
|
10
|
+
export declare const EXIT_MEANINGS: Record<number, string>;
|
|
11
|
+
export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: string, options?: {
|
|
12
|
+
brief?: boolean;
|
|
13
|
+
}): {
|
|
14
|
+
name: string;
|
|
15
|
+
title: string;
|
|
16
|
+
version: string;
|
|
17
|
+
framework: {
|
|
18
|
+
name: string;
|
|
19
|
+
version: string;
|
|
20
|
+
};
|
|
21
|
+
description?: string | undefined;
|
|
22
|
+
binaries: {
|
|
23
|
+
mcp: string;
|
|
24
|
+
cli: string;
|
|
25
|
+
};
|
|
26
|
+
usage: {
|
|
27
|
+
run: string;
|
|
28
|
+
help: string;
|
|
29
|
+
agent_mode: string;
|
|
30
|
+
confirm_flag: string;
|
|
31
|
+
note: string;
|
|
32
|
+
};
|
|
33
|
+
exit_codes: Record<number, string>;
|
|
34
|
+
global_flags: {
|
|
35
|
+
flag: string;
|
|
36
|
+
description: string;
|
|
37
|
+
}[];
|
|
38
|
+
settings: ({
|
|
39
|
+
env: string;
|
|
40
|
+
set: boolean;
|
|
41
|
+
secret?: boolean | undefined;
|
|
42
|
+
description: string;
|
|
43
|
+
} | {
|
|
44
|
+
env: string;
|
|
45
|
+
value: boolean;
|
|
46
|
+
description: string;
|
|
47
|
+
} | {
|
|
48
|
+
env: string;
|
|
49
|
+
value: string | string[];
|
|
50
|
+
description: string;
|
|
51
|
+
} | {
|
|
52
|
+
env: string;
|
|
53
|
+
value: string | null;
|
|
54
|
+
description: string;
|
|
55
|
+
} | {
|
|
56
|
+
env: string;
|
|
57
|
+
value: number | null;
|
|
58
|
+
description: string;
|
|
59
|
+
})[];
|
|
60
|
+
toolsets?: Record<string, string> | undefined;
|
|
61
|
+
hidden_commands: number;
|
|
62
|
+
commands: ({
|
|
63
|
+
command: string;
|
|
64
|
+
title: string;
|
|
65
|
+
risk: import("../tool.js").Risk;
|
|
66
|
+
requires_confirm: boolean;
|
|
67
|
+
} | {
|
|
68
|
+
command: string;
|
|
69
|
+
tool: string;
|
|
70
|
+
title: string;
|
|
71
|
+
description: string;
|
|
72
|
+
risk: import("../tool.js").Risk;
|
|
73
|
+
requires_confirm: boolean;
|
|
74
|
+
toolsets?: readonly string[] | undefined;
|
|
75
|
+
positional?: readonly string[] | undefined;
|
|
76
|
+
flags: {
|
|
77
|
+
flag: string;
|
|
78
|
+
type: import("./flags.js").FlagKind;
|
|
79
|
+
required: boolean;
|
|
80
|
+
repeatable?: boolean | undefined;
|
|
81
|
+
choices?: string[] | undefined;
|
|
82
|
+
default?: {} | null | undefined;
|
|
83
|
+
description?: string | undefined;
|
|
84
|
+
}[];
|
|
85
|
+
output_schema?: import("../schema.js").JsonSchema | undefined;
|
|
86
|
+
paginates?: boolean | undefined;
|
|
87
|
+
job?: {
|
|
88
|
+
status_command: string;
|
|
89
|
+
background: boolean;
|
|
90
|
+
} | undefined;
|
|
91
|
+
checks_jobs_of?: string | undefined;
|
|
92
|
+
examples?: {
|
|
93
|
+
description: string;
|
|
94
|
+
command: string;
|
|
95
|
+
}[] | undefined;
|
|
96
|
+
})[];
|
|
97
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI described to an agent in one read.
|
|
3
|
+
*
|
|
4
|
+
* An agent that has to run `--help` on every command before using one spends a
|
|
5
|
+
* round trip per command. `agent-context` hands it every command, flag, risk,
|
|
6
|
+
* example, exit code and setting at once, as JSON it can act on.
|
|
7
|
+
*/
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { EXIT } from "../errors.js";
|
|
10
|
+
import { policyEnvNames } from "../policy.js";
|
|
11
|
+
import { outputJsonSchema } from "../schema.js";
|
|
12
|
+
import { exampleCommand, GLOBAL_FLAGS } from "./help.js";
|
|
13
|
+
import { flagsFor } from "./flags.js";
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
export const SLIPWAY_VERSION = require("../../package.json").version;
|
|
16
|
+
export const EXIT_MEANINGS = {
|
|
17
|
+
[EXIT.ok]: "ok",
|
|
18
|
+
[EXIT.error]: "unexpected error",
|
|
19
|
+
[EXIT.usage]: "usage error, or a write the guard refused",
|
|
20
|
+
[EXIT.notFound]: "not found",
|
|
21
|
+
[EXIT.auth]: "authentication or permission",
|
|
22
|
+
[EXIT.api]: "upstream API error or timeout",
|
|
23
|
+
[EXIT.rateLimited]: "rate limited",
|
|
24
|
+
[EXIT.notConfigured]: "nothing configured",
|
|
25
|
+
};
|
|
26
|
+
export function agentContext(app, env, bin, options = {}) {
|
|
27
|
+
const policy = app.policy(env);
|
|
28
|
+
const names = policyEnvNames(app.envPrefix);
|
|
29
|
+
const tools = app.tools(env);
|
|
30
|
+
return {
|
|
31
|
+
name: app.name,
|
|
32
|
+
title: app.title,
|
|
33
|
+
version: app.version,
|
|
34
|
+
framework: { name: "slipway", version: SLIPWAY_VERSION },
|
|
35
|
+
...(app.description ? { description: app.description } : {}),
|
|
36
|
+
binaries: app.bins,
|
|
37
|
+
usage: {
|
|
38
|
+
run: `${bin} <command> [flags]`,
|
|
39
|
+
help: `${bin} <command> --help`,
|
|
40
|
+
agent_mode: "--agent",
|
|
41
|
+
confirm_flag: "--confirm",
|
|
42
|
+
note: "--agent never confirms a write. A command that requires --confirm runs only when it is passed explicitly.",
|
|
43
|
+
},
|
|
44
|
+
exit_codes: EXIT_MEANINGS,
|
|
45
|
+
global_flags: GLOBAL_FLAGS.map(([flag, description]) => ({ flag, description })),
|
|
46
|
+
settings: [
|
|
47
|
+
...(app.definition.settings ?? []).map((setting) => ({
|
|
48
|
+
env: setting.env,
|
|
49
|
+
set: Boolean(env[setting.env]),
|
|
50
|
+
...(setting.secret ? { secret: true } : {}),
|
|
51
|
+
description: setting.description,
|
|
52
|
+
})),
|
|
53
|
+
{ env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" },
|
|
54
|
+
{ env: names.allowDestructive, value: policy.allowDestructive, description: "allow public or irreversible writes" },
|
|
55
|
+
{ env: names.toolsets, value: policy.toolsets === "all" ? "all" : [...policy.toolsets], description: "toolsets that are on" },
|
|
56
|
+
{ env: names.surface, value: policy.surface, description: "full tool list, or search for very large catalogs" },
|
|
57
|
+
{ env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" },
|
|
58
|
+
{ env: names.toolTimeoutMs, value: policy.toolTimeoutMs ?? null, description: "deadline for any tool" },
|
|
59
|
+
{ env: names.confirm, value: policy.confirm, description: "who confirms a confirmed call over MCP: human asks a person where the client can, model accepts confirm: true" },
|
|
60
|
+
],
|
|
61
|
+
...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}),
|
|
62
|
+
hidden_commands: app.allTools.length - tools.length,
|
|
63
|
+
commands: tools.map((tool) => options.brief
|
|
64
|
+
? { command: tool.command, title: tool.title, risk: tool.risk, requires_confirm: tool.requireConfirm }
|
|
65
|
+
: {
|
|
66
|
+
command: tool.command,
|
|
67
|
+
tool: tool.name,
|
|
68
|
+
title: tool.title,
|
|
69
|
+
description: tool.description,
|
|
70
|
+
risk: tool.risk,
|
|
71
|
+
requires_confirm: tool.requireConfirm,
|
|
72
|
+
...(tool.tags.length ? { toolsets: tool.tags } : {}),
|
|
73
|
+
...(tool.positional.length ? { positional: tool.positional } : {}),
|
|
74
|
+
flags: flagsFor(tool.jsonSchema)
|
|
75
|
+
.filter((flag) => flag.key !== "confirm")
|
|
76
|
+
.map((flag) => ({
|
|
77
|
+
flag: flag.flag,
|
|
78
|
+
type: flag.kind,
|
|
79
|
+
required: flag.required,
|
|
80
|
+
...(flag.repeatable ? { repeatable: true } : {}),
|
|
81
|
+
...(flag.choices ? { choices: flag.choices } : {}),
|
|
82
|
+
...(flag.default !== undefined ? { default: flag.default } : {}),
|
|
83
|
+
...(flag.help ? { description: flag.help } : {}),
|
|
84
|
+
})),
|
|
85
|
+
...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
|
|
86
|
+
...(tool.paginate ? { paginates: true } : {}),
|
|
87
|
+
...(tool.job && !tool.statusOf ? { job: { status_command: `${tool.command}-status`, background: "background" in tool.job } } : {}),
|
|
88
|
+
...(tool.statusOf ? { checks_jobs_of: tool.statusOf.replace(/_/g, "-") } : {}),
|
|
89
|
+
...(tool.examples.length
|
|
90
|
+
? { examples: tool.examples.map((example) => ({ description: example.description, command: exampleCommand(bin, tool, example.args) })) }
|
|
91
|
+
: {}),
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `data`: the local data file from a terminal.
|
|
3
|
+
*
|
|
4
|
+
* data what is kept, where, and for which account
|
|
5
|
+
* data sync <command> [flags] copy every page of a list to this machine
|
|
6
|
+
* data search <words> search synced records offline
|
|
7
|
+
* data sql "<select>" query it with read-only SQL
|
|
8
|
+
* data clear [<command>] delete this account's local data
|
|
9
|
+
*/
|
|
10
|
+
import type { App, CliIO } from "../app.js";
|
|
11
|
+
import { type Format } from "./output.js";
|
|
12
|
+
export type DataOptions = {
|
|
13
|
+
format: Format;
|
|
14
|
+
select?: string[];
|
|
15
|
+
agent: boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function runData(app: App, io: CliIO, tokens: string[], options: DataOptions): Promise<number>;
|