@thenavidm/slipway 0.1.6 → 0.1.8
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 +23 -0
- package/README.md +14 -5
- package/dist/app.d.ts +25 -0
- package/dist/app.js +9 -7
- package/dist/check.js +14 -0
- package/dist/cli/context.d.ts +2 -0
- package/dist/cli/context.js +7 -2
- package/dist/cli/data.js +1 -1
- package/dist/cli/flags.d.ts +1 -1
- package/dist/cli/flags.js +7 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +17 -12
- package/dist/cli/output.js +13 -0
- package/dist/cli/run.js +23 -9
- package/dist/confirm.d.ts +1 -1
- package/dist/docs.js +1 -1
- package/dist/doctor.js +2 -1
- package/dist/guard.d.ts +1 -1
- package/dist/guard.js +6 -2
- package/dist/install.js +3 -3
- package/dist/policy.d.ts +4 -1
- package/dist/policy.js +2 -2
- package/dist/search.d.ts +1 -1
- package/dist/search.js +27 -5
- package/dist/serve.d.ts +8 -0
- package/dist/serve.js +38 -5
- package/dist/server.d.ts +1 -1
- package/dist/server.js +6 -3
- package/dist/tool.d.ts +34 -0
- package/dist/tool.js +35 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in Slipway, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.8, 2026-10-05: what Midjourney and Substack needed
|
|
6
|
+
|
|
7
|
+
- **HTTP checks Origin.** A request whose `Origin` is another site is refused unless `<PREFIX>_HTTP_ALLOWED_ORIGINS` lists it, as the MCP transport spec asks: a page in a browser can send a request to a localhost server, and only that header says where it came from. Clients that are not browsers send none and are unaffected. Substack's own server did this before it moved.
|
|
8
|
+
- **`spends`: a call that costs money.** A paid generation needs confirming, `<PREFIX>_ALLOW_DESTRUCTIVE=0` refuses it, the CLI marks it `$`, and its refusal says it spends money that cannot be refunded, while clients still see a plain write, because making an image destroys nothing. Midjourney's ten generating tools are this.
|
|
9
|
+
- **`flagAliases`: the spellings people already use.** `{ ar: "aspect" }` lets `--ar 16:9` set `aspect` on every command that has it, and help shows the alias beside the flag. `slipway check` fails an alias no input takes.
|
|
10
|
+
- **`synonyms`, stopwords and exact names in search.** An app can map the words its users type onto the words its tools use, for `which` and the search surface; a query of filler words finds nothing instead of everything; and a term that is a tool's name, or a synonym pointing at it, wins outright. Midjourney's "make a picture" finds `imagine`, not `vary_image`.
|
|
11
|
+
- **`--select` reaches into a result list.** When no path starts at the top and a result holds one list of records, the fields are selected inside it and the rest is kept: `--select id` on `{ count, jobs: [...] }` keeps the count and each job's id.
|
|
12
|
+
- **An app command can own a flag Slipway also has.** `flags: ["--out"]` hands `--out` to the command instead of taking it as the global output flag. Midjourney's `capture --out` needs it.
|
|
13
|
+
- **`login --help` prints the steps** for an app whose login is printed steps, not the general help.
|
|
14
|
+
- **A command's own `--help` page reads as a sentence.** The line written for the command table, such as "capture a session for your publication once", starts with a capital and ends with a period on `login --help` and an app command's `--help`.
|
|
15
|
+
- **Every command's `--help` is 10 tokens shorter.** `--select` and `--agent` say what they do in fewer words, and a read says `Risk: read`. Midjourney's `list-jobs --help` had grown 10 tokens on the move and is now the size 1.3.1's was.
|
|
16
|
+
- **The unknown-command hint names one binary.** Typed at the MCP binary it said to list commands with `substack-cli` and find one with `substack-mcp which`; both now name the CLI binary.
|
|
17
|
+
- **`install` says where settings go, not that they must be set.** Most settings are optional, and the notes no longer read as an order to set every one.
|
|
18
|
+
|
|
19
|
+
## 0.1.7, 2026-10-05: what Threads and ThriveCart needed, and what Substack and WordPress will
|
|
20
|
+
|
|
21
|
+
- **`riskFor`: a write whose arguments decide its risk.** Publishing is destructive and saving a draft is a write, from the same tool. `risk` stays the highest a call can be, which is what clients see in annotations and listings; the guard, the approval and the audit log go by the call, and only a destructive call needs confirming. WordPress's post tools need it: 1.1 confirmed publishing and not drafting.
|
|
22
|
+
- **`onServe`: work that belongs to a running server.** It runs once the server is answering, over stdio or HTTP, and never for a CLI command, with the context and the server's logger; a throw is logged and the server keeps serving. Threads uses it to warn that a token expires this week, and Substack's queue of scheduled Notes needs it.
|
|
23
|
+
- **A tool's own consequence.** `consequence` replaces "is public or cannot be undone" in the refusal and the approval form. ThriveCart's refund said it "is public" on Slipway and now says it "moves money or ends a customer's access and cannot be undone", as its own release did.
|
|
24
|
+
- **`which` lists only the close matches.** Results scoring at least half of the best one, and always the top three. On Threads, "publish a post staged earlier" printed ten lines, 1,077 characters, where three carried the answer.
|
|
25
|
+
- **`<PREFIX>_TOOLSETS` is listed only when some tool has a toolset.** With none, every tool is always on and the switch does nothing, so help and `agent-context` stop offering it.
|
|
26
|
+
- **Tests from the servers that moved.** ThriveCart's cases for `--select` paths that share a head and for a list of choices typed as repeated words now run here, where that code lives.
|
|
27
|
+
|
|
5
28
|
## 0.1.6, 2026-10-05: what Mastodon's move needed
|
|
6
29
|
|
|
7
30
|
- **`login` runs the app's own sign-in, with the words after it.** `mastodon-cli login mastodon.social --oob` hands `mastodon.social --oob` to Mastodon's flow, which registers an app on that instance and signs in. A flow given with `usage` and `help` shows them in help, `login --help` and `agent-context`, so nobody has to guess that it takes an instance.
|
package/README.md
CHANGED
|
@@ -213,6 +213,9 @@ The context is built on the first call that needs it, never at startup, and so i
|
|
|
213
213
|
| `output` | Optional. Results are validated against it and sent as `structuredContent` |
|
|
214
214
|
| `risk` | `read`, `write` (easy to undo) or `destructive` (public, irreversible, or both) |
|
|
215
215
|
| `requireConfirm` | Defaults to true for destructive tools. Set it on a write that spends money |
|
|
216
|
+
| `riskFor` | `(args) => risk`, when the arguments decide it: publishing is destructive, saving a draft is a write. `risk` stays the highest, which clients see; the guard, confirmation and audit log go by the call |
|
|
217
|
+
| `spends` | The call spends money or credits, a paid generation: it needs confirming, `<PREFIX>_ALLOW_DESTRUCTIVE=0` refuses it, and the CLI marks it `$`, while clients still see a write |
|
|
218
|
+
| `consequence` | What a confirmed call does, in the tool's own words for the refusal and the approval form: "moves money and cannot be undone". Defaults to "is public or cannot be undone" |
|
|
216
219
|
| `idempotent`, `openWorld` | Annotation hints. Reads are idempotent by default; every tool is open world unless it never leaves the machine |
|
|
217
220
|
| `tags` | Toolsets this tool belongs to. A tool with no tags is always on |
|
|
218
221
|
| `summary` | One line for the refusal message and the audit log: "delete note 7" |
|
|
@@ -385,7 +388,7 @@ Override any operation's name or risk with `names` and `risk`, keep a subset wit
|
|
|
385
388
|
| `<cli>` | Every command, grouped by toolset, writes marked |
|
|
386
389
|
| `<cli> <command> [flags]` | Run one tool |
|
|
387
390
|
| `<cli> <command> --help` | Its flags, choices, defaults, examples and risk |
|
|
388
|
-
| `<cli> which <words>` | Find the command for a task, by what it does |
|
|
391
|
+
| `<cli> which <words>` | Find the command for a task, by what it does. An app's `synonyms` add the words its users type: `{ picture: ["image"] }` |
|
|
389
392
|
| `<cli> schema <command>` | The JSON Schema an MCP client receives. `--output` for the result's |
|
|
390
393
|
| `<cli> agent-context` | Commands, flags, risk, examples, exit codes and settings as JSON. `--brief` for just the commands, which ones write or need `--confirm`, and the exit codes |
|
|
391
394
|
| `<cli> doctor` | Check the setup. `--network` also calls the service, which an app with `doctorNetwork` does every time |
|
|
@@ -395,7 +398,7 @@ Override any operation's name or risk with `names` and `risk`, keep a subset wit
|
|
|
395
398
|
| `<cli> <command>` from `commands` | A terminal command the app adds, such as `logout`. Listed in help and `agent-context`, never sent to an MCP client |
|
|
396
399
|
| `<cli> completion bash` | Tab completion for bash, zsh or fish |
|
|
397
400
|
|
|
398
|
-
Flags come from the schema: `--flag value`, `--flag=value`, the underscore spelling, `--no-flag` for a boolean, repeated or comma-separated lists of numbers and choices, and JSON or `@file.json` for an object. `--input` takes every argument as one JSON object, from the flag, a file or stdin, and flags on the same line override it.
|
|
401
|
+
Flags come from the schema: `--flag value`, `--flag=value`, the underscore spelling, any name an app's `flagAliases` gives (`{ ar: "aspect" }` for `--ar 16:9`), `--no-flag` for a boolean, repeated or comma-separated lists of numbers and choices, and JSON or `@file.json` for an object. `--input` takes every argument as one JSON object, from the flag, a file or stdin, and flags on the same line override it.
|
|
399
402
|
|
|
400
403
|
| Output flag | Shape |
|
|
401
404
|
|---|---|
|
|
@@ -404,7 +407,7 @@ Flags come from the schema: `--flag value`, `--flag=value`, the underscore spell
|
|
|
404
407
|
| `--jsonl` | One JSON value per line, for lists |
|
|
405
408
|
| `--csv`, `--tsv` | A table, for lists of records |
|
|
406
409
|
| `--quiet` | One value per line: ids, or the one `--select` field |
|
|
407
|
-
| `--select a,b.c` | Keep only these fields. Dotted paths descend into arrays |
|
|
410
|
+
| `--select a,b.c` | Keep only these fields. Dotted paths descend into arrays, and a field not at the top selects inside the one list a result holds, keeping the rest |
|
|
408
411
|
| `--out <file>` | Write to a new file, readable only by you, never over an existing one |
|
|
409
412
|
| `--wait` | For a job: wait until it finishes |
|
|
410
413
|
| `--refresh` | Skip the local cache and fetch again |
|
|
@@ -429,7 +432,9 @@ Errors are JSON on stderr, always, with `error`, `code` and a `hint` that names
|
|
|
429
432
|
| `<mcp>` | MCP over stdio, what a client launches |
|
|
430
433
|
| `<mcp> --http [--port 8787]` | Streamable HTTP at `/mcp`, with `/health`. The app's `httpPort` replaces 8787, for a server that shipped another default |
|
|
431
434
|
|
|
432
|
-
HTTP binds `127.0.0.1` and checks the Host header, so a web page cannot reach it through a name that resolves to localhost. It refuses to listen on any other address without `<PREFIX>_HTTP_TOKEN`, because anyone who reached the port would act as your account.
|
|
435
|
+
HTTP binds `127.0.0.1` and checks the Host header, so a web page cannot reach it through a name that resolves to localhost, and it refuses a request whose Origin is another site unless `<PREFIX>_HTTP_ALLOWED_ORIGINS` lists it, as the MCP transport spec asks. It refuses to listen on any other address without `<PREFIX>_HTTP_TOKEN`, because anyone who reached the port would act as your account.
|
|
436
|
+
|
|
437
|
+
Work that belongs to a running server goes in `onServe(ctx, log)`, which runs once the server is answering over either transport and never for a CLI command: a queue that publishes on time, or a warning that a token expires this week. A throw there is logged and the server keeps serving.
|
|
433
438
|
|
|
434
439
|
Resources and prompts are optional and take a few lines each:
|
|
435
440
|
|
|
@@ -550,12 +555,13 @@ Every server reads these, under its own prefix: the app name in capitals, `NOTES
|
|
|
550
555
|
| `<PREFIX>_CONFIRM` | `human` | `model` lets `confirm: true` alone confirm, for an agent with no person to ask |
|
|
551
556
|
| `<PREFIX>_CACHE` | `1` | `0` never answers from the local cache |
|
|
552
557
|
| `<PREFIX>_DATA_DIR` | the system's data folder | Where the local data file lives |
|
|
553
|
-
| `<PREFIX>_TOOLSETS` | `all` | Comma-separated toolsets to turn on |
|
|
558
|
+
| `<PREFIX>_TOOLSETS` | `all` | Comma-separated toolsets to turn on. Listed only when some tool has a toolset |
|
|
554
559
|
| `<PREFIX>_SURFACE` | `full` | `search` lists three tools that find, describe and run the rest |
|
|
555
560
|
| `<PREFIX>_TOOL_TIMEOUT_MS` | none | Give up on any tool after this long |
|
|
556
561
|
| `<PREFIX>_HTTP_PORT` | `8787`, or the app's `httpPort` | For `--http` |
|
|
557
562
|
| `<PREFIX>_HTTP_HOST` | `127.0.0.1` | For `--http`. Any other address needs a token |
|
|
558
563
|
| `<PREFIX>_HTTP_TOKEN` | none | Bearer token required by `--http` |
|
|
564
|
+
| `<PREFIX>_HTTP_ALLOWED_ORIGINS` | none | Browser origins beyond localhost that may call `--http`, comma-separated |
|
|
559
565
|
| `<PREFIX>_DEBUG` | `0` | `1` prints debug lines on stderr |
|
|
560
566
|
|
|
561
567
|
## Versions
|
|
@@ -567,7 +573,10 @@ See [CHANGELOG.md](CHANGELOG.md).
|
|
|
567
573
|
| Server | Package | Covers |
|
|
568
574
|
| --- | --- | --- |
|
|
569
575
|
| [Bluesky](https://github.com/thenavidm/bluesky-mcp-cli) | [`@thenavidm/bluesky-mcp-cli`](https://www.npmjs.com/package/@thenavidm/bluesky-mcp-cli) 2.0.0 | Posting, threads, replies, the timeline, search, feeds, lists, notifications and the social graph |
|
|
576
|
+
| [Mastodon](https://github.com/thenavidm/mastodon-mcp-cli) | [`@thenavidm/mastodon-mcp-cli`](https://www.npmjs.com/package/@thenavidm/mastodon-mcp-cli) 2.0.0 | Posting, editing, threads, timelines, search, lists, notifications and following, on any instance |
|
|
570
577
|
| [Teachable](https://github.com/thenavidm/teachable-mcp-cli) | [`@thenavidm/teachable-mcp-cli`](https://www.npmjs.com/package/@thenavidm/teachable-mcp-cli) 3.0.0 | Courses, users, enrollments, pricing, coupons and transactions |
|
|
578
|
+
| [Threads](https://github.com/thenavidm/threads-mcp-cli) | [`@thenavidm/threads-mcp-cli`](https://www.npmjs.com/package/@thenavidm/threads-mcp-cli) 2.0.0 | Posting, threads, carousels, replies and reply approvals, insights and keyword search |
|
|
579
|
+
| [ThriveCart](https://github.com/thenavidm/thrivecart-mcp-cli) | [`@thenavidm/thrivecart-mcp-cli`](https://www.npmjs.com/package/@thenavidm/thrivecart-mcp-cli) 3.0.0 | Products and pricing, transactions and revenue, customers, subscriptions and affiliates, across several carts |
|
|
571
580
|
|
|
572
581
|
Each server was measured against its previous release before it moved: startup, what a client receives, CLI exit codes, and tokens in Claude Code and Codex. Its README has the numbers.
|
|
573
582
|
|
package/dist/app.d.ts
CHANGED
|
@@ -79,6 +79,11 @@ export type CliCommand = {
|
|
|
79
79
|
usage?: string;
|
|
80
80
|
/** One line: what it does. */
|
|
81
81
|
help: string;
|
|
82
|
+
/**
|
|
83
|
+
* Flags the command reads itself, such as `--out`, which would otherwise be
|
|
84
|
+
* taken as Slipway's global flag of the same name before the command sees it.
|
|
85
|
+
*/
|
|
86
|
+
flags?: readonly string[];
|
|
82
87
|
run: (io: CliIO, args: string[]) => number | Promise<number>;
|
|
83
88
|
};
|
|
84
89
|
export type AppDefinition<Ctx> = {
|
|
@@ -129,6 +134,13 @@ export type AppDefinition<Ctx> = {
|
|
|
129
134
|
* a scope.
|
|
130
135
|
*/
|
|
131
136
|
doctorNetwork?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Runs once the server is answering, over stdio or HTTP, and never for a CLI
|
|
139
|
+
* command. For work that belongs to a running server, such as a queue that
|
|
140
|
+
* publishes on time, or a warning such as a token about to expire. It gets
|
|
141
|
+
* the context and the server's stderr logger; a throw is logged, never fatal.
|
|
142
|
+
*/
|
|
143
|
+
onServe?: (ctx: Ctx, log: Logger) => void | Promise<void>;
|
|
132
144
|
/**
|
|
133
145
|
* How to sign in: printed instructions, or an interactive flow that returns an
|
|
134
146
|
* exit code. A flow gets the words after `login`: `mastodon-cli login mastodon.social`.
|
|
@@ -140,6 +152,19 @@ export type AppDefinition<Ctx> = {
|
|
|
140
152
|
help: string;
|
|
141
153
|
run: LoginFlow;
|
|
142
154
|
};
|
|
155
|
+
/**
|
|
156
|
+
* Other names a flag answers to, on every command that has the input:
|
|
157
|
+
* `{ ar: "aspect" }` lets `--ar 16:9` set `aspect`, as Midjourney's own
|
|
158
|
+
* syntax spells it. Help shows each beside its flag, and `slipway check`
|
|
159
|
+
* fails one that no tool's input can take.
|
|
160
|
+
*/
|
|
161
|
+
flagAliases?: Readonly<Record<string, string>>;
|
|
162
|
+
/**
|
|
163
|
+
* Words people type, mapped onto words the tools use, for `which` and the
|
|
164
|
+
* search surface: `{ picture: ["image"] }`. Without them a lookup works only
|
|
165
|
+
* for someone who already knows the vocabulary.
|
|
166
|
+
*/
|
|
167
|
+
synonyms?: Readonly<Record<string, readonly string[]>>;
|
|
143
168
|
/**
|
|
144
169
|
* Terminal commands beyond the tools, such as `logout`, `auth` or `refresh`.
|
|
145
170
|
* An MCP client never sees them. Each is listed in help and `agent-context`,
|
package/dist/app.js
CHANGED
|
@@ -18,7 +18,7 @@ import { isContentResult } from "./result.js";
|
|
|
18
18
|
import { formatIssues, validate } from "./schema.js";
|
|
19
19
|
import { buildServer } from "./server.js";
|
|
20
20
|
import { localDataTools } from "./sync.js";
|
|
21
|
-
import { defineTool, isTool, summarize } from "./tool.js";
|
|
21
|
+
import { defineTool, forCall, isTool, summarize } from "./tool.js";
|
|
22
22
|
const SLUG = /^[a-z][a-z0-9-]{0,40}$/;
|
|
23
23
|
export function stderrLogger(prefix, env) {
|
|
24
24
|
const debug = /^(1|true|yes)$/i.test(env[`${prefix}_DEBUG`] ?? "");
|
|
@@ -140,7 +140,7 @@ export function slipway(definition) {
|
|
|
140
140
|
assertVisible(tool, policy, envPrefix);
|
|
141
141
|
const { confirm: _confirm, ...args } = rawArgs;
|
|
142
142
|
const summary = summarize(tool, args);
|
|
143
|
-
new Guard(policy, options.surface, envPrefix).preflight(tool, summary);
|
|
143
|
+
new Guard(policy, options.surface, envPrefix).preflight(forCall(tool, args), summary);
|
|
144
144
|
return summary;
|
|
145
145
|
},
|
|
146
146
|
async run(tool, rawArgs, options) {
|
|
@@ -152,8 +152,10 @@ export function slipway(definition) {
|
|
|
152
152
|
const confirmedBy = options.approvedBy ?? (options.confirmed === true || confirm === true ? "flag" : undefined);
|
|
153
153
|
const dryRun = options.dryRun === true;
|
|
154
154
|
const summary = summarize(tool, args);
|
|
155
|
+
// What this call risks, which `riskFor` may put below the tool's declared risk.
|
|
156
|
+
const call = forCall(tool, args);
|
|
155
157
|
const guard = new Guard(policy, options.surface, envPrefix);
|
|
156
|
-
guard.check(
|
|
158
|
+
guard.check(call, { confirmedBy, dryRun, summary });
|
|
157
159
|
const ctx = await app.context(env);
|
|
158
160
|
const timeoutMs = tool.timeoutMs ?? policy.toolTimeoutMs;
|
|
159
161
|
const withDeadline = () => deadlineSignal(options.signal, timeoutMs);
|
|
@@ -168,7 +170,7 @@ export function slipway(definition) {
|
|
|
168
170
|
surface: options.surface,
|
|
169
171
|
env,
|
|
170
172
|
dryRun,
|
|
171
|
-
tool: { name: tool.name, risk:
|
|
173
|
+
tool: { name: tool.name, risk: call.risk },
|
|
172
174
|
secrets,
|
|
173
175
|
log,
|
|
174
176
|
async progress(progress, total, message) {
|
|
@@ -182,7 +184,7 @@ export function slipway(definition) {
|
|
|
182
184
|
const wouldRun = tool.preview ? await tool.preview(args, toolContext) : args;
|
|
183
185
|
return { dry_run: true, tool: tool.name, summary, would_run: secrets.redactDeep(wouldRun) };
|
|
184
186
|
}
|
|
185
|
-
const writes =
|
|
187
|
+
const writes = call.risk !== "read" || call.requireConfirm;
|
|
186
188
|
const cache = tool.cache && policy.cache ? { scope: app.dataScope(ctx), key: cacheKey(args), ttlSeconds: tool.cache.ttlSeconds } : undefined;
|
|
187
189
|
if (cache && !options.refresh) {
|
|
188
190
|
const hit = await quietly(log, async () => (await app.localData(env)).cacheGet(cache.scope, tool.name, cache.key));
|
|
@@ -227,7 +229,7 @@ export function slipway(definition) {
|
|
|
227
229
|
result = await untilAborted(Promise.resolve(tool.handler(args, toolContext)), signal, timeoutMs, tool.name);
|
|
228
230
|
}
|
|
229
231
|
if (writes)
|
|
230
|
-
guard.record(
|
|
232
|
+
guard.record(call, summary, result?.done === false ? "started" : "done");
|
|
231
233
|
await forget();
|
|
232
234
|
// Only data is cached. Images and files are fetched again.
|
|
233
235
|
if (cache && !isContentResult(result)) {
|
|
@@ -238,7 +240,7 @@ export function slipway(definition) {
|
|
|
238
240
|
}
|
|
239
241
|
catch (error) {
|
|
240
242
|
if (writes)
|
|
241
|
-
guard.record(
|
|
243
|
+
guard.record(call, summary, "failed");
|
|
242
244
|
await forget();
|
|
243
245
|
throw toSlipwayError(error);
|
|
244
246
|
}
|
package/dist/check.js
CHANGED
|
@@ -55,6 +55,20 @@ export async function checkApp(app, options = {}) {
|
|
|
55
55
|
add("error", "names", `The terminal command '${command.name}' has the name of a built-in or a tool. Rename it.`);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
for (const [alias, key] of Object.entries(app.definition.flagAliases ?? {})) {
|
|
59
|
+
const takes = app.allTools.some((tool) => Object.keys(tool.jsonSchema.properties ?? {}).includes(key));
|
|
60
|
+
if (!takes)
|
|
61
|
+
add("error", "names", `--${alias} points at '${key}', which no tool's input has.`);
|
|
62
|
+
}
|
|
63
|
+
// A synonym pointing at a word no tool uses is a redirect to nowhere that quietly stops matching.
|
|
64
|
+
const vocabulary = new Set(app.allTools.flatMap((tool) => `${tool.name} ${tool.title} ${tool.description}`.toLowerCase().split(/[^a-z0-9]+/)));
|
|
65
|
+
for (const [word, targets] of Object.entries(app.definition.synonyms ?? {})) {
|
|
66
|
+
for (const target of targets) {
|
|
67
|
+
if (!vocabulary.has(target.toLowerCase()) && !app.allTools.some((tool) => tool.name === target)) {
|
|
68
|
+
add("warn", "synonyms", `'${word}' points at '${target}', which no tool's name, title or description uses.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
58
72
|
const httpPort = app.definition.httpPort;
|
|
59
73
|
if (httpPort !== undefined && (!Number.isInteger(httpPort) || httpPort < 1 || httpPort > 65535)) {
|
|
60
74
|
add("error", "http", `httpPort is ${httpPort}, which is not a port number from 1 to 65535.`);
|
package/dist/cli/context.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
|
|
|
34
34
|
title: string;
|
|
35
35
|
risk?: "destructive" | "write" | undefined;
|
|
36
36
|
requires_confirm?: boolean | undefined;
|
|
37
|
+
spends?: boolean | undefined;
|
|
37
38
|
}[];
|
|
38
39
|
} | {
|
|
39
40
|
name: string;
|
|
@@ -96,6 +97,7 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
|
|
|
96
97
|
description: string;
|
|
97
98
|
risk: import("../tool.js").Risk;
|
|
98
99
|
requires_confirm: boolean;
|
|
100
|
+
spends?: boolean | undefined;
|
|
99
101
|
toolsets?: readonly string[] | undefined;
|
|
100
102
|
positional?: readonly string[] | undefined;
|
|
101
103
|
flags: {
|
package/dist/cli/context.js
CHANGED
|
@@ -51,6 +51,7 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
51
51
|
title: tool.title,
|
|
52
52
|
...(tool.risk !== "read" ? { risk: tool.risk } : {}),
|
|
53
53
|
...(tool.requireConfirm ? { requires_confirm: true } : {}),
|
|
54
|
+
...(tool.spends ? { spends: true } : {}),
|
|
54
55
|
})),
|
|
55
56
|
};
|
|
56
57
|
}
|
|
@@ -78,8 +79,10 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
78
79
|
description: setting.description,
|
|
79
80
|
})),
|
|
80
81
|
{ env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" },
|
|
81
|
-
{ env: names.allowDestructive, value: policy.allowDestructive, description: "allow public or irreversible writes" },
|
|
82
|
-
|
|
82
|
+
{ env: names.allowDestructive, value: policy.allowDestructive, description: app.allTools.some((tool) => tool.spends) ? "allow public or irreversible writes and paid calls" : "allow public or irreversible writes" },
|
|
83
|
+
...(app.allTools.some((tool) => tool.tags.length > 0)
|
|
84
|
+
? [{ env: names.toolsets, value: policy.toolsets === "all" ? "all" : [...policy.toolsets], description: "toolsets that are on" }]
|
|
85
|
+
: []),
|
|
83
86
|
{ env: names.surface, value: policy.surface, description: "full tool list, or search for very large catalogs" },
|
|
84
87
|
{ env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" },
|
|
85
88
|
{ env: names.toolTimeoutMs, value: policy.toolTimeoutMs ?? null, description: "deadline for any tool" },
|
|
@@ -87,6 +90,7 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
87
90
|
{ env: `${app.envPrefix}_HTTP_PORT`, value: env[`${app.envPrefix}_HTTP_PORT`] ?? null, description: `port for --http, ${app.definition.httpPort ?? 8787} when unset` },
|
|
88
91
|
{ env: `${app.envPrefix}_HTTP_HOST`, value: env[`${app.envPrefix}_HTTP_HOST`] ?? null, description: "address for --http, 127.0.0.1 when unset; any other needs a token" },
|
|
89
92
|
{ env: `${app.envPrefix}_HTTP_TOKEN`, set: Boolean(env[`${app.envPrefix}_HTTP_TOKEN`]), secret: true, description: "bearer token --http requires" },
|
|
93
|
+
{ env: `${app.envPrefix}_HTTP_ALLOWED_ORIGINS`, value: env[`${app.envPrefix}_HTTP_ALLOWED_ORIGINS`] ?? null, description: "browser origins beyond localhost that may call --http, comma-separated" },
|
|
90
94
|
{ env: `${app.envPrefix}_DEBUG`, value: /^(1|true|yes)$/i.test(env[`${app.envPrefix}_DEBUG`] ?? ""), description: "print debug lines on stderr" },
|
|
91
95
|
],
|
|
92
96
|
...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}),
|
|
@@ -99,6 +103,7 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
99
103
|
description: tool.description,
|
|
100
104
|
risk: tool.risk,
|
|
101
105
|
requires_confirm: tool.requireConfirm,
|
|
106
|
+
...(tool.spends ? { spends: true } : {}),
|
|
102
107
|
...(tool.tags.length ? { toolsets: tool.tags } : {}),
|
|
103
108
|
...(tool.positional.length ? { positional: tool.positional } : {}),
|
|
104
109
|
flags: flagsFor(tool.jsonSchema)
|
package/dist/cli/data.js
CHANGED
|
@@ -64,7 +64,7 @@ export async function runData(app, io, tokens, options) {
|
|
|
64
64
|
const lists = app.allTools.filter((candidate) => candidate.sync).map((candidate) => candidate.command);
|
|
65
65
|
throw new UsageError(`${tool.command} is not a list that can be synced.`, { hint: `Lists that can: ${lists.join(", ") || "none"}.` });
|
|
66
66
|
}
|
|
67
|
-
const args = await app.parse(tool, parseToolArgs(flags, flagsFor(tool.jsonSchema), tool.positional));
|
|
67
|
+
const args = await app.parse(tool, parseToolArgs(flags, flagsFor(tool.jsonSchema), tool.positional, app.definition.flagAliases));
|
|
68
68
|
const report = await syncTool(app, tool, args, {
|
|
69
69
|
surface: "cli",
|
|
70
70
|
env: io.env,
|
package/dist/cli/flags.d.ts
CHANGED
|
@@ -31,5 +31,5 @@ export declare function parseJsonValue(raw: string, label: string): unknown;
|
|
|
31
31
|
* positional arguments. The schema validates the result afterwards; this only
|
|
32
32
|
* gets values into the right types and reports mistakes in terms of flags.
|
|
33
33
|
*/
|
|
34
|
-
export declare function parseToolArgs(argv: readonly string[], flags: readonly Flag[], positional: readonly string[]): Record<string, unknown>;
|
|
34
|
+
export declare function parseToolArgs(argv: readonly string[], flags: readonly Flag[], positional: readonly string[], aliases?: Readonly<Record<string, string>>): Record<string, unknown>;
|
|
35
35
|
export declare function missingRequired(flags: readonly Flag[], args: Record<string, unknown>): Flag[];
|
package/dist/cli/flags.js
CHANGED
|
@@ -118,7 +118,7 @@ function splits(flag) {
|
|
|
118
118
|
* positional arguments. The schema validates the result afterwards; this only
|
|
119
119
|
* gets values into the right types and reports mistakes in terms of flags.
|
|
120
120
|
*/
|
|
121
|
-
export function parseToolArgs(argv, flags, positional) {
|
|
121
|
+
export function parseToolArgs(argv, flags, positional, aliases = {}) {
|
|
122
122
|
const out = {};
|
|
123
123
|
const bare = [];
|
|
124
124
|
const byName = new Map();
|
|
@@ -126,6 +126,12 @@ export function parseToolArgs(argv, flags, positional) {
|
|
|
126
126
|
byName.set(flag.flag, flag);
|
|
127
127
|
byName.set(`--${flag.key}`, flag);
|
|
128
128
|
}
|
|
129
|
+
// An alias never shadows a real flag of the same name.
|
|
130
|
+
for (const [alias, key] of Object.entries(aliases)) {
|
|
131
|
+
const flag = flags.find((candidate) => candidate.key === key);
|
|
132
|
+
if (flag && !byName.has(`--${alias}`))
|
|
133
|
+
byName.set(`--${alias}`, flag);
|
|
134
|
+
}
|
|
129
135
|
const assign = (flag, value) => {
|
|
130
136
|
if (flag.repeatable) {
|
|
131
137
|
const values = Array.isArray(value) ? value : [value];
|
package/dist/cli/help.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare const GLOBAL_FLAGS: Array<[string, string]>;
|
|
|
12
12
|
export declare function renderList(app: App, tools: readonly Tool[], bin: string, env?: NodeJS.ProcessEnv): string;
|
|
13
13
|
/** An example's arguments as the command someone would type. */
|
|
14
14
|
export declare function exampleCommand(bin: string, tool: Tool, args: Record<string, unknown>): string;
|
|
15
|
-
export declare function renderToolHelp(tool: Tool, bin: string): string;
|
|
15
|
+
export declare function renderToolHelp(tool: Tool, bin: string, aliases?: Readonly<Record<string, string>>): string;
|
|
16
16
|
export declare function renderGeneralHelp(app: App, bin: string): string;
|
|
17
17
|
/** One line per tool, for search results and listings. */
|
|
18
18
|
export declare function toolLine(tool: Tool): string;
|
package/dist/cli/help.js
CHANGED
|
@@ -17,8 +17,8 @@ export const GLOBAL_FLAGS = [
|
|
|
17
17
|
["--jsonl", "one JSON value per line, for lists"],
|
|
18
18
|
["--csv / --tsv", "a table, for lists of records"],
|
|
19
19
|
["--quiet", "one value per line: ids, or the one --select field"],
|
|
20
|
-
["--select <a,b.c>", "keep only these fields
|
|
21
|
-
["--agent", "JSON,
|
|
20
|
+
["--select <a,b.c>", "keep only these fields"],
|
|
21
|
+
["--agent", "compact JSON, no prompts, never confirms"],
|
|
22
22
|
["--out <file>", "write the output to a new file instead of stdout"],
|
|
23
23
|
["--input <json|@file|->", "all arguments as one JSON object; flags override it"],
|
|
24
24
|
["--dry-run", "check everything and print what would run, without running it"],
|
|
@@ -32,7 +32,9 @@ function line(left, help) {
|
|
|
32
32
|
return left.length < COLUMN ? [`${left.padEnd(COLUMN)}${help}`] : [left, `${" ".repeat(COLUMN)}${help}`];
|
|
33
33
|
}
|
|
34
34
|
function riskWords(tool) {
|
|
35
|
-
|
|
35
|
+
if (tool.spends)
|
|
36
|
+
return "spends money, needs --confirm";
|
|
37
|
+
return tool.risk === "read" ? "read" : tool.risk === "write" ? "writes, reversible" : "public or irreversible";
|
|
36
38
|
}
|
|
37
39
|
/** The output flags a command can use: the four every command takes, and those its kind adds. */
|
|
38
40
|
function outputFlagsFor(tool) {
|
|
@@ -92,11 +94,12 @@ export function renderList(app, tools, bin, env = process.env) {
|
|
|
92
94
|
if (grouped)
|
|
93
95
|
lines.push(``, ` ${group || "general"}${group && toolsets[group] ? `: ${toolsets[group]}` : ""}`);
|
|
94
96
|
for (const tool of members)
|
|
95
|
-
lines.push(` ${riskMark(tool
|
|
97
|
+
lines.push(` ${riskMark(tool)} ${tool.command.padEnd(width)}${tool.title}`);
|
|
96
98
|
}
|
|
97
99
|
// The legend says `!` needs --confirm only when that holds for every listed command.
|
|
98
|
-
const confirmByRisk = tools.every((tool) => tool.requireConfirm === (tool.risk === "destructive"));
|
|
99
|
-
|
|
100
|
+
const confirmByRisk = tools.every((tool) => tool.requireConfirm === (tool.risk === "destructive" || tool.spends));
|
|
101
|
+
const paid = tools.some((tool) => tool.spends) ? " $ spends money, needs --confirm" : "";
|
|
102
|
+
lines.push(``, ` * writes ! public or irreversible${confirmByRisk ? ", needs --confirm" : ""}${paid}`, ``, ` ${bin} <command> --help what one takes, with examples`, ` ${bin} which <words> find the command for a task`, ` ${bin} --help flags, settings and setup`, ``, ...hiddenNote(app, env));
|
|
100
103
|
return lines.join("\n");
|
|
101
104
|
}
|
|
102
105
|
function shellQuote(value) {
|
|
@@ -136,7 +139,8 @@ function placeholder(flag) {
|
|
|
136
139
|
return flag.choices.length <= 6 ? ` <${flag.choices.join("|")}>` : " <choice>";
|
|
137
140
|
return ` <${flag.kind === "json" ? "json|@file" : flag.kind}>`;
|
|
138
141
|
}
|
|
139
|
-
export function renderToolHelp(tool, bin) {
|
|
142
|
+
export function renderToolHelp(tool, bin, aliases = {}) {
|
|
143
|
+
const aliasOf = (key) => Object.entries(aliases).filter(([, target]) => target === key).map(([alias]) => `, --${alias}`).join("");
|
|
140
144
|
const flags = flagsFor(tool.jsonSchema).filter((flag) => flag.key !== "confirm");
|
|
141
145
|
const required = flags.filter((flag) => flag.required);
|
|
142
146
|
const optional = flags.filter((flag) => !flag.required);
|
|
@@ -159,7 +163,7 @@ export function renderToolHelp(tool, bin) {
|
|
|
159
163
|
const extra = [flag.repeatable ? "Repeatable." : "", flag.default !== undefined ? `Default ${JSON.stringify(flag.default)}.` : ""]
|
|
160
164
|
.filter(Boolean)
|
|
161
165
|
.join(" ");
|
|
162
|
-
lines.push(...line(` ${flag.flag}${placeholder(flag)}`, [flag.help, extra].filter(Boolean).join(" ")));
|
|
166
|
+
lines.push(...line(` ${flag.flag}${aliasOf(flag.key)}${placeholder(flag)}`, [flag.help, extra].filter(Boolean).join(" ")));
|
|
163
167
|
}
|
|
164
168
|
lines.push(...after, ``);
|
|
165
169
|
};
|
|
@@ -215,15 +219,16 @@ export function renderGeneralHelp(app, bin) {
|
|
|
215
219
|
const settings = [
|
|
216
220
|
...(app.definition.settings ?? []).filter((setting) => !setting.tuning).map((setting) => [setting.env, setting.description]),
|
|
217
221
|
[`${names.readOnly}=1`, "hide and refuse every write"],
|
|
218
|
-
[`${names.allowDestructive}=0`, "refuse the irreversible writes"],
|
|
219
|
-
|
|
222
|
+
[`${names.allowDestructive}=0`, app.allTools.some((tool) => tool.spends) ? "refuse the irreversible writes and paid calls" : "refuse the irreversible writes"],
|
|
223
|
+
// With no tagged tool every tool is always on, so the switch would do nothing.
|
|
224
|
+
...(app.allTools.some((tool) => tool.tags.length > 0) ? [[`${names.toolsets}=a,b`, "only these toolsets, or all"]] : []),
|
|
220
225
|
[`${names.surface}=search`, "MCP lists three finder tools instead"],
|
|
221
226
|
[`${names.auditLog}=<file>`, "log every attempted write"],
|
|
222
227
|
[`${names.toolTimeoutMs}=<ms>`, "deadline for any tool"],
|
|
223
228
|
[`${names.confirm}=model`, "confirm: true alone confirms over MCP"],
|
|
224
229
|
...(cache ? [[`${names.cache}=0`, "never answer from the local cache"]] : []),
|
|
225
230
|
...(cache || sync ? [[`${names.dataDir}=<dir>`, "keep local data in this folder"]] : []),
|
|
226
|
-
[`${app.envPrefix}_HTTP_PORT / _HOST / _TOKEN`, "for --http"],
|
|
231
|
+
[`${app.envPrefix}_HTTP_PORT / _HOST / _TOKEN / _ALLOWED_ORIGINS`, "for --http"],
|
|
227
232
|
[`${app.envPrefix}_DEBUG=1`, "debug lines on stderr"],
|
|
228
233
|
];
|
|
229
234
|
// Flags that cannot apply here (jobs, the cache) are left out; agent-context lists every one.
|
|
@@ -252,5 +257,5 @@ export function renderGeneralHelp(app, bin) {
|
|
|
252
257
|
}
|
|
253
258
|
/** One line per tool, for search results and listings. */
|
|
254
259
|
export function toolLine(tool) {
|
|
255
|
-
return `${riskMark(tool
|
|
260
|
+
return `${riskMark(tool)} ${tool.command} ${tool.title}: ${firstSentence(tool.description, 80)}`;
|
|
256
261
|
}
|
package/dist/cli/output.js
CHANGED
|
@@ -16,6 +16,19 @@ export function selectFields(data, paths) {
|
|
|
16
16
|
return data.map((item) => selectFields(item, paths));
|
|
17
17
|
if (data === null || typeof data !== "object")
|
|
18
18
|
return data;
|
|
19
|
+
const record = data;
|
|
20
|
+
// `--select id,status` on `{ count, jobs: [...] }` means the jobs' fields. When no path
|
|
21
|
+
// starts at the top and the result holds one list of records, select inside it and keep
|
|
22
|
+
// the rest as it is. A list of plain values, such as image URLs, is not a result set.
|
|
23
|
+
const heads = paths.map((path) => path.split(".")[0]).filter((head) => Boolean(head));
|
|
24
|
+
if (heads.length && heads.every((head) => !(head in record))) {
|
|
25
|
+
const isRecord = (item) => item !== null && typeof item === "object" && !Array.isArray(item);
|
|
26
|
+
const arrays = Object.entries(record).filter(([, value]) => Array.isArray(value));
|
|
27
|
+
const lists = arrays.filter(([, value]) => value.length > 0 && value.every(isRecord));
|
|
28
|
+
const only = lists.length === 1 ? lists[0] : lists.length === 0 && arrays.length === 1 && arrays[0][1].length === 0 ? arrays[0] : undefined;
|
|
29
|
+
if (only)
|
|
30
|
+
return { ...record, [only[0]]: only[1].map((item) => selectFields(item, paths)) };
|
|
31
|
+
}
|
|
19
32
|
// Grouped by first segment: assigning one path at a time let the last path win,
|
|
20
33
|
// so `--select posts.uri,posts.text` returned only the text.
|
|
21
34
|
const byHead = new Map();
|
package/dist/cli/run.js
CHANGED
|
@@ -142,7 +142,7 @@ export async function runCli(app, argv, partial = {}) {
|
|
|
142
142
|
const builtin = command !== undefined && BUILTINS.includes(command);
|
|
143
143
|
const custom = command !== undefined && !builtin ? app.definition.commands?.find((candidate) => candidate.name === command) : undefined;
|
|
144
144
|
const tool = command !== undefined && !builtin && !custom ? app.find(command) : undefined;
|
|
145
|
-
const reserved = new Set(tool ? flagsFor(tool.jsonSchema).map((flag) => flag.flag) : []);
|
|
145
|
+
const reserved = new Set(tool ? flagsFor(tool.jsonSchema).map((flag) => flag.flag) : (custom?.flags ?? []));
|
|
146
146
|
let agent = argv.includes("--agent");
|
|
147
147
|
try {
|
|
148
148
|
const { globals, rest } = extractGlobals(at === -1 ? argv : [...argv.slice(0, at), ...argv.slice(at + 1)], reserved);
|
|
@@ -158,14 +158,14 @@ export async function runCli(app, argv, partial = {}) {
|
|
|
158
158
|
return await runBuiltin(app, io, command, rest, globals);
|
|
159
159
|
if (custom) {
|
|
160
160
|
if (globals.help)
|
|
161
|
-
return print(io, `\nUsage: ${io.bin} ${custom.usage ?? custom.name}\n\n${custom.help}\n`);
|
|
161
|
+
return print(io, `\nUsage: ${io.bin} ${custom.usage ?? custom.name}\n\n${sentence(custom.help)}\n`);
|
|
162
162
|
return await custom.run(io, rest);
|
|
163
163
|
}
|
|
164
164
|
if (!tool) {
|
|
165
165
|
const candidates = [...app.tools(io.env).map((t) => t.command), ...BUILTINS, ...(app.definition.commands ?? []).map((c) => c.name)];
|
|
166
166
|
const guess = didYouMean(command, candidates);
|
|
167
167
|
throw new UsageError(`Unknown command '${command}'.${guess ? ` Did you mean '${guess}'?` : ""}`, {
|
|
168
|
-
hint: `Run \`${app.bins.cli}\` to list commands, or \`${
|
|
168
|
+
hint: `Run \`${app.bins.cli}\` to list commands, or \`${app.bins.cli} which <words>\` to find one.`,
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
171
|
const seen = visibility(tool, app.policy(io.env));
|
|
@@ -176,14 +176,14 @@ export async function runCli(app, argv, partial = {}) {
|
|
|
176
176
|
: `${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.` });
|
|
177
177
|
}
|
|
178
178
|
if (globals.help)
|
|
179
|
-
return print(io, renderToolHelp(tool, io.bin));
|
|
179
|
+
return print(io, renderToolHelp(tool, io.bin, app.definition.flagAliases));
|
|
180
180
|
return await runTool(app, io, tool, rest, globals);
|
|
181
181
|
}
|
|
182
182
|
catch (error) {
|
|
183
183
|
const failure = toSlipwayError(error);
|
|
184
184
|
emitError(io, app, failure, agent);
|
|
185
185
|
if (failure.code === "usage" && tool && !agent && io.isTTY)
|
|
186
|
-
io.stderr(renderToolHelp(tool, io.bin));
|
|
186
|
+
io.stderr(renderToolHelp(tool, io.bin, app.definition.flagAliases));
|
|
187
187
|
return failure.exitCode;
|
|
188
188
|
}
|
|
189
189
|
}
|
|
@@ -204,7 +204,10 @@ async function runBuiltin(app, io, command, rest, globals) {
|
|
|
204
204
|
return print(io, (await import("./install.js")).installHelp(app, io.bin));
|
|
205
205
|
const login = app.definition.login;
|
|
206
206
|
if (globals.help && command === "login" && typeof login === "object")
|
|
207
|
-
return print(io, `\nUsage: ${io.bin} ${login.usage ?? "login"}\n\n${login.help}\n`);
|
|
207
|
+
return print(io, `\nUsage: ${io.bin} ${login.usage ?? "login"}\n\n${sentence(login.help)}\n`);
|
|
208
|
+
// Printed steps are their own help.
|
|
209
|
+
if (globals.help && command === "login" && typeof login === "string")
|
|
210
|
+
return print(io, login);
|
|
208
211
|
if (globals.help && command !== "help")
|
|
209
212
|
return print(io, renderGeneralHelp(app, io.bin));
|
|
210
213
|
const target = rest.find((token) => !token.startsWith("-"));
|
|
@@ -219,7 +222,7 @@ async function runBuiltin(app, io, command, rest, globals) {
|
|
|
219
222
|
const tool = app.find(target);
|
|
220
223
|
if (!tool)
|
|
221
224
|
throw new UsageError(`Unknown command '${target}'.`, { hint: `Run \`${app.bins.cli}\` to list commands.` });
|
|
222
|
-
return print(io, renderToolHelp(tool, io.bin));
|
|
225
|
+
return print(io, renderToolHelp(tool, io.bin, app.definition.flagAliases));
|
|
223
226
|
}
|
|
224
227
|
case "schema": {
|
|
225
228
|
const tool = target ? app.find(target) : undefined;
|
|
@@ -238,7 +241,11 @@ async function runBuiltin(app, io, command, rest, globals) {
|
|
|
238
241
|
const query = rest.filter((token) => !token.startsWith("-")).join(" ");
|
|
239
242
|
if (!query)
|
|
240
243
|
throw new UsageError("which expects the words for what you want to do: which schedule a post");
|
|
241
|
-
|
|
244
|
+
// Only the close matches: on Threads the right command scored 20 and the eighth 9, and the
|
|
245
|
+
// ten-line list cost an agent more to read than the answer was worth.
|
|
246
|
+
const found = searchTools(app.tools(io.env), query, 10, app.definition.synonyms);
|
|
247
|
+
const best = found[0]?.score ?? 0;
|
|
248
|
+
const matches = found.filter(({ score }, index) => index < 3 || score >= best / 2);
|
|
242
249
|
if (globals.format !== "auto") {
|
|
243
250
|
return print(io, json(globals, matches.map(({ tool, score }) => ({ command: tool.command, title: tool.title, risk: tool.risk, score: Number(score.toFixed(2)) }))));
|
|
244
251
|
}
|
|
@@ -281,7 +288,7 @@ async function readInput(io, raw) {
|
|
|
281
288
|
async function runTool(app, io, tool, rest, globals) {
|
|
282
289
|
const flags = flagsFor(tool.jsonSchema);
|
|
283
290
|
const base = globals.input ? await readInput(io, globals.input) : {};
|
|
284
|
-
const args = { ...base, ...parseToolArgs(rest, flags, tool.positional) };
|
|
291
|
+
const args = { ...base, ...parseToolArgs(rest, flags, tool.positional, app.definition.flagAliases) };
|
|
285
292
|
if (globals.confirm && tool.requireConfirm)
|
|
286
293
|
args.confirm = true;
|
|
287
294
|
const missing = missingRequired(flags, args).filter((flag) => !(globals.all && tool.paginate && flag.key === tool.paginate.cursorArg));
|
|
@@ -357,3 +364,10 @@ function writeOut(io, globals, tool, result) {
|
|
|
357
364
|
io.stdout(`${JSON.stringify({ saved: globals.out, bytes: Buffer.byteLength(text) })}\n`);
|
|
358
365
|
return EXIT.ok;
|
|
359
366
|
}
|
|
367
|
+
/** A help line written for the command table reads as a sentence on its own `--help` page. */
|
|
368
|
+
function sentence(text) {
|
|
369
|
+
const trimmed = text.trim();
|
|
370
|
+
if (!trimmed)
|
|
371
|
+
return trimmed;
|
|
372
|
+
return `${trimmed[0].toUpperCase()}${trimmed.slice(1)}${/[.!?:]$/.test(trimmed) ? "" : "."}`;
|
|
373
|
+
}
|
package/dist/confirm.d.ts
CHANGED
|
@@ -64,7 +64,7 @@ export declare const verifyApprovalState: (state: string, ctx: ServerContext) =>
|
|
|
64
64
|
* form by itself (Codex accepts a form that has no fields), and one that fills
|
|
65
65
|
* in defaults would otherwise approve with them.
|
|
66
66
|
*/
|
|
67
|
-
export declare function approvalForm(appTitle: string, tool: Pick<Tool, "risk">, summary: string): {
|
|
67
|
+
export declare function approvalForm(appTitle: string, tool: Pick<Tool, "risk" | "consequence" | "spends">, summary: string): {
|
|
68
68
|
message: string;
|
|
69
69
|
requestedSchema: {
|
|
70
70
|
type: "object";
|
package/dist/docs.js
CHANGED
|
@@ -52,7 +52,7 @@ export function settingsTable(app) {
|
|
|
52
52
|
"|---|---|",
|
|
53
53
|
...(app.definition.settings ?? []).map((setting) => `| \`${setting.env}\` | ${cell(setting.description)}${setting.secret ? " Keep it private." : ""} |`),
|
|
54
54
|
`| \`${names.readOnly}=1\` | Hide and refuse every write |`,
|
|
55
|
-
`| \`${names.allowDestructive}=0\` | Keep writes, refuse the public or irreversible ones |`,
|
|
55
|
+
`| \`${names.allowDestructive}=0\` | Keep writes, refuse the public or irreversible ones${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""} |`,
|
|
56
56
|
`| \`${names.toolsets}\` | Comma-separated toolsets to turn on, or \`all\` |`,
|
|
57
57
|
`| \`${names.surface}=search\` | List three tools that find, describe and run the rest |`,
|
|
58
58
|
`| \`${names.auditLog}\` | File that records every attempted write |`,
|
package/dist/doctor.js
CHANGED
|
@@ -16,7 +16,8 @@ export async function runDoctor(app, io, options) {
|
|
|
16
16
|
const major = Number(process.versions.node.split(".")[0]);
|
|
17
17
|
checks.push({ name: "Node.js", ok: major >= 22, detail: `v${process.versions.node}`, ...(major >= 22 ? {} : { fix: "Install Node.js 22 or later." }) });
|
|
18
18
|
checks.push({ name: "Version", ok: true, detail: `${app.name} ${app.version}` });
|
|
19
|
-
const
|
|
19
|
+
const paid = app.allTools.some((tool) => tool.spends) ? " and paid" : "";
|
|
20
|
+
const writes = policy.readOnly ? "off (read-only)" : policy.allowDestructive ? "on" : `on, irreversible${paid} ones refused`;
|
|
20
21
|
checks.push({ name: "Writes", ok: true, detail: writes });
|
|
21
22
|
checks.push({
|
|
22
23
|
name: "Tools",
|
package/dist/guard.d.ts
CHANGED
|
@@ -43,4 +43,4 @@ export declare class Guard {
|
|
|
43
43
|
record(tool: Tool, summary: string, outcome: GuardOutcome | "failed" | "done" | "started", confirmedBy?: ConfirmedBy): void;
|
|
44
44
|
}
|
|
45
45
|
/** Why a tool needs confirming, in the words a refusal and an approval form both use. */
|
|
46
|
-
export declare function consequence(tool: Pick<Tool, "risk">): string;
|
|
46
|
+
export declare function consequence(tool: Pick<Tool, "risk" | "consequence" | "spends">): string;
|
package/dist/guard.js
CHANGED
|
@@ -41,10 +41,10 @@ export class Guard {
|
|
|
41
41
|
hint: `Unset ${names.readOnly} to allow writes.`,
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
|
-
if (tool.risk === "destructive" && !this.policy.allowDestructive) {
|
|
44
|
+
if ((tool.risk === "destructive" || tool.spends) && !this.policy.allowDestructive) {
|
|
45
45
|
this.record(tool, summary, "blocked: destructive disabled");
|
|
46
46
|
throw new RefusedError(`${tool.name} is unavailable: this server is running with ${names.allowDestructive}=0.`, {
|
|
47
|
-
hint: `Unset ${names.allowDestructive} to allow irreversible writes.`,
|
|
47
|
+
hint: `Unset ${names.allowDestructive} to allow irreversible writes${tool.spends ? " and paid calls" : ""}.`,
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -85,5 +85,9 @@ export class Guard {
|
|
|
85
85
|
}
|
|
86
86
|
/** Why a tool needs confirming, in the words a refusal and an approval form both use. */
|
|
87
87
|
export function consequence(tool) {
|
|
88
|
+
if (tool.consequence)
|
|
89
|
+
return tool.consequence;
|
|
90
|
+
if (tool.spends)
|
|
91
|
+
return "spends money and cannot be refunded";
|
|
88
92
|
return tool.risk === "destructive" ? "is public or cannot be undone" : "has an effect that cannot be taken back";
|
|
89
93
|
}
|
package/dist/install.js
CHANGED
|
@@ -215,7 +215,7 @@ export function planInstall(app, options, context) {
|
|
|
215
215
|
if (client === "claude-code") {
|
|
216
216
|
const entry = { type: "stdio", command: launch.command, args: launch.args };
|
|
217
217
|
if (variables.length)
|
|
218
|
-
notes.push(`Claude Code passes its own environment to the server
|
|
218
|
+
notes.push(`Claude Code passes its own environment to the server, so whichever of ${list(variables)} you use go in the shell you start Claude Code from.`);
|
|
219
219
|
return { client, scope, name, entry, env, notes, run: { command: "claude", args: ["mcp", "add-json", name, JSON.stringify(entry), "--scope", scope] } };
|
|
220
220
|
}
|
|
221
221
|
if (client === "codex") {
|
|
@@ -223,7 +223,7 @@ export function planInstall(app, options, context) {
|
|
|
223
223
|
const before = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
224
224
|
const text = upsertCodexServer(before, name, launch, variables, usesNpx);
|
|
225
225
|
if (variables.length)
|
|
226
|
-
notes.push(`Codex passes ${list(variables)} on from its own environment
|
|
226
|
+
notes.push(`Codex passes ${list(variables)} on from its own environment, so whichever you use go where you start Codex.`);
|
|
227
227
|
if (scope === "project")
|
|
228
228
|
notes.push("Codex reads a project's .codex/config.toml only once the project is trusted.");
|
|
229
229
|
const entry = upsertCodexServer("", name, launch, variables, usesNpx).trim();
|
|
@@ -290,7 +290,7 @@ export function planInstall(app, options, context) {
|
|
|
290
290
|
if (env.copied.length)
|
|
291
291
|
notes.push(`Copied ${list(env.copied)} from this shell into ${file}, which is now readable by you only.`);
|
|
292
292
|
if (env.toAdd.length) {
|
|
293
|
-
notes.push(`Claude Desktop does not read a shell's environment
|
|
293
|
+
notes.push(`Claude Desktop does not read a shell's environment, so whichever of ${list(env.toAdd)} you use go in the env of "${name}" in ${file}, or run install again with --copy-env to copy them from this shell.`);
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
return { client, scope, name, file, entry: server, text: `${JSON.stringify(next, null, 2)}\n`, env, notes };
|
package/dist/policy.d.ts
CHANGED
|
@@ -63,4 +63,7 @@ export type Visibility = {
|
|
|
63
63
|
};
|
|
64
64
|
/** Whether a tool is on under this policy, and if not, why, so a refusal can say how to turn it on. */
|
|
65
65
|
export declare function visibility(tool: Pick<Tool, "risk" | "tags">, policy: Policy): Visibility;
|
|
66
|
-
export declare function riskMark(
|
|
66
|
+
export declare function riskMark(tool: {
|
|
67
|
+
risk: Risk;
|
|
68
|
+
spends?: boolean;
|
|
69
|
+
}): string;
|
package/dist/policy.js
CHANGED
|
@@ -69,6 +69,6 @@ export function visibility(tool, policy) {
|
|
|
69
69
|
}
|
|
70
70
|
return { visible: true };
|
|
71
71
|
}
|
|
72
|
-
export function riskMark(
|
|
73
|
-
return risk === "read" ? " " : risk === "destructive" ? "!" : "*";
|
|
72
|
+
export function riskMark(tool) {
|
|
73
|
+
return tool.spends ? "$" : tool.risk === "read" ? " " : tool.risk === "destructive" ? "!" : "*";
|
|
74
74
|
}
|
package/dist/search.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type Match = {
|
|
|
10
10
|
tool: Tool;
|
|
11
11
|
score: number;
|
|
12
12
|
};
|
|
13
|
-
export declare function searchTools(tools: readonly Tool[], query: string, limit?: number): Match[];
|
|
13
|
+
export declare function searchTools(tools: readonly Tool[], query: string, limit?: number, synonyms?: Readonly<Record<string, readonly string[]>>): Match[];
|
|
14
14
|
/** Edit distance, for "did you mean" on a mistyped command. */
|
|
15
15
|
export declare function distance(a: string, b: string): number;
|
|
16
16
|
export declare function didYouMean(input: string, candidates: readonly string[]): string | undefined;
|
package/dist/search.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* from the words someone would use to ask for it. The same ranking answers
|
|
6
6
|
* `which` in a terminal and `search_tools` for a model.
|
|
7
7
|
*/
|
|
8
|
+
/** Words a query carries that say nothing about which tool: "how do I get my images". */
|
|
9
|
+
const STOP_WORDS = new Set([
|
|
10
|
+
"the", "my", "me", "to", "of", "for", "in", "on", "and", "or", "how", "do", "does", "can", "want",
|
|
11
|
+
"with", "from", "is", "are", "it", "that", "this", "what", "which", "any", "some", "please", "an",
|
|
12
|
+
]);
|
|
8
13
|
function words(text) {
|
|
9
14
|
return text
|
|
10
15
|
.toLowerCase()
|
|
@@ -12,6 +17,13 @@ function words(text) {
|
|
|
12
17
|
.filter((word) => word.length > 1)
|
|
13
18
|
.map(stem);
|
|
14
19
|
}
|
|
20
|
+
function queryWords(text) {
|
|
21
|
+
return text
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.split(/[^a-z0-9]+/)
|
|
24
|
+
.filter((word) => word.length > 1 && !STOP_WORDS.has(word))
|
|
25
|
+
.map(stem);
|
|
26
|
+
}
|
|
15
27
|
/** Close enough for matching "posts" to "post" and "scheduling" to "schedul". */
|
|
16
28
|
function stem(word) {
|
|
17
29
|
if (word.length > 4 && word.endsWith("ies"))
|
|
@@ -48,23 +60,32 @@ const RELATED = new Map();
|
|
|
48
60
|
for (const group of SYNONYMS)
|
|
49
61
|
for (const word of group)
|
|
50
62
|
RELATED.set(word, [...new Set([...(RELATED.get(word) ?? []), ...group])]);
|
|
51
|
-
function hits(query, haystack) {
|
|
63
|
+
function hits(query, haystack, extra) {
|
|
52
64
|
const direct = exact(query, haystack);
|
|
53
65
|
if (direct > 0)
|
|
54
66
|
return direct;
|
|
55
67
|
// A synonym counts for a little less than the word itself, so an exact match still wins.
|
|
56
|
-
|
|
68
|
+
const related = [...(RELATED.get(query) ?? []), ...(extra?.get(query) ?? [])];
|
|
69
|
+
return related.some((word) => word !== query && exact(word, haystack) === 1) ? 0.75 : 0;
|
|
57
70
|
}
|
|
58
71
|
function exact(query, haystack) {
|
|
59
72
|
if (haystack.includes(query))
|
|
60
73
|
return 1;
|
|
61
74
|
return haystack.some((word) => word.startsWith(query) || query.startsWith(word)) ? 0.5 : 0;
|
|
62
75
|
}
|
|
63
|
-
export function searchTools(tools, query, limit = 10) {
|
|
64
|
-
const terms = [...new Set(
|
|
76
|
+
export function searchTools(tools, query, limit = 10, synonyms = {}) {
|
|
77
|
+
const terms = [...new Set(queryWords(query))];
|
|
65
78
|
if (terms.length === 0)
|
|
66
79
|
return [];
|
|
67
80
|
const phrase = query.trim().toLowerCase();
|
|
81
|
+
const extra = new Map();
|
|
82
|
+
const pointsAt = new Map();
|
|
83
|
+
for (const [word, targets] of Object.entries(synonyms)) {
|
|
84
|
+
extra.set(stem(word.toLowerCase()), targets.flatMap(words));
|
|
85
|
+
pointsAt.set(stem(word.toLowerCase()), targets.map((target) => target.toLowerCase()));
|
|
86
|
+
}
|
|
87
|
+
// A synonym can point straight at a tool's name, "redo" at `rerun_job`.
|
|
88
|
+
const named = (tool, term) => [term, ...(pointsAt.get(term) ?? [])].some((word) => word === tool.name || word === tool.name.replace(/_/g, ""));
|
|
68
89
|
const scored = tools.map((tool) => {
|
|
69
90
|
const name = words(tool.name);
|
|
70
91
|
const title = words(tool.title);
|
|
@@ -73,7 +94,8 @@ export function searchTools(tools, query, limit = 10) {
|
|
|
73
94
|
let score = 0;
|
|
74
95
|
let matched = 0;
|
|
75
96
|
for (const term of terms) {
|
|
76
|
-
|
|
97
|
+
// A term that is the tool's name is not a hint, it is the answer.
|
|
98
|
+
const got = (named(tool, term) ? 20 : 0) + hits(term, name, extra) * 5 + hits(term, title, extra) * 4 + hits(term, tags, extra) * 3 + hits(term, description, extra);
|
|
77
99
|
if (got > 0)
|
|
78
100
|
matched++;
|
|
79
101
|
score += got;
|
package/dist/serve.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Serving an app over stdio, which every local client launches, or HTTP.
|
|
3
3
|
*/
|
|
4
4
|
import { type App } from "./app.js";
|
|
5
|
+
import type { Logger } from "./tool.js";
|
|
5
6
|
/**
|
|
6
7
|
* Serve over stdio.
|
|
7
8
|
*
|
|
@@ -11,10 +12,17 @@ import { type App } from "./app.js";
|
|
|
11
12
|
* set of tools that explain what to configure.
|
|
12
13
|
*/
|
|
13
14
|
export declare function serveStdioApp(app: App, env: NodeJS.ProcessEnv): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Once the server is answering: say if nothing is configured, then run the
|
|
17
|
+
* app's `onServe`. Nothing here can hold up the handshake or stop the server.
|
|
18
|
+
*/
|
|
19
|
+
export declare function afterStart(app: App, env: NodeJS.ProcessEnv, log: Logger): Promise<void>;
|
|
14
20
|
export type HttpOptions = {
|
|
15
21
|
host: string;
|
|
16
22
|
port: number;
|
|
17
23
|
token?: string;
|
|
24
|
+
/** Browser origins beyond localhost that may call the server, from `<PREFIX>_HTTP_ALLOWED_ORIGINS`. */
|
|
25
|
+
allowedOrigins?: readonly string[];
|
|
18
26
|
};
|
|
19
27
|
export declare function httpOptions(app: App, env: NodeJS.ProcessEnv, argv: string[]): HttpOptions;
|
|
20
28
|
/**
|
package/dist/serve.js
CHANGED
|
@@ -27,17 +27,29 @@ export async function serveStdioApp(app, env) {
|
|
|
27
27
|
};
|
|
28
28
|
process.on("SIGINT", shutdown);
|
|
29
29
|
process.on("SIGTERM", shutdown);
|
|
30
|
-
void
|
|
30
|
+
void afterStart(app, env, log);
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Once the server is answering: say if nothing is configured, then run the
|
|
34
|
+
* app's `onServe`. Nothing here can hold up the handshake or stop the server.
|
|
35
|
+
*/
|
|
36
|
+
export async function afterStart(app, env, log) {
|
|
37
|
+
let ctx;
|
|
33
38
|
try {
|
|
34
|
-
|
|
39
|
+
ctx = await app.context(env);
|
|
35
40
|
if (app.definition.configured && !(await app.definition.configured(ctx))) {
|
|
36
|
-
warn(`Nothing is configured yet. Tools that need an account will say what is missing. Run \`${app.bins.cli} doctor\`.`);
|
|
41
|
+
log.warn(`Nothing is configured yet. Tools that need an account will say what is missing. Run \`${app.bins.cli} doctor\`.`);
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
catch (error) {
|
|
40
|
-
warn(`Setup is incomplete: ${error.message} Run \`${app.bins.cli} doctor\`.`);
|
|
45
|
+
log.warn(`Setup is incomplete: ${error.message} Run \`${app.bins.cli} doctor\`.`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await app.definition.onServe?.(ctx, log);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
log.warn(app.secrets.redact(error?.message ?? String(error)));
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
55
|
export function httpOptions(app, env, argv) {
|
|
@@ -50,9 +62,21 @@ export function httpOptions(app, env, argv) {
|
|
|
50
62
|
host: env[`${app.envPrefix}_HTTP_HOST`]?.trim() || "127.0.0.1",
|
|
51
63
|
port,
|
|
52
64
|
token: env[`${app.envPrefix}_HTTP_TOKEN`]?.trim() || undefined,
|
|
65
|
+
allowedOrigins: (env[`${app.envPrefix}_HTTP_ALLOWED_ORIGINS`] ?? "")
|
|
66
|
+
.split(",")
|
|
67
|
+
.map((origin) => origin.trim().replace(/\/$/, ""))
|
|
68
|
+
.filter(Boolean),
|
|
53
69
|
};
|
|
54
70
|
}
|
|
55
71
|
const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
72
|
+
function localOrigin(origin) {
|
|
73
|
+
try {
|
|
74
|
+
return LOOPBACK.has(new URL(origin).hostname);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
56
80
|
/**
|
|
57
81
|
* Serve over Streamable HTTP, for a machine that is always on.
|
|
58
82
|
*
|
|
@@ -83,6 +107,7 @@ export async function serveHttpApp(app, env, options) {
|
|
|
83
107
|
const port = address && typeof address === "object" ? address.port : options.port;
|
|
84
108
|
const url = `http://${options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host}:${port}/mcp`;
|
|
85
109
|
log.info(`listening on ${url}${options.token ? " (bearer token required)" : ""}`);
|
|
110
|
+
void afterStart(app, env, log);
|
|
86
111
|
return {
|
|
87
112
|
url,
|
|
88
113
|
close: async () => {
|
|
@@ -99,6 +124,14 @@ async function handle(app, fetchMcp, options, loopback, req, res) {
|
|
|
99
124
|
res.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: "forbidden host" }));
|
|
100
125
|
return;
|
|
101
126
|
}
|
|
127
|
+
// The MCP transport spec asks every server to check Origin: a page on another
|
|
128
|
+
// site can send a request here that a browser lets through, and only the
|
|
129
|
+
// Origin header says where it came from. Clients that are not browsers send none.
|
|
130
|
+
const origin = req.headers.origin;
|
|
131
|
+
if (origin && !localOrigin(origin) && !(options.allowedOrigins ?? []).includes(origin.replace(/\/$/, ""))) {
|
|
132
|
+
res.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: `Origin ${origin} is not allowed. Add it to ${app.envPrefix}_HTTP_ALLOWED_ORIGINS if this is deliberate.` }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
102
135
|
if (url.pathname === "/health") {
|
|
103
136
|
res.writeHead(200, { "content-type": "application/json" });
|
|
104
137
|
res.end(JSON.stringify({ ok: true, name: app.name, version: app.version, tools: app.tools().length }));
|
package/dist/server.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { McpServer, type ToolAnnotations } from "@modelcontextprotocol/server";
|
|
9
9
|
import type { App } from "./app.js";
|
|
10
10
|
import type { Policy } from "./policy.js";
|
|
11
|
-
import type
|
|
11
|
+
import { type Tool } from "./tool.js";
|
|
12
12
|
/** Claude Code shows a person a permission prompt on every call to a tool carrying this, in any mode. */
|
|
13
13
|
export declare const REQUIRES_USER_INTERACTION = "anthropic/requiresUserInteraction";
|
|
14
14
|
/** Claude Code raises its result-size limit for a tool carrying this. */
|
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { toSlipwayError, UsageError } from "./errors.js";
|
|
|
12
12
|
import { errorResult, isPlainObject, toCallToolResult } from "./result.js";
|
|
13
13
|
import { outputJsonSchema } from "./schema.js";
|
|
14
14
|
import { firstSentence, searchTools } from "./search.js";
|
|
15
|
+
import { forCall } from "./tool.js";
|
|
15
16
|
/** Claude Code shows a person a permission prompt on every call to a tool carrying this, in any mode. */
|
|
16
17
|
export const REQUIRES_USER_INTERACTION = "anthropic/requiresUserInteraction";
|
|
17
18
|
/** Claude Code raises its result-size limit for a tool carrying this. */
|
|
@@ -82,14 +83,16 @@ export function buildServer(app, env) {
|
|
|
82
83
|
* is what makes Claude Code prompt for it.
|
|
83
84
|
*/
|
|
84
85
|
async function confirmFirst(server, app, tool, args, ctx, env, policy, listedForPerson) {
|
|
85
|
-
|
|
86
|
+
// A call whose arguments make it a plain write needs nobody's approval, even on a tool that can publish.
|
|
87
|
+
const call = forCall(tool, args);
|
|
88
|
+
if (!call.requireConfirm)
|
|
86
89
|
return {};
|
|
87
90
|
const route = confirmRoute(policy.confirm, clientView(server, ctx), listedForPerson);
|
|
88
91
|
if (route === "client")
|
|
89
92
|
return { approvedBy: "client" };
|
|
90
93
|
if (route === "flag")
|
|
91
94
|
return {};
|
|
92
|
-
const ask = await personApproval(app,
|
|
95
|
+
const ask = await personApproval(app, call, args, ctx, env);
|
|
93
96
|
return ask ? { ask } : { approvedBy: "person" };
|
|
94
97
|
}
|
|
95
98
|
function registerTool(server, app, tool, env, policy) {
|
|
@@ -143,7 +146,7 @@ function registerSearchSurface(server, app, env, policy) {
|
|
|
143
146
|
}),
|
|
144
147
|
annotations: { title: "Find a tool", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
145
148
|
}, async ({ query, limit }) => {
|
|
146
|
-
const matches = searchTools(visible(), query, limit ?? 10).map(({ tool }) => ({
|
|
149
|
+
const matches = searchTools(visible(), query, limit ?? 10, app.definition.synonyms).map(({ tool }) => ({
|
|
147
150
|
name: tool.name,
|
|
148
151
|
title: tool.title,
|
|
149
152
|
risk: tool.risk,
|
package/dist/tool.d.ts
CHANGED
|
@@ -103,6 +103,27 @@ export type ToolDefinition<Ctx, I extends Schema, O extends Schema | undefined>
|
|
|
103
103
|
* generation.
|
|
104
104
|
*/
|
|
105
105
|
requireConfirm?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The risk of one call, when its arguments decide it: publishing is
|
|
108
|
+
* destructive, saving a draft is a write. `risk` stays the highest a call can
|
|
109
|
+
* be, which is what clients see in annotations and listings. The guard, the
|
|
110
|
+
* confirmation and the audit log go by this, and with it only a destructive
|
|
111
|
+
* call needs confirming.
|
|
112
|
+
*/
|
|
113
|
+
riskFor?: (args: InferOutput<I>) => Risk;
|
|
114
|
+
/**
|
|
115
|
+
* The call spends money or credits: a paid generation, a billed send. It
|
|
116
|
+
* needs confirming, `<PREFIX>_ALLOW_DESTRUCTIVE=0` refuses it, and the CLI
|
|
117
|
+
* marks it `$`, while clients still see a plain write, because making an
|
|
118
|
+
* image destroys nothing.
|
|
119
|
+
*/
|
|
120
|
+
spends?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* What a confirmed call does that cannot be taken back, as the refusal and
|
|
123
|
+
* the approval form put it: "moves money and cannot be undone". Defaults to
|
|
124
|
+
* "is public or cannot be undone" for a destructive tool.
|
|
125
|
+
*/
|
|
126
|
+
consequence?: string;
|
|
106
127
|
/** Toolsets this tool belongs to. A tool with no tags is always on. */
|
|
107
128
|
tags?: string[];
|
|
108
129
|
/** One line for the audit log and the refusal message: "post 'Hello' as @alice". */
|
|
@@ -146,6 +167,12 @@ export type Tool<Ctx = any> = {
|
|
|
146
167
|
readonly idempotent: boolean;
|
|
147
168
|
readonly openWorld: boolean;
|
|
148
169
|
readonly requireConfirm: boolean;
|
|
170
|
+
/** The call spends money or credits. */
|
|
171
|
+
readonly spends: boolean;
|
|
172
|
+
/** The risk of one call, from its arguments. `forCall` applies it. */
|
|
173
|
+
readonly riskFor?: (args: any) => Risk;
|
|
174
|
+
/** What a confirmed call does that cannot be taken back, in the tool's own words. */
|
|
175
|
+
readonly consequence?: string;
|
|
149
176
|
readonly tags: readonly string[];
|
|
150
177
|
readonly examples: readonly ToolExample[];
|
|
151
178
|
readonly positional: readonly string[];
|
|
@@ -191,4 +218,11 @@ export declare function isTool(value: unknown): value is Tool;
|
|
|
191
218
|
* arguments have been anywhere near the handler.
|
|
192
219
|
*/
|
|
193
220
|
export declare function summarize(tool: Pick<Tool, "summary" | "title">, args: Record<string, unknown>): string;
|
|
221
|
+
/**
|
|
222
|
+
* The tool as one call sees it. With `riskFor`, the call's risk comes from its
|
|
223
|
+
* arguments, never above the declared one, and only a destructive call needs
|
|
224
|
+
* confirming. Like `summarize`, it runs before validation, so a `riskFor` that
|
|
225
|
+
* throws on odd arguments counts as the declared risk.
|
|
226
|
+
*/
|
|
227
|
+
export declare function forCall<Ctx>(tool: Tool<Ctx>, args: Record<string, unknown>): Tool<Ctx>;
|
|
194
228
|
export {};
|
package/dist/tool.js
CHANGED
|
@@ -36,6 +36,14 @@ export function defineTool(definition) {
|
|
|
36
36
|
}
|
|
37
37
|
if (typeof definition.handler !== "function")
|
|
38
38
|
throw new Error(`${where}: handler is required.`);
|
|
39
|
+
if (definition.spends && definition.risk === "read")
|
|
40
|
+
throw new Error(`${where}: a read cannot spend; make it a write.`);
|
|
41
|
+
if (definition.riskFor !== undefined) {
|
|
42
|
+
if (typeof definition.riskFor !== "function")
|
|
43
|
+
throw new Error(`${where}: riskFor must be a function of the arguments.`);
|
|
44
|
+
if (definition.risk === "read")
|
|
45
|
+
throw new Error(`${where}: riskFor is for a write whose arguments decide how far it reaches; a read has nothing to decide.`);
|
|
46
|
+
}
|
|
39
47
|
const job = definition.job;
|
|
40
48
|
if (job) {
|
|
41
49
|
if (definition.name.length > 57)
|
|
@@ -75,7 +83,7 @@ export function defineTool(definition) {
|
|
|
75
83
|
if (own.includes(name))
|
|
76
84
|
throw new Error(`${where}: '${name}' is Slipway's own argument. Rename the input property.`);
|
|
77
85
|
}
|
|
78
|
-
const requireConfirm = definition.requireConfirm ?? definition.risk === "destructive";
|
|
86
|
+
const requireConfirm = definition.requireConfirm ?? (definition.risk === "destructive" || definition.spends === true);
|
|
79
87
|
const schema = withControls(input, {
|
|
80
88
|
confirm: requireConfirm,
|
|
81
89
|
...(job ? { wait: { defaultSeconds: waitSecondsFor(job), maxSeconds: MAX_WAIT_SECONDS } } : {}),
|
|
@@ -99,6 +107,9 @@ export function defineTool(definition) {
|
|
|
99
107
|
idempotent: definition.idempotent ?? definition.risk === "read",
|
|
100
108
|
openWorld: definition.openWorld ?? true,
|
|
101
109
|
requireConfirm,
|
|
110
|
+
spends: definition.spends === true,
|
|
111
|
+
...(definition.riskFor ? { riskFor: definition.riskFor } : {}),
|
|
112
|
+
...(definition.consequence?.trim() ? { consequence: definition.consequence.trim().replace(/\.$/, "") } : {}),
|
|
102
113
|
tags: Object.freeze([...(definition.tags ?? [])]),
|
|
103
114
|
examples: Object.freeze([...(definition.examples ?? [])]),
|
|
104
115
|
positional: Object.freeze([...(definition.positional ?? [])]),
|
|
@@ -149,3 +160,26 @@ export function summarize(tool, args) {
|
|
|
149
160
|
return fallback;
|
|
150
161
|
}
|
|
151
162
|
}
|
|
163
|
+
const REACH = { read: 0, write: 1, destructive: 2 };
|
|
164
|
+
/**
|
|
165
|
+
* The tool as one call sees it. With `riskFor`, the call's risk comes from its
|
|
166
|
+
* arguments, never above the declared one, and only a destructive call needs
|
|
167
|
+
* confirming. Like `summarize`, it runs before validation, so a `riskFor` that
|
|
168
|
+
* throws on odd arguments counts as the declared risk.
|
|
169
|
+
*/
|
|
170
|
+
export function forCall(tool, args) {
|
|
171
|
+
if (!tool.riskFor)
|
|
172
|
+
return tool;
|
|
173
|
+
let risk = tool.risk;
|
|
174
|
+
try {
|
|
175
|
+
const asked = tool.riskFor(args);
|
|
176
|
+
if (asked in REACH && REACH[asked] < REACH[tool.risk])
|
|
177
|
+
risk = asked;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// The declared risk is the safe answer.
|
|
181
|
+
}
|
|
182
|
+
if (risk === tool.risk)
|
|
183
|
+
return tool;
|
|
184
|
+
return { ...tool, risk, requireConfirm: tool.requireConfirm && (risk === "destructive" || tool.spends) };
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thenavidm/slipway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Slipway, the TypeScript framework for MCP servers and agent-native CLIs. One tool definition ships an MCP server and a CLI, with write safety, typed results and release checks built in.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|