@thenavidm/slipway 0.1.5 → 0.1.7
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 +19 -0
- package/README.md +14 -7
- package/dist/app.d.ts +57 -3
- package/dist/app.js +9 -7
- package/dist/check.js +10 -1
- package/dist/cli/completion.js +12 -10
- package/dist/cli/context.d.ts +10 -0
- package/dist/cli/context.js +13 -2
- package/dist/cli/data.js +1 -1
- package/dist/cli/help.js +12 -5
- package/dist/cli/run.js +23 -9
- package/dist/doctor.js +3 -2
- package/dist/guard.d.ts +1 -1
- package/dist/guard.js +2 -0
- package/dist/install.js +1 -1
- package/dist/schema.js +20 -3
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +19 -6
- package/dist/server.d.ts +1 -1
- package/dist/server.js +5 -2
- package/dist/tool.d.ts +25 -0
- package/dist/tool.js +31 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in Slipway, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.7, 2026-10-05: what Threads and ThriveCart needed, and what Substack and WordPress will
|
|
6
|
+
|
|
7
|
+
- **`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.
|
|
8
|
+
- **`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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **`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.
|
|
11
|
+
- **`<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.
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
14
|
+
## 0.1.6, 2026-10-05: what Mastodon's move needed
|
|
15
|
+
|
|
16
|
+
- **`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.
|
|
17
|
+
- **Terminal commands beside the tools.** `commands` adds commands such as `logout` to the CLI only. Each is listed in help, `agent-context` and tab completion, `slipway check` fails one named like a built-in or a tool, and an MCP client never sees it.
|
|
18
|
+
- **Tuning stays out of the way.** A setting marked `tuning: true`, such as a timeout with a working default, is named on one line of help and left out of what `install` writes. On Mastodon, `install claude-code` told people to set 13 variables and now names the 6 that connect an account, and the general help is 64 tokens shorter.
|
|
19
|
+
- **`httpPort` sets the default port for `--http`.** A server that shipped another default, such as 8000, keeps it after the move; `<PREFIX>_HTTP_PORT` and `--port` still override it.
|
|
20
|
+
- **`doctorNetwork` calls the service on every `doctor`.** Doctor stays local unless `--network` is passed, which keeps it quick and spends no requests. Mastodon's most common failure is a token without the `write` scope, which only a request finds, and its docs have always told people to run plain `doctor` for it.
|
|
21
|
+
- **The MCP binary's help names the CLI binary for the command list.** `mastodon-mcp --help` said a bare `mastodon-mcp` lists the commands, when it starts the server. Every hint that says where to list the commands now names the CLI binary.
|
|
22
|
+
- **Advertised schemas leave out Zod 4's safe-integer bounds.** Zod 4 gives every whole number `maximum: 9007199254740991` and its negative unless the schema sets its own. They tell a client nothing, so they are left out of what it receives; three were in Mastodon's tool list. Validation still runs on the full schema.
|
|
23
|
+
|
|
5
24
|
## 0.1.5, 2026-10-04: what Bluesky's move found
|
|
6
25
|
|
|
7
26
|
- **A request that never got an answer exits 5.** A failed fetch, a refused connection or a DNS failure mapped to exit 1, "unexpected error", so a script that retries on 5 gave up instead. Bluesky 1.2.3 exited 5 for an unreachable host, 0.1.4 made it 1, and it is 5 again.
|
package/README.md
CHANGED
|
@@ -213,6 +213,8 @@ 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
|
+
| `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
218
|
| `idempotent`, `openWorld` | Annotation hints. Reads are idempotent by default; every tool is open world unless it never leaves the machine |
|
|
217
219
|
| `tags` | Toolsets this tool belongs to. A tool with no tags is always on |
|
|
218
220
|
| `summary` | One line for the refusal message and the audit log: "delete note 7" |
|
|
@@ -388,10 +390,11 @@ Override any operation's name or risk with `names` and `risk`, keep a subset wit
|
|
|
388
390
|
| `<cli> which <words>` | Find the command for a task, by what it does |
|
|
389
391
|
| `<cli> schema <command>` | The JSON Schema an MCP client receives. `--output` for the result's |
|
|
390
392
|
| `<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
|
-
| `<cli> doctor` | Check the setup. `--network` also calls the service |
|
|
392
|
-
| `<cli> login` | How to connect an account |
|
|
393
|
+
| `<cli> doctor` | Check the setup. `--network` also calls the service, which an app with `doctorNetwork` does every time |
|
|
394
|
+
| `<cli> login` | How to connect an account: printed steps, or the app's own sign-in flow with the words after `login` |
|
|
393
395
|
| `<cli> install <client>` | Add the MCP server to a client. See [Add it to a client](#10-add-it-to-a-client) |
|
|
394
396
|
| `<cli> data` | The local cache and synced lists: `sync`, `search`, `sql`, `clear` |
|
|
397
|
+
| `<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 |
|
|
395
398
|
| `<cli> completion bash` | Tab completion for bash, zsh or fish |
|
|
396
399
|
|
|
397
400
|
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.
|
|
@@ -426,10 +429,12 @@ Errors are JSON on stderr, always, with `error`, `code` and a `hint` that names
|
|
|
426
429
|
| Run | Serves |
|
|
427
430
|
|---|---|
|
|
428
431
|
| `<mcp>` | MCP over stdio, what a client launches |
|
|
429
|
-
| `<mcp> --http [--port 8787]` | Streamable HTTP at `/mcp`, with `/health` |
|
|
432
|
+
| `<mcp> --http [--port 8787]` | Streamable HTTP at `/mcp`, with `/health`. The app's `httpPort` replaces 8787, for a server that shipped another default |
|
|
430
433
|
|
|
431
434
|
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.
|
|
432
435
|
|
|
436
|
+
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.
|
|
437
|
+
|
|
433
438
|
Resources and prompts are optional and take a few lines each:
|
|
434
439
|
|
|
435
440
|
```ts
|
|
@@ -457,7 +462,7 @@ notes-cli install cursor --dry-run
|
|
|
457
462
|
| `vscode` | `.vscode/mcp.json` | VS Code asks for each credential once and stores it securely |
|
|
458
463
|
| `gemini` | `~/.gemini/settings.json`, or `.gemini/settings.json` | `${NAME}` references, which Gemini CLI needs to pass anything named like a key |
|
|
459
464
|
|
|
460
|
-
A published server is started with `npx --package=<package>@latest <name>-mcp`, so a client picks up every release on its next start, with Codex's startup timeout raised for the download. The binary is named, because npx alone starts whichever binary a package lists first. Without `package`, or with `--local`, the client starts this copy on disk. Installing again updates the entry in place: anything you added to it by hand stays, and the old file is kept as a backup.
|
|
465
|
+
A published server is started with `npx --package=<package>@latest <name>-mcp`, so a client picks up every release on its next start, with Codex's startup timeout raised for the download. The binary is named, because npx alone starts whichever binary a package lists first. Without `package`, or with `--local`, the client starts this copy on disk. Installing again updates the entry in place: anything you added to it by hand stays, and the old file is kept as a backup. A setting marked `tuning: true`, such as a timeout with a working default, stays out of the entry, so it carries only what connects an account.
|
|
461
466
|
|
|
462
467
|
## 11. Large catalogs
|
|
463
468
|
|
|
@@ -539,7 +544,7 @@ const mcp = await connect(app, { era: "modern", elicit: () => ({ action: "accept
|
|
|
539
544
|
|
|
540
545
|
## Environment variables
|
|
541
546
|
|
|
542
|
-
Every server reads these, under its own prefix: the app name in capitals, `NOTES` for `notes`, unless `envPrefix` says otherwise. A server's own settings, declared with `settings`, are listed in its help, its `agent-context` and its generated docs
|
|
547
|
+
Every server reads these, under its own prefix: the app name in capitals, `NOTES` for `notes`, unless `envPrefix` says otherwise. A server's own settings, declared with `settings`, are listed in its help, its `agent-context` and its generated docs, and `install` passes on every one not marked `tuning`.
|
|
543
548
|
|
|
544
549
|
| Variable | Default | What it does |
|
|
545
550
|
|---|---|---|
|
|
@@ -549,10 +554,10 @@ Every server reads these, under its own prefix: the app name in capitals, `NOTES
|
|
|
549
554
|
| `<PREFIX>_CONFIRM` | `human` | `model` lets `confirm: true` alone confirm, for an agent with no person to ask |
|
|
550
555
|
| `<PREFIX>_CACHE` | `1` | `0` never answers from the local cache |
|
|
551
556
|
| `<PREFIX>_DATA_DIR` | the system's data folder | Where the local data file lives |
|
|
552
|
-
| `<PREFIX>_TOOLSETS` | `all` | Comma-separated toolsets to turn on |
|
|
557
|
+
| `<PREFIX>_TOOLSETS` | `all` | Comma-separated toolsets to turn on. Listed only when some tool has a toolset |
|
|
553
558
|
| `<PREFIX>_SURFACE` | `full` | `search` lists three tools that find, describe and run the rest |
|
|
554
559
|
| `<PREFIX>_TOOL_TIMEOUT_MS` | none | Give up on any tool after this long |
|
|
555
|
-
| `<PREFIX>_HTTP_PORT` | `8787` | For `--http` |
|
|
560
|
+
| `<PREFIX>_HTTP_PORT` | `8787`, or the app's `httpPort` | For `--http` |
|
|
556
561
|
| `<PREFIX>_HTTP_HOST` | `127.0.0.1` | For `--http`. Any other address needs a token |
|
|
557
562
|
| `<PREFIX>_HTTP_TOKEN` | none | Bearer token required by `--http` |
|
|
558
563
|
| `<PREFIX>_DEBUG` | `0` | `1` prints debug lines on stderr |
|
|
@@ -565,6 +570,8 @@ See [CHANGELOG.md](CHANGELOG.md).
|
|
|
565
570
|
|
|
566
571
|
| Server | Package | Covers |
|
|
567
572
|
| --- | --- | --- |
|
|
573
|
+
| [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 |
|
|
574
|
+
| [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 |
|
|
568
575
|
| [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 |
|
|
569
576
|
|
|
570
577
|
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.
|
package/dist/app.d.ts
CHANGED
|
@@ -48,6 +48,13 @@ export type ServiceSetting = {
|
|
|
48
48
|
description: string;
|
|
49
49
|
/** A credential: shown as set or unset, never printed. */
|
|
50
50
|
secret?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Tuning with a working default, such as a timeout or a retry count, or a
|
|
53
|
+
* second name for another setting. Listed in help and `agent-context`, but
|
|
54
|
+
* `install` leaves it out, so a client entry and its instructions hold only
|
|
55
|
+
* what connects an account.
|
|
56
|
+
*/
|
|
57
|
+
tuning?: boolean;
|
|
51
58
|
};
|
|
52
59
|
export type CliIO = {
|
|
53
60
|
stdout: (text: string) => void;
|
|
@@ -62,6 +69,18 @@ export type CliIO = {
|
|
|
62
69
|
/** The folder a project-scoped `install` writes into. Defaults to the current one. */
|
|
63
70
|
cwd?: string;
|
|
64
71
|
};
|
|
72
|
+
/** An interactive sign-in. It gets the words after `login` and returns an exit code. */
|
|
73
|
+
export type LoginFlow = (io: CliIO, args: string[]) => number | Promise<number>;
|
|
74
|
+
/** A terminal-only command an app adds beside its tools. */
|
|
75
|
+
export type CliCommand = {
|
|
76
|
+
/** The word typed after the binary: `logout`. Must not be a tool's command or a built-in. */
|
|
77
|
+
name: string;
|
|
78
|
+
/** What it takes, as help shows it: `logout [<handle>]`. Defaults to the name. */
|
|
79
|
+
usage?: string;
|
|
80
|
+
/** One line: what it does. */
|
|
81
|
+
help: string;
|
|
82
|
+
run: (io: CliIO, args: string[]) => number | Promise<number>;
|
|
83
|
+
};
|
|
65
84
|
export type AppDefinition<Ctx> = {
|
|
66
85
|
/** The service slug: "bluesky". Binaries default to bluesky-mcp and bluesky-cli. */
|
|
67
86
|
name: string;
|
|
@@ -83,6 +102,12 @@ export type AppDefinition<Ctx> = {
|
|
|
83
102
|
};
|
|
84
103
|
/** The npm package that ships the binaries, so `install` can have a client start it with npx. */
|
|
85
104
|
package?: string;
|
|
105
|
+
/**
|
|
106
|
+
* The port `--http` listens on when neither `--port` nor `<PREFIX>_HTTP_PORT`
|
|
107
|
+
* names one. 8787 when unset; a server that already shipped another default
|
|
108
|
+
* keeps it here.
|
|
109
|
+
*/
|
|
110
|
+
httpPort?: number;
|
|
86
111
|
/**
|
|
87
112
|
* Builds what handlers need: an API client, config, accounts. Called once,
|
|
88
113
|
* on the first call that needs it, so `--help` works with nothing configured.
|
|
@@ -93,12 +118,41 @@ export type AppDefinition<Ctx> = {
|
|
|
93
118
|
prompts?: readonly PromptDefinition<Ctx>[];
|
|
94
119
|
/** Whether any credentials are set. False makes `doctor` exit 10 and the server warn at startup. */
|
|
95
120
|
configured?: (ctx: Ctx) => boolean | Promise<boolean>;
|
|
96
|
-
/** Service checks for `doctor`. `network` is true only when the person passed --network. */
|
|
121
|
+
/** Service checks for `doctor`. `network` is true only when the person passed --network, or `doctorNetwork` is set. */
|
|
97
122
|
doctor?: (ctx: Ctx, options: {
|
|
98
123
|
network: boolean;
|
|
99
124
|
}) => DoctorCheck[] | Promise<DoctorCheck[]>;
|
|
100
|
-
/**
|
|
101
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Call the service on every `doctor`, not only with --network. Off by
|
|
127
|
+
* default, so doctor stays quick and spends no requests; on for a service
|
|
128
|
+
* whose most common failure only a request finds, such as a token missing
|
|
129
|
+
* a scope.
|
|
130
|
+
*/
|
|
131
|
+
doctorNetwork?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Runs once the server is answering, over stdio or HTTP, and never for a CLI
|
|
134
|
+
* command. For work that belongs to a running server, such as a queue that
|
|
135
|
+
* publishes on time, or a warning such as a token about to expire. It gets
|
|
136
|
+
* the context and the server's stderr logger; a throw is logged, never fatal.
|
|
137
|
+
*/
|
|
138
|
+
onServe?: (ctx: Ctx, log: Logger) => void | Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* How to sign in: printed instructions, or an interactive flow that returns an
|
|
141
|
+
* exit code. A flow gets the words after `login`: `mastodon-cli login mastodon.social`.
|
|
142
|
+
* A flow given with `usage` and `help` shows them in help, `login --help` and
|
|
143
|
+
* `agent-context`, so nobody has to guess that it takes an instance.
|
|
144
|
+
*/
|
|
145
|
+
login?: string | LoginFlow | {
|
|
146
|
+
usage?: string;
|
|
147
|
+
help: string;
|
|
148
|
+
run: LoginFlow;
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Terminal commands beyond the tools, such as `logout`, `auth` or `refresh`.
|
|
152
|
+
* An MCP client never sees them. Each is listed in help and `agent-context`,
|
|
153
|
+
* and gets the words after its name.
|
|
154
|
+
*/
|
|
155
|
+
commands?: readonly CliCommand[];
|
|
102
156
|
/** Values to mask in every result: API keys, tokens. */
|
|
103
157
|
secrets?: (ctx: Ctx) => Array<string | undefined | null>;
|
|
104
158
|
/**
|
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
|
@@ -50,6 +50,15 @@ export async function checkApp(app, options = {}) {
|
|
|
50
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
51
|
let totalBytes = 0;
|
|
52
52
|
let largest;
|
|
53
|
+
for (const command of app.definition.commands ?? []) {
|
|
54
|
+
if (BUILTINS.includes(command.name) || app.find(command.name)) {
|
|
55
|
+
add("error", "names", `The terminal command '${command.name}' has the name of a built-in or a tool. Rename it.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const httpPort = app.definition.httpPort;
|
|
59
|
+
if (httpPort !== undefined && (!Number.isInteger(httpPort) || httpPort < 1 || httpPort > 65535)) {
|
|
60
|
+
add("error", "http", `httpPort is ${httpPort}, which is not a port number from 1 to 65535.`);
|
|
61
|
+
}
|
|
53
62
|
for (const tool of app.allTools) {
|
|
54
63
|
if (BUILTINS.includes(tool.command)) {
|
|
55
64
|
add("error", "names", `'${tool.command}' is a built-in CLI command. Rename the tool.`, tool.name);
|
|
@@ -288,7 +297,7 @@ function checkDocs(app, file, add) {
|
|
|
288
297
|
// The match may begin on the character before the binary, a newline included.
|
|
289
298
|
const at = segment.offset + (match.index ?? 0) + (match[0].length - match[0].trimStart().length);
|
|
290
299
|
const line = text.slice(0, at).split("\n").length;
|
|
291
|
-
if (BUILTINS.includes(command))
|
|
300
|
+
if (BUILTINS.includes(command) || app.definition.commands?.some((custom) => custom.name === command))
|
|
292
301
|
continue;
|
|
293
302
|
const tool = app.find(command);
|
|
294
303
|
if (!tool) {
|
package/dist/cli/completion.js
CHANGED
|
@@ -10,9 +10,9 @@ function globalFlags() {
|
|
|
10
10
|
function toolFlags(tool) {
|
|
11
11
|
return flagsFor(tool.jsonSchema).map((flag) => flag.flag);
|
|
12
12
|
}
|
|
13
|
-
function bash(bin, tools) {
|
|
13
|
+
function bash(bin, tools, words) {
|
|
14
14
|
const fn = `_${bin.replace(/[^A-Za-z0-9]/g, "_")}`;
|
|
15
|
-
const commands = [...tools.map((tool) => tool.command), ...
|
|
15
|
+
const commands = [...tools.map((tool) => tool.command), ...words].join(" ");
|
|
16
16
|
const globals = globalFlags().join(" ");
|
|
17
17
|
const cases = tools
|
|
18
18
|
.map((tool) => ` ${tool.command}) COMPREPLY=( $(compgen -W "${[...toolFlags(tool), ...(tool.paginate ? ["--all", "--max-items"] : [])].join(" ")} ${globals}" -- "$cur") ) ;;`)
|
|
@@ -35,16 +35,16 @@ ${cases}
|
|
|
35
35
|
complete -F ${fn} ${bin}
|
|
36
36
|
`;
|
|
37
37
|
}
|
|
38
|
-
function zsh(bin, tools) {
|
|
38
|
+
function zsh(bin, tools, words) {
|
|
39
39
|
return `#compdef ${bin}
|
|
40
40
|
# ${bin} completion for zsh, through zsh's bash compatibility layer.
|
|
41
41
|
autoload -U +X bashcompinit && bashcompinit
|
|
42
|
-
${bash(bin, tools)}`;
|
|
42
|
+
${bash(bin, tools, words)}`;
|
|
43
43
|
}
|
|
44
44
|
function fishEscape(text) {
|
|
45
45
|
return text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
46
46
|
}
|
|
47
|
-
function fish(bin, tools) {
|
|
47
|
+
function fish(bin, tools, words) {
|
|
48
48
|
const lines = [`# ${bin} completion for fish`, `complete -c ${bin} -f`];
|
|
49
49
|
for (const tool of tools) {
|
|
50
50
|
lines.push(`complete -c ${bin} -n '__fish_use_subcommand' -a '${tool.command}' -d '${fishEscape(tool.title)}'`);
|
|
@@ -52,20 +52,22 @@ function fish(bin, tools) {
|
|
|
52
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
53
|
}
|
|
54
54
|
}
|
|
55
|
-
for (const
|
|
56
|
-
lines.push(`complete -c ${bin} -n '__fish_use_subcommand' -a '${
|
|
55
|
+
for (const word of words)
|
|
56
|
+
lines.push(`complete -c ${bin} -n '__fish_use_subcommand' -a '${word}'`);
|
|
57
57
|
for (const flag of globalFlags())
|
|
58
58
|
lines.push(`complete -c ${bin} -l '${flag.slice(2)}'`);
|
|
59
59
|
return `${lines.join("\n")}\n`;
|
|
60
60
|
}
|
|
61
61
|
export function completionScript(app, shell, bin, env) {
|
|
62
62
|
const tools = app.tools(env);
|
|
63
|
+
// The built-ins, then any terminal commands the app adds, such as logout.
|
|
64
|
+
const words = [...BUILTINS, ...(app.definition.commands ?? []).map((command) => command.name)];
|
|
63
65
|
if (shell === "bash")
|
|
64
|
-
return bash(bin, tools);
|
|
66
|
+
return bash(bin, tools, words);
|
|
65
67
|
if (shell === "zsh")
|
|
66
|
-
return zsh(bin, tools);
|
|
68
|
+
return zsh(bin, tools, words);
|
|
67
69
|
if (shell === "fish")
|
|
68
|
-
return fish(bin, tools);
|
|
70
|
+
return fish(bin, tools, words);
|
|
69
71
|
throw new UsageError(`completion expects bash, zsh or fish${shell ? `, got '${shell}'` : ""}.`, {
|
|
70
72
|
hint: `Add \`source <(${bin} completion bash)\` to your shell's startup file.`,
|
|
71
73
|
});
|
package/dist/cli/context.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
|
|
|
24
24
|
exit_codes: Record<number, string>;
|
|
25
25
|
hidden_commands?: number | undefined;
|
|
26
26
|
toolsets?: Record<string, string> | undefined;
|
|
27
|
+
extra_commands?: {
|
|
28
|
+
command: string;
|
|
29
|
+
usage: string;
|
|
30
|
+
description: string;
|
|
31
|
+
}[] | undefined;
|
|
27
32
|
commands: {
|
|
28
33
|
command: string;
|
|
29
34
|
title: string;
|
|
@@ -79,6 +84,11 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
|
|
|
79
84
|
})[];
|
|
80
85
|
toolsets?: Record<string, string> | undefined;
|
|
81
86
|
hidden_commands: number;
|
|
87
|
+
extra_commands?: {
|
|
88
|
+
command: string;
|
|
89
|
+
usage: string;
|
|
90
|
+
description: string;
|
|
91
|
+
}[] | undefined;
|
|
82
92
|
commands: {
|
|
83
93
|
command: string;
|
|
84
94
|
tool: string;
|
package/dist/cli/context.js
CHANGED
|
@@ -28,6 +28,13 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
28
28
|
const names = policyEnvNames(app.envPrefix);
|
|
29
29
|
const tools = app.tools(env);
|
|
30
30
|
const hidden = app.allTools.length - tools.length;
|
|
31
|
+
// Terminal commands the app adds beside its tools, such as logout, and a sign-in flow that says what it takes.
|
|
32
|
+
const login = app.definition.login;
|
|
33
|
+
const extra = [...(typeof login === "object" ? [{ name: "login", ...login }] : []), ...(app.definition.commands ?? [])].map((command) => ({
|
|
34
|
+
command: command.name,
|
|
35
|
+
usage: `${bin} ${command.usage ?? command.name}`,
|
|
36
|
+
description: command.help,
|
|
37
|
+
}));
|
|
31
38
|
const note = "--agent never confirms a write. A command that requires --confirm runs only when it is passed explicitly.";
|
|
32
39
|
if (options.brief) {
|
|
33
40
|
// Enough to pick a command: what each one is, and which write or need --confirm. The full read adds flags and settings.
|
|
@@ -38,6 +45,7 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
38
45
|
usage: { run: `${bin} <command> [flags]`, help: `${bin} <command> --help`, note },
|
|
39
46
|
exit_codes: EXIT_MEANINGS,
|
|
40
47
|
...(hidden ? { hidden_commands: hidden, ...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}) } : {}),
|
|
48
|
+
...(extra.length ? { extra_commands: extra } : {}),
|
|
41
49
|
commands: tools.map((tool) => ({
|
|
42
50
|
command: tool.command,
|
|
43
51
|
title: tool.title,
|
|
@@ -71,18 +79,21 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
71
79
|
})),
|
|
72
80
|
{ env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" },
|
|
73
81
|
{ env: names.allowDestructive, value: policy.allowDestructive, description: "allow public or irreversible writes" },
|
|
74
|
-
|
|
82
|
+
...(app.allTools.some((tool) => tool.tags.length > 0)
|
|
83
|
+
? [{ env: names.toolsets, value: policy.toolsets === "all" ? "all" : [...policy.toolsets], description: "toolsets that are on" }]
|
|
84
|
+
: []),
|
|
75
85
|
{ env: names.surface, value: policy.surface, description: "full tool list, or search for very large catalogs" },
|
|
76
86
|
{ env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" },
|
|
77
87
|
{ env: names.toolTimeoutMs, value: policy.toolTimeoutMs ?? null, description: "deadline for any tool" },
|
|
78
88
|
{ 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" },
|
|
79
|
-
{ env: `${app.envPrefix}_HTTP_PORT`, value: env[`${app.envPrefix}_HTTP_PORT`] ?? null, description:
|
|
89
|
+
{ env: `${app.envPrefix}_HTTP_PORT`, value: env[`${app.envPrefix}_HTTP_PORT`] ?? null, description: `port for --http, ${app.definition.httpPort ?? 8787} when unset` },
|
|
80
90
|
{ 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" },
|
|
81
91
|
{ env: `${app.envPrefix}_HTTP_TOKEN`, set: Boolean(env[`${app.envPrefix}_HTTP_TOKEN`]), secret: true, description: "bearer token --http requires" },
|
|
82
92
|
{ env: `${app.envPrefix}_DEBUG`, value: /^(1|true|yes)$/i.test(env[`${app.envPrefix}_DEBUG`] ?? ""), description: "print debug lines on stderr" },
|
|
83
93
|
],
|
|
84
94
|
...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}),
|
|
85
95
|
hidden_commands: hidden,
|
|
96
|
+
...(extra.length ? { extra_commands: extra } : {}),
|
|
86
97
|
commands: tools.map((tool) => ({
|
|
87
98
|
command: tool.command,
|
|
88
99
|
tool: tool.name,
|
package/dist/cli/data.js
CHANGED
|
@@ -59,7 +59,7 @@ export async function runData(app, io, tokens, options) {
|
|
|
59
59
|
throw new UsageError("data sync expects the command to copy: data sync <command>.");
|
|
60
60
|
const tool = app.find(command);
|
|
61
61
|
if (!tool)
|
|
62
|
-
throw new UsageError(`Unknown command '${command}'.`, { hint: `Run \`${
|
|
62
|
+
throw new UsageError(`Unknown command '${command}'.`, { hint: `Run \`${app.bins.cli}\` to list commands.` });
|
|
63
63
|
if (!tool.sync) {
|
|
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"}.` });
|
package/dist/cli/help.js
CHANGED
|
@@ -191,11 +191,14 @@ export function renderGeneralHelp(app, bin) {
|
|
|
191
191
|
// An agent often reads this first and pays for it again on every later step, so the
|
|
192
192
|
// rarely needed commands share one line and Slipway's own settings say only what they do.
|
|
193
193
|
const commands = [
|
|
194
|
-
[
|
|
194
|
+
[app.bins.cli, "list the commands"],
|
|
195
195
|
[`${bin} <command> --help`, "what one takes, with examples"],
|
|
196
196
|
[`${bin} which <words>`, "find the command for a task"],
|
|
197
|
-
[`${bin} doctor [--network]`, "check the setup and say what is wrong"],
|
|
198
|
-
|
|
197
|
+
[`${bin} doctor${app.definition.doctorNetwork ? "" : " [--network]"}`, "check the setup and say what is wrong"],
|
|
198
|
+
typeof app.definition.login === "object"
|
|
199
|
+
? [`${bin} ${app.definition.login.usage ?? "login"}`, app.definition.login.help]
|
|
200
|
+
: [`${bin} login`, "how to connect an account"],
|
|
201
|
+
...(app.definition.commands ?? []).map((command) => [`${bin} ${command.usage ?? command.name}`, command.help]),
|
|
199
202
|
[`${bin} install <client>`, "add the server to an MCP client; install --help lists them"],
|
|
200
203
|
...(cache || sync ? [[`${bin} data`, "what is kept on this machine; data clear [<command>] deletes it"]] : []),
|
|
201
204
|
...(sync
|
|
@@ -207,11 +210,14 @@ export function renderGeneralHelp(app, bin) {
|
|
|
207
210
|
: []),
|
|
208
211
|
[app.bins.mcp, "the MCP server over stdio; --http [--port N] for HTTP"],
|
|
209
212
|
];
|
|
213
|
+
// Tuning keeps a working default, so it is named on one line; agent-context says what each does.
|
|
214
|
+
const tuning = (app.definition.settings ?? []).filter((setting) => setting.tuning).map((setting) => setting.env);
|
|
210
215
|
const settings = [
|
|
211
|
-
...(app.definition.settings ?? []).map((setting) => [setting.env, setting.description]),
|
|
216
|
+
...(app.definition.settings ?? []).filter((setting) => !setting.tuning).map((setting) => [setting.env, setting.description]),
|
|
212
217
|
[`${names.readOnly}=1`, "hide and refuse every write"],
|
|
213
218
|
[`${names.allowDestructive}=0`, "refuse the irreversible writes"],
|
|
214
|
-
|
|
219
|
+
// With no tagged tool every tool is always on, so the switch would do nothing.
|
|
220
|
+
...(app.allTools.some((tool) => tool.tags.length > 0) ? [[`${names.toolsets}=a,b`, "only these toolsets, or all"]] : []),
|
|
215
221
|
[`${names.surface}=search`, "MCP lists three finder tools instead"],
|
|
216
222
|
[`${names.auditLog}=<file>`, "log every attempted write"],
|
|
217
223
|
[`${names.toolTimeoutMs}=<ms>`, "deadline for any tool"],
|
|
@@ -236,6 +242,7 @@ export function renderGeneralHelp(app, bin) {
|
|
|
236
242
|
``,
|
|
237
243
|
`Settings:`,
|
|
238
244
|
...settings.map(row),
|
|
245
|
+
...(tuning.length ? [` Also: ${tuning.join(", ")}, described in agent-context.`] : []),
|
|
239
246
|
``,
|
|
240
247
|
`Exit codes: ${EXIT.ok} ok, ${EXIT.error} unexpected, ${EXIT.usage} usage or refused, ${EXIT.notFound} not found, ${EXIT.auth} auth, ${EXIT.api} API, ${EXIT.rateLimited} rate limited, ${EXIT.notConfigured} not configured`,
|
|
241
248
|
``,
|
package/dist/cli/run.js
CHANGED
|
@@ -140,7 +140,8 @@ export async function runCli(app, argv, partial = {}) {
|
|
|
140
140
|
const at = findCommand(argv);
|
|
141
141
|
const command = at === -1 ? undefined : argv[at];
|
|
142
142
|
const builtin = command !== undefined && BUILTINS.includes(command);
|
|
143
|
-
const
|
|
143
|
+
const custom = command !== undefined && !builtin ? app.definition.commands?.find((candidate) => candidate.name === command) : undefined;
|
|
144
|
+
const tool = command !== undefined && !builtin && !custom ? app.find(command) : undefined;
|
|
144
145
|
const reserved = new Set(tool ? flagsFor(tool.jsonSchema).map((flag) => flag.flag) : []);
|
|
145
146
|
let agent = argv.includes("--agent");
|
|
146
147
|
try {
|
|
@@ -155,11 +156,16 @@ export async function runCli(app, argv, partial = {}) {
|
|
|
155
156
|
}
|
|
156
157
|
if (builtin)
|
|
157
158
|
return await runBuiltin(app, io, command, rest, globals);
|
|
159
|
+
if (custom) {
|
|
160
|
+
if (globals.help)
|
|
161
|
+
return print(io, `\nUsage: ${io.bin} ${custom.usage ?? custom.name}\n\n${custom.help}\n`);
|
|
162
|
+
return await custom.run(io, rest);
|
|
163
|
+
}
|
|
158
164
|
if (!tool) {
|
|
159
|
-
const candidates = [...app.tools(io.env).map((t) => t.command), ...BUILTINS];
|
|
165
|
+
const candidates = [...app.tools(io.env).map((t) => t.command), ...BUILTINS, ...(app.definition.commands ?? []).map((c) => c.name)];
|
|
160
166
|
const guess = didYouMean(command, candidates);
|
|
161
167
|
throw new UsageError(`Unknown command '${command}'.${guess ? ` Did you mean '${guess}'?` : ""}`, {
|
|
162
|
-
hint: `Run \`${
|
|
168
|
+
hint: `Run \`${app.bins.cli}\` to list commands, or \`${io.bin} which <words>\` to find one.`,
|
|
163
169
|
});
|
|
164
170
|
}
|
|
165
171
|
const seen = visibility(tool, app.policy(io.env));
|
|
@@ -196,6 +202,9 @@ async function runBuiltin(app, io, command, rest, globals) {
|
|
|
196
202
|
// `<built-in> --help` explains the command instead of running it.
|
|
197
203
|
if (globals.help && command === "install")
|
|
198
204
|
return print(io, (await import("./install.js")).installHelp(app, io.bin));
|
|
205
|
+
const login = app.definition.login;
|
|
206
|
+
if (globals.help && command === "login" && typeof login === "object")
|
|
207
|
+
return print(io, `\nUsage: ${io.bin} ${login.usage ?? "login"}\n\n${login.help}\n`);
|
|
199
208
|
if (globals.help && command !== "help")
|
|
200
209
|
return print(io, renderGeneralHelp(app, io.bin));
|
|
201
210
|
const target = rest.find((token) => !token.startsWith("-"));
|
|
@@ -209,13 +218,13 @@ async function runBuiltin(app, io, command, rest, globals) {
|
|
|
209
218
|
return print(io, renderGeneralHelp(app, io.bin));
|
|
210
219
|
const tool = app.find(target);
|
|
211
220
|
if (!tool)
|
|
212
|
-
throw new UsageError(`Unknown command '${target}'.`, { hint: `Run \`${
|
|
221
|
+
throw new UsageError(`Unknown command '${target}'.`, { hint: `Run \`${app.bins.cli}\` to list commands.` });
|
|
213
222
|
return print(io, renderToolHelp(tool, io.bin));
|
|
214
223
|
}
|
|
215
224
|
case "schema": {
|
|
216
225
|
const tool = target ? app.find(target) : undefined;
|
|
217
226
|
if (!tool)
|
|
218
|
-
throw new UsageError(`schema expects a command${target ? `; '${target}' is not one` : ""}.`, { hint: `Run \`${
|
|
227
|
+
throw new UsageError(`schema expects a command${target ? `; '${target}' is not one` : ""}.`, { hint: `Run \`${app.bins.cli}\` to list commands.` });
|
|
219
228
|
if (rest.includes("--output")) {
|
|
220
229
|
if (!tool.output)
|
|
221
230
|
throw new UsageError(`${tool.command} declares no output schema.`);
|
|
@@ -229,20 +238,25 @@ async function runBuiltin(app, io, command, rest, globals) {
|
|
|
229
238
|
const query = rest.filter((token) => !token.startsWith("-")).join(" ");
|
|
230
239
|
if (!query)
|
|
231
240
|
throw new UsageError("which expects the words for what you want to do: which schedule a post");
|
|
232
|
-
|
|
241
|
+
// Only the close matches: on Threads the right command scored 20 and the eighth 9, and the
|
|
242
|
+
// ten-line list cost an agent more to read than the answer was worth.
|
|
243
|
+
const found = searchTools(app.tools(io.env), query, 10);
|
|
244
|
+
const best = found[0]?.score ?? 0;
|
|
245
|
+
const matches = found.filter(({ score }, index) => index < 3 || score >= best / 2);
|
|
233
246
|
if (globals.format !== "auto") {
|
|
234
247
|
return print(io, json(globals, matches.map(({ tool, score }) => ({ command: tool.command, title: tool.title, risk: tool.risk, score: Number(score.toFixed(2)) }))));
|
|
235
248
|
}
|
|
236
249
|
if (!matches.length)
|
|
237
|
-
return print(io, `No command matches '${query}'. Run \`${
|
|
250
|
+
return print(io, `No command matches '${query}'. Run \`${app.bins.cli}\` to see them all.`);
|
|
238
251
|
return print(io, matches.map(({ tool }) => toolLine(tool)).join("\n"));
|
|
239
252
|
}
|
|
240
253
|
case "doctor":
|
|
241
254
|
return runDoctor(app, io, { network: rest.includes("--network"), json: globals.format !== "auto" });
|
|
242
255
|
case "login": {
|
|
243
|
-
const login = app.definition.login;
|
|
244
256
|
if (typeof login === "function")
|
|
245
|
-
return await login(io);
|
|
257
|
+
return await login(io, rest);
|
|
258
|
+
if (typeof login === "object")
|
|
259
|
+
return await login.run(io, rest);
|
|
246
260
|
if (typeof login === "string")
|
|
247
261
|
return print(io, login);
|
|
248
262
|
return print(io, `${app.title} reads its credentials from the environment. Run \`${io.bin} doctor\` to see what is missing.`);
|
package/dist/doctor.js
CHANGED
|
@@ -68,15 +68,16 @@ export async function runDoctor(app, io, options) {
|
|
|
68
68
|
...(configured ? {} : { fix: `Run \`${app.bins.cli} login\` to see how to connect an account.` }),
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
+
const network = options.network || app.definition.doctorNetwork === true;
|
|
71
72
|
if (ctx !== undefined && app.definition.doctor) {
|
|
72
73
|
try {
|
|
73
|
-
checks.push(...(await app.definition.doctor(ctx, { network
|
|
74
|
+
checks.push(...(await app.definition.doctor(ctx, { network })));
|
|
74
75
|
}
|
|
75
76
|
catch (error) {
|
|
76
77
|
checks.push({ name: "Service check", ok: false, detail: app.secrets.redact(error?.message ?? String(error)) });
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
|
-
if (!
|
|
80
|
+
if (!network && app.definition.doctor) {
|
|
80
81
|
checks.push({ name: "Network", ok: true, warn: true, detail: "not checked; run with --network to call the service" });
|
|
81
82
|
}
|
|
82
83
|
const failed = checks.filter((check) => !check.ok && !check.warn);
|
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">): string;
|
package/dist/guard.js
CHANGED
|
@@ -85,5 +85,7 @@ 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;
|
|
88
90
|
return tool.risk === "destructive" ? "is public or cannot be undone" : "has an effect that cannot be taken back";
|
|
89
91
|
}
|
package/dist/install.js
CHANGED
|
@@ -207,7 +207,7 @@ export function planInstall(app, options, context) {
|
|
|
207
207
|
}
|
|
208
208
|
const launch = launchFor(app, { local: options.local, ...(context.entry ? { entry: context.entry } : {}), ...(context.platform ? { platform: context.platform } : {}) });
|
|
209
209
|
const usesNpx = launch.command === "npx" || launch.args.includes("npx");
|
|
210
|
-
const settings = app.definition.settings ?? [];
|
|
210
|
+
const settings = (app.definition.settings ?? []).filter((setting) => !setting.tuning);
|
|
211
211
|
const variables = settings.map((setting) => setting.env);
|
|
212
212
|
const env = { forwarded: [], copied: [], toAdd: [] };
|
|
213
213
|
const notes = [];
|
package/dist/schema.js
CHANGED
|
@@ -83,6 +83,25 @@ export const CONTROL_NAMES = ["confirm", "wait_seconds"];
|
|
|
83
83
|
export const CONFIRM_DESCRIPTION = "Set true only when the user asked for exactly this action.";
|
|
84
84
|
/** The schema already carries the range and the default, so the words only say what the number is for. */
|
|
85
85
|
export const WAIT_DESCRIPTION = "Seconds to wait for the job to finish before returning it to check later.";
|
|
86
|
+
/**
|
|
87
|
+
* Zod 4 gives every whole number the safe-integer bounds, `maximum:
|
|
88
|
+
* 9007199254740991` and its negative, unless the schema sets its own. They say
|
|
89
|
+
* nothing a client can use, so they are left out of what it receives.
|
|
90
|
+
* Validation still runs on the schema itself.
|
|
91
|
+
*/
|
|
92
|
+
function withoutSafeIntegerBounds(node) {
|
|
93
|
+
if (Array.isArray(node))
|
|
94
|
+
return node.map(withoutSafeIntegerBounds);
|
|
95
|
+
if (node === null || typeof node !== "object")
|
|
96
|
+
return node;
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [key, value] of Object.entries(node)) {
|
|
99
|
+
if ((key === "maximum" && value === Number.MAX_SAFE_INTEGER) || (key === "minimum" && value === Number.MIN_SAFE_INTEGER))
|
|
100
|
+
continue;
|
|
101
|
+
out[key] = withoutSafeIntegerBounds(value);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
86
105
|
/**
|
|
87
106
|
* A schema as clients receive it, without the `$schema` line that names its
|
|
88
107
|
* dialect. A client reads JSON Schema 2020-12 when no dialect is named, so
|
|
@@ -91,10 +110,8 @@ export const WAIT_DESCRIPTION = "Seconds to wait for the job to finish before re
|
|
|
91
110
|
export function advertised(schema) {
|
|
92
111
|
const std = schema["~standard"];
|
|
93
112
|
const plain = (json) => {
|
|
94
|
-
if (!("$schema" in json))
|
|
95
|
-
return json;
|
|
96
113
|
const { $schema: _dialect, ...rest } = json;
|
|
97
|
-
return rest;
|
|
114
|
+
return withoutSafeIntegerBounds(rest);
|
|
98
115
|
};
|
|
99
116
|
return {
|
|
100
117
|
"~standard": {
|
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,6 +12,11 @@ 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;
|
package/dist/serve.js
CHANGED
|
@@ -27,23 +27,35 @@ 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) {
|
|
44
56
|
const at = argv.findIndex((token) => token === "--port" || token.startsWith("--port="));
|
|
45
57
|
const raw = at === -1 ? env[`${app.envPrefix}_HTTP_PORT`] : argv[at].includes("=") ? argv[at].split("=")[1] : argv[at + 1];
|
|
46
|
-
const port = Number(raw ?? 8787);
|
|
58
|
+
const port = Number(raw ?? app.definition.httpPort ?? 8787);
|
|
47
59
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
48
60
|
throw new UsageError(`--port expects a port number, got '${raw}'.`);
|
|
49
61
|
return {
|
|
@@ -83,6 +95,7 @@ export async function serveHttpApp(app, env, options) {
|
|
|
83
95
|
const port = address && typeof address === "object" ? address.port : options.port;
|
|
84
96
|
const url = `http://${options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host}:${port}/mcp`;
|
|
85
97
|
log.info(`listening on ${url}${options.token ? " (bearer token required)" : ""}`);
|
|
98
|
+
void afterStart(app, env, log);
|
|
86
99
|
return {
|
|
87
100
|
url,
|
|
88
101
|
close: async () => {
|
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) {
|
package/dist/tool.d.ts
CHANGED
|
@@ -103,6 +103,20 @@ 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
|
+
* What a confirmed call does that cannot be taken back, as the refusal and
|
|
116
|
+
* the approval form put it: "moves money and cannot be undone". Defaults to
|
|
117
|
+
* "is public or cannot be undone" for a destructive tool.
|
|
118
|
+
*/
|
|
119
|
+
consequence?: string;
|
|
106
120
|
/** Toolsets this tool belongs to. A tool with no tags is always on. */
|
|
107
121
|
tags?: string[];
|
|
108
122
|
/** One line for the audit log and the refusal message: "post 'Hello' as @alice". */
|
|
@@ -146,6 +160,10 @@ export type Tool<Ctx = any> = {
|
|
|
146
160
|
readonly idempotent: boolean;
|
|
147
161
|
readonly openWorld: boolean;
|
|
148
162
|
readonly requireConfirm: boolean;
|
|
163
|
+
/** The risk of one call, from its arguments. `forCall` applies it. */
|
|
164
|
+
readonly riskFor?: (args: any) => Risk;
|
|
165
|
+
/** What a confirmed call does that cannot be taken back, in the tool's own words. */
|
|
166
|
+
readonly consequence?: string;
|
|
149
167
|
readonly tags: readonly string[];
|
|
150
168
|
readonly examples: readonly ToolExample[];
|
|
151
169
|
readonly positional: readonly string[];
|
|
@@ -191,4 +209,11 @@ export declare function isTool(value: unknown): value is Tool;
|
|
|
191
209
|
* arguments have been anywhere near the handler.
|
|
192
210
|
*/
|
|
193
211
|
export declare function summarize(tool: Pick<Tool, "summary" | "title">, args: Record<string, unknown>): string;
|
|
212
|
+
/**
|
|
213
|
+
* The tool as one call sees it. With `riskFor`, the call's risk comes from its
|
|
214
|
+
* arguments, never above the declared one, and only a destructive call needs
|
|
215
|
+
* confirming. Like `summarize`, it runs before validation, so a `riskFor` that
|
|
216
|
+
* throws on odd arguments counts as the declared risk.
|
|
217
|
+
*/
|
|
218
|
+
export declare function forCall<Ctx>(tool: Tool<Ctx>, args: Record<string, unknown>): Tool<Ctx>;
|
|
194
219
|
export {};
|
package/dist/tool.js
CHANGED
|
@@ -36,6 +36,12 @@ 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.riskFor !== undefined) {
|
|
40
|
+
if (typeof definition.riskFor !== "function")
|
|
41
|
+
throw new Error(`${where}: riskFor must be a function of the arguments.`);
|
|
42
|
+
if (definition.risk === "read")
|
|
43
|
+
throw new Error(`${where}: riskFor is for a write whose arguments decide how far it reaches; a read has nothing to decide.`);
|
|
44
|
+
}
|
|
39
45
|
const job = definition.job;
|
|
40
46
|
if (job) {
|
|
41
47
|
if (definition.name.length > 57)
|
|
@@ -99,6 +105,8 @@ export function defineTool(definition) {
|
|
|
99
105
|
idempotent: definition.idempotent ?? definition.risk === "read",
|
|
100
106
|
openWorld: definition.openWorld ?? true,
|
|
101
107
|
requireConfirm,
|
|
108
|
+
...(definition.riskFor ? { riskFor: definition.riskFor } : {}),
|
|
109
|
+
...(definition.consequence?.trim() ? { consequence: definition.consequence.trim().replace(/\.$/, "") } : {}),
|
|
102
110
|
tags: Object.freeze([...(definition.tags ?? [])]),
|
|
103
111
|
examples: Object.freeze([...(definition.examples ?? [])]),
|
|
104
112
|
positional: Object.freeze([...(definition.positional ?? [])]),
|
|
@@ -149,3 +157,26 @@ export function summarize(tool, args) {
|
|
|
149
157
|
return fallback;
|
|
150
158
|
}
|
|
151
159
|
}
|
|
160
|
+
const REACH = { read: 0, write: 1, destructive: 2 };
|
|
161
|
+
/**
|
|
162
|
+
* The tool as one call sees it. With `riskFor`, the call's risk comes from its
|
|
163
|
+
* arguments, never above the declared one, and only a destructive call needs
|
|
164
|
+
* confirming. Like `summarize`, it runs before validation, so a `riskFor` that
|
|
165
|
+
* throws on odd arguments counts as the declared risk.
|
|
166
|
+
*/
|
|
167
|
+
export function forCall(tool, args) {
|
|
168
|
+
if (!tool.riskFor)
|
|
169
|
+
return tool;
|
|
170
|
+
let risk = tool.risk;
|
|
171
|
+
try {
|
|
172
|
+
const asked = tool.riskFor(args);
|
|
173
|
+
if (asked in REACH && REACH[asked] < REACH[tool.risk])
|
|
174
|
+
risk = asked;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// The declared risk is the safe answer.
|
|
178
|
+
}
|
|
179
|
+
if (risk === tool.risk)
|
|
180
|
+
return tool;
|
|
181
|
+
return { ...tool, risk, requireConfirm: tool.requireConfirm && risk === "destructive" };
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thenavidm/slipway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|