@better-fs-tools/shell 0.0.0-tegami-trusted-publish-setup → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -6
  3. package/dist/contract/context.d.ts +10 -0
  4. package/dist/contract/context.js +0 -0
  5. package/dist/contract/deps.d.ts +43 -0
  6. package/dist/contract/deps.js +0 -0
  7. package/dist/contract/extensions.d.ts +94 -0
  8. package/dist/contract/extensions.js +0 -0
  9. package/dist/contract/format.d.ts +22 -0
  10. package/dist/contract/format.js +0 -0
  11. package/dist/contract/input.d.ts +15 -0
  12. package/dist/contract/input.js +0 -0
  13. package/dist/contract/limits.d.ts +16 -0
  14. package/dist/contract/limits.js +0 -0
  15. package/dist/contract/messages.d.ts +69 -0
  16. package/dist/contract/messages.js +0 -0
  17. package/dist/contract/result.d.ts +77 -0
  18. package/dist/contract/result.js +0 -0
  19. package/dist/contract/runner.d.ts +55 -0
  20. package/dist/contract/runner.js +0 -0
  21. package/dist/core/abort.d.ts +12 -0
  22. package/dist/core/abort.js +41 -0
  23. package/dist/core/capture.d.ts +11 -0
  24. package/dist/core/capture.js +171 -0
  25. package/dist/core/create-bash-tool.d.ts +8 -0
  26. package/dist/core/create-bash-tool.js +18 -0
  27. package/dist/core/deps.d.ts +5 -0
  28. package/dist/core/deps.js +103 -0
  29. package/dist/core/env.d.ts +14 -0
  30. package/dist/core/env.js +32 -0
  31. package/dist/core/execute.d.ts +11 -0
  32. package/dist/core/execute.js +265 -0
  33. package/dist/core/input.d.ts +12 -0
  34. package/dist/core/input.js +46 -0
  35. package/dist/core/limits.d.ts +10 -0
  36. package/dist/core/limits.js +44 -0
  37. package/dist/core/messages.d.ts +12 -0
  38. package/dist/core/messages.js +94 -0
  39. package/dist/core/pipeline.d.ts +4 -0
  40. package/dist/core/pipeline.js +179 -0
  41. package/dist/core/stages.d.ts +20 -0
  42. package/dist/core/stages.js +166 -0
  43. package/dist/core/stop.d.ts +17 -0
  44. package/dist/core/stop.js +50 -0
  45. package/dist/formatters/default.d.ts +20 -0
  46. package/dist/formatters/default.js +77 -0
  47. package/dist/formatters/index.d.ts +2 -0
  48. package/dist/formatters/index.js +1 -0
  49. package/dist/index.d.ts +17 -0
  50. package/dist/index.js +7 -0
  51. package/dist/signature/bash.d.ts +28 -0
  52. package/dist/signature/bash.js +158 -0
  53. package/dist/signature/index.d.ts +3 -0
  54. package/dist/signature/index.js +2 -0
  55. package/docs/architecture.md +74 -0
  56. package/docs/result-schema.md +151 -0
  57. package/package.json +41 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gareth Andrew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,269 @@
1
- # Placeholder package
2
-
3
- This empty package was published by [Tegami](https://tegami.fuma-nama.dev) to configure npm trusted publishing.
4
-
5
- The real package contents will be published via CI with OIDC.
6
-
1
+ # @better-fs-tools/shell
2
+
3
+ A `bash` tool for TypeScript agents. It runs one command for each call and returns the exit code and a bounded view of the output.
4
+
5
+ The package has a small run core, hooks, and defaults. It ships no policy: no authorizers, no command parser, and no guards. A host adds those through the hooks.
6
+
7
+ The core always does these things:
8
+
9
+ - It runs one command for each call, with stdin closed.
10
+ - It stops the whole process tree on a timeout and on an abort.
11
+ - It uses one timeout unit, milliseconds, in the code.
12
+ - It keeps the output bounded in memory, and cuts the model view to a head and a tail with one line between them.
13
+ - It puts the exit code first in the model text.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npm install @better-fs-tools/shell @better-fs-tools/read @better-fs-tools/fs
19
+ ```
20
+
21
+ The package has no peers and imports no `node:` module. It starts no process by itself. A runner does that: `nodeCommandRunner()` from [`@better-fs-tools/node`](https://www.npmjs.com/package/@better-fs-tools/node), `justBashCommandRunner()` from [`@better-fs-tools/just-bash`](https://www.npmjs.com/package/@better-fs-tools/just-bash), or your own.
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import { createNodeBashTool } from "@better-fs-tools/node";
27
+ import { textOf } from "@better-fs-tools/shell";
28
+
29
+ // bash -c in process.cwd(), with process.env, a 2 minute timeout, and a
30
+ // 30 000 byte view of the output.
31
+ const bash = createNodeBashTool();
32
+
33
+ const listed = await bash({ command: "ls package.json" });
34
+ console.log(textOf(listed));
35
+ // Exit code 0 · 0 s
36
+ // package.json
37
+
38
+ const failed = await bash({ command: "grep -q nothing-here package.json" });
39
+ if (failed.status === "failed") console.log(failed.run.exitCode); // 1
40
+
41
+ const slow = await bash({ command: "sleep 5", timeoutMs: 100 });
42
+ console.log(textOf(slow));
43
+ // Timed out after 0.1 s. The process tree was stopped.
44
+ // (no output)
45
+ ```
46
+
47
+ `textOf` from `@better-fs-tools/shell` is the same function as `textOf` from `@better-fs-tools/read` and `@better-fs-tools/write`. It is re-exported so a bash-only host needs one import.
48
+
49
+ A tool call is `bash(input, ctx?)`. The input is `{ command, timeoutMs?, cwd? }`. `ctx` is the call context that the other tools use: `{ signal?, callId?, host }`.
50
+
51
+ Each call starts a new shell. A `cd` or an `export` does not carry to the next call. Use the `cwd` input instead.
52
+
53
+ ## Hosts
54
+
55
+ | Host | Package | Factories |
56
+ | --------------- | ---------------------------- | -------------------------------------------------------------------------------------- |
57
+ | Node or Bun | `@better-fs-tools/node` | `createNodeBashTool()`, `nodeCommandRunner()`, and `createNodeFsTools({ bash: true })` |
58
+ | AI SDK 7 | `@better-fs-tools/ai-sdk` | `createAiSdkBashTool({ runner, env })` |
59
+ | Pi coding agent | `@better-fs-tools/pi` | `createPiBashTool()`, with Pi's own shape: `{ command, timeout }` in seconds |
60
+ | just-bash | `@better-fs-tools/just-bash` | `justBashCommandRunner(bash)`, an emulated shell with no process |
61
+
62
+ ## Results
63
+
64
+ | Status | When |
65
+ | --------- | ------------------------------------------------------------------------------------------------------ |
66
+ | `ok` | The command exited with code 0. |
67
+ | `failed` | The command exited with another code, or a signal ended it. This is a normal result, not a tool error. |
68
+ | `timeout` | The timeout stopped the command. The output so far is kept. |
69
+ | `error` | The call stopped, or the core stopped the command. `error.code` says why. |
70
+
71
+ The error codes are `INVALID_INPUT`, `CWD_NOT_FOUND`, `CWD_NOT_A_DIRECTORY`, `DENIED` (the authorizer refused), `REFUSED` (a `beforeRun` hook refused), `ABORTED` (the caller's signal fired, before or during the run), `SPAWN_FAILED`, `OUTPUT_CAP`, and `EXTENSION_FAILED`. Read and write use the same codes for a denial and an abort.
72
+
73
+ The result is a union on `status`. Every result has `tool: "bash"`, `status`, `request`, `run` (command as run, cwd, exit code, signal, duration, and what stopped it), `output` (the view, byte and line counts, and the spill reference), `notes`, and `content` for the model. Only the `error` variant has `error: { code, phase, message, data? }`, the same shape as the read and write errors. For `ok`, `failed`, and `timeout`, `run` and `output` are never null. For `error` they are set only when the command started: an abort during the run, `OUTPUT_CAP`, or a failed `afterRun` hook.
74
+
75
+ ```ts
76
+ import { createNodeBashTool } from "@better-fs-tools/node";
77
+
78
+ const bash = createNodeBashTool();
79
+ const result = await bash({ command: "ls" });
80
+
81
+ if (result.status === "error") {
82
+ console.log(result.error.code, result.error.phase); // only the error variant has `error`
83
+ } else {
84
+ console.log(result.run.exitCode, result.output.totalBytes); // never null here
85
+ }
86
+ ```
87
+
88
+ [docs/result-schema.md](docs/result-schema.md) lists every field, every error code with its phase, and every note. Note codes are kebab case, as in read and write: `clamped`, `output-incomplete`, `unconfirmed-stop`, and the error note of an error result, whose code is the error code in kebab case (`denied`, `aborted`, `output-cap`, and so on).
89
+
90
+ The model text starts with the status line:
91
+
92
+ ```text
93
+ Exit code 1 · 0.4 s
94
+ <output>
95
+ ```
96
+
97
+ ```text
98
+ Timed out after 2 min. The process tree was stopped.
99
+ <first lines>
100
+ [… 11945 lines (1.8 MB) not shown] Full output: /tmp/bash-1.log
101
+ <last lines>
102
+ ```
103
+
104
+ ## Defaults
105
+
106
+ | Limit | Default | Meaning |
107
+ | ------------------ | ---------- | ------------------------------------------------------------------------------------------------ |
108
+ | `defaultTimeoutMs` | 120 000 | Used when the input has no timeout. |
109
+ | `maxTimeoutMs` | 600 000 | A larger input timeout is cut to this, with a `clamped` info note. |
110
+ | `killGraceMs` | 2 000 | Time between SIGTERM and SIGKILL. The core waits this long plus one second for the exit. |
111
+ | `maxOutputBytes` | 30 000 | Bytes in the model view. |
112
+ | `maxOutputLines` | 2 000 | Lines in the model view. |
113
+ | `headPercent` | 20 | Percent of the view for the head. The tail gets the rest, because errors are usually at the end. |
114
+ | `maxCaptureBytes` | 10 485 760 | Past this, the core stops the command with `OUTPUT_CAP`. |
115
+
116
+ A `defaultTimeoutMs` that you set above `maxTimeoutMs`, or a `headPercent` over 100, throws `TypeError`. When you set only `maxTimeoutMs` below 120 000, the default timeout is lowered to it. The same limits rule holds in read, write, and bash. A per-call value over its ceiling is clamped, with a `clamped` info note. Two limits that you set and that conflict throw `TypeError` when the tool is built. A default that is over a ceiling you set is lowered to that ceiling.
117
+
118
+ stdout and stderr are merged in arrival order. The core decodes UTF-8 with replacement characters and removes ANSI escape codes. It changes nothing else.
119
+
120
+ `createBashTool` has no default environment: `env` is required, so the host chooses once what a command sees. `shellEnv(() => process.env)` passes the host environment, `shellEnv({ PATH, HOME })` passes an allow list, and `shellEnv()` passes `defaultShellEnv` alone. Every `shellEnv()` puts `defaultShellEnv` over its base: `PAGER=cat`, `GIT_PAGER=cat`, `GIT_TERMINAL_PROMPT=0`, `NO_COLOR=1`, and `TERM=dumb`. `createNodeBashTool()`, `createPiBashTool()`, and `createNodeFsTools({ bash: true })` default `env` to `shellEnv(() => process.env)`. `createAiSdkBashTool()` and `createFsTools()` require it, like the core.
121
+
122
+ ## Hooks
123
+
124
+ `runner` and `env` are required. `limits` and `messages` merge key by key. Every other dependency replaces its default.
125
+
126
+ | Dependency | Default | What a host builds on it |
127
+ | ----------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
128
+ | `runner` | required | Node, just-bash, a sandbox wrapper, a remote runner. A factory gets the call. |
129
+ | `cwd` | the runner's `cwd` | Another default directory, or one for each call. |
130
+ | `resolve` | none | A read tool resolver changes the requested cwd string. |
131
+ | `beforeRun` | `[]` | Guards and command rewrites. A rewrite feeds the next hook. |
132
+ | `authorize` | none (allow) | Approval prompts, prefix rules, deny lists, plan mode. A read `ToolAuthorizer` works on the cwd: `denyPaths()` sees the working directory, not the files a command touches. |
133
+ | `env` | required | The host environment, allow lists, and secret scrubbing. |
134
+ | `spill` | none | Every output byte saved to a file or a store. |
135
+ | `afterRun` | `[]` | Secret masking, output filters, exit-code meanings, read-record invalidation. |
136
+ | `formatter` | `defaultShellFormatter()` | Another layout of the model text. |
137
+ | `messages` | `defaultShellMessages` | Wording. A signature sets the parameter names and the timeout unit. |
138
+ | `digest` | null | Passed to host functions as `ctx.digest`. The bash tool hashes nothing itself. |
139
+ | `clock` | `() => new Date()` | Passed to host functions as `ctx.clock`. |
140
+
141
+ The stages run in this order: input, cwd, `beforeRun`, `authorize`, `env`, the run, `afterRun`, and the formatter. `authorize` sees the command after every `beforeRun` rewrite, so an approval prompt shows the command that runs. Its target has the cwd as `resolvedPath`, and a `displayPath` relative to the default cwd (`.` for the default cwd itself), as the file tools show paths.
142
+
143
+ A `beforeRun` hook returns `{ allow: true, command?, notes? }` or `{ allow: false, note? }`, the same `{ allow }` shape as an authorize decision. A refusal without a note gets a default `refused` note. A refusal note becomes the `refused` error note with the hook's own code in `data.source`, as for an authorizer note. An `afterRun` hook returns `{ output?, notes? }`: only the parts the core keeps. A field left out keeps its value.
144
+
145
+ ```ts
146
+ import { createNodeBashTool } from "@better-fs-tools/node";
147
+ import type { AfterRunHook, BeforeRunHook, ShellAuthorizer } from "@better-fs-tools/shell";
148
+ import { defaultShellEnv } from "@better-fs-tools/shell";
149
+
150
+ // Host policy: allow a short list of command prefixes.
151
+ const allowPrefixes: ShellAuthorizer = {
152
+ id: "allow-prefixes",
153
+ authorize: (target) =>
154
+ ["git status", "ls", "bun test"].some((prefix) => target.command.startsWith(prefix))
155
+ ? { allow: true }
156
+ : {
157
+ allow: false,
158
+ note: { code: "not-allowed", severity: "warning", message: "Ask the user first." },
159
+ },
160
+ };
161
+
162
+ // A reusable guard: refuse programs that need a terminal. It runs before
163
+ // authorize, so allowPrefixes sees any rewritten command.
164
+ const noInteractive: BeforeRunHook = {
165
+ id: "no-interactive",
166
+ beforeRun: (run) =>
167
+ /^(vim|less|top)\b/u.test(run.command)
168
+ ? {
169
+ allow: false,
170
+ note: { code: "interactive", severity: "warning", message: "stdin is closed." },
171
+ }
172
+ : { allow: true },
173
+ };
174
+
175
+ // Mask a secret in the model view. The hook returns only what it changes.
176
+ const maskTokens: AfterRunHook = {
177
+ id: "mask-tokens",
178
+ afterRun: (outcome) => ({
179
+ output: {
180
+ ...outcome.output,
181
+ head: outcome.output.head.replaceAll(/ghp_\w+/gu, "ghp_***"),
182
+ tail: outcome.output.tail?.replaceAll(/ghp_\w+/gu, "ghp_***") ?? null,
183
+ },
184
+ }),
185
+ };
186
+
187
+ export const bash = createNodeBashTool({
188
+ authorize: allowPrefixes,
189
+ beforeRun: [noInteractive],
190
+ afterRun: [maskTokens],
191
+ // Only PATH and HOME from the host, plus the pager and colour defaults.
192
+ env: () => ({
193
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
194
+ HOME: process.env.HOME ?? "/",
195
+ ...defaultShellEnv,
196
+ }),
197
+ });
198
+ ```
199
+
200
+ A spill sink gets every byte in arrival order. Its reference goes into the truncation line:
201
+
202
+ ```ts
203
+ import { open } from "node:fs/promises";
204
+ import { tmpdir } from "node:os";
205
+ import { join } from "node:path";
206
+
207
+ import { createNodeBashTool } from "@better-fs-tools/node";
208
+ import type { SpillSink } from "@better-fs-tools/shell";
209
+
210
+ // Save every byte to a file. The truncation line names it, so the model can
211
+ // read the part the view left out with the read tool.
212
+ const fileSpill: SpillSink = {
213
+ id: "file",
214
+ async open() {
215
+ const path = join(tmpdir(), `bash-${crypto.randomUUID()}.log`);
216
+ const file = await open(path, "w");
217
+ return {
218
+ write: async (bytes) => {
219
+ await file.write(bytes);
220
+ },
221
+ close: async () => {
222
+ await file.close();
223
+ return path;
224
+ },
225
+ };
226
+ },
227
+ };
228
+
229
+ export const bash = createNodeBashTool({ spill: fileSpill });
230
+ // [… 196 000 lines (1.2 MB) not shown] Full output: /tmp/bash-….log
231
+ ```
232
+
233
+ A hook that throws, or returns a malformed value, gives `EXTENSION_FAILED` with its phase. After the run, the result keeps the run and the output.
234
+
235
+ A formatter that throws, or returns neither a string nor an array, does not change the status. The core formats with `defaultShellFormatter()` and adds no note, as the read and write tools do. The format context has `digest`, `limits`, `messages`, `mode`, and `call`.
236
+
237
+ ## Runners
238
+
239
+ A runner starts one command and never throws for a failed command:
240
+
241
+ - `run(request)` gets the command, an absolute cwd, the whole environment, an abort signal, and `killGraceMs`.
242
+ - It returns `output`, an async iterable of `{ stream, bytes }` chunks in arrival order, and `exit`, a promise of `{ code, signal, error? }`.
243
+ - When the signal aborts, the runner stops the whole process tree.
244
+ - A start that fails is an exit with `code: null` and an `error` reason: `cwd-not-found`, `cwd-not-a-directory`, or `spawn-failed`.
245
+
246
+ The runner's `id` goes into the tool description, so the model can see, for example, that commands run in an emulated shell.
247
+
248
+ ## Signatures
249
+
250
+ `defaultBashSignature()` from `@better-fs-tools/shell/signature` gives the model `bash({ command, timeout?, cwd? })` with the timeout in milliseconds. `timeoutUnit: "s"` takes seconds, and `cwd: false` leaves out the cwd parameter; `names.cwd` and `describe.cwd` are then ignored. `names` renames a parameter, for example `names: { timeout: "timeout_seconds" }`, and `describe` replaces a parameter's description. Both take the preset's own names as keys, as in the read and write presets. `BashSignature` extends the `ToolSignature` base from `@better-fs-tools/read` with `duration(ms)`, the timeout in the signature's unit. `bashSignatureMessages(signature)` gives the messages the same parameter names and unit.
251
+
252
+ ## Parse helper
253
+
254
+ Every tool has one parse helper with the same pattern: `parseReadInput`, `parseEditInput`, `parseWriteInput`, `parseApplyPatchInput`, and `parseBashInput`. Each takes the canonical input and the resolved limits, returns the request, and throws `TypeError` for input it refuses. The tool turns that throw into `INVALID_INPUT`. A clamp is not an error: the helper returns the clamped value, and the tool adds the `clamped` note.
255
+
256
+ ## Known limits
257
+
258
+ - POSIX only. The Node runner runs `bash -c` in its own process group.
259
+ - A background child that outlives a normal exit keeps running. The core stops reading its output shortly after the shell exits.
260
+ - The allowed roots of the file tools do not limit what a command touches. Add an OS sandbox through a wrapper runner.
261
+ - The bash tool does not update the read records of the file tools. The write core still catches most changes, because it compares the version or the content hash at commit. On a backend with weak versions, a change in the same second can go unseen. An `afterRun` hook that calls `invalidate(path)` closes that gap.
262
+ - The just-bash runner buffers output until the command ends. A busy loop does not yield to the event loop, so the timeout cannot fire during it. Set just-bash's `executionLimits`.
263
+ - There are no background runs, no persistent session, no PTY, and no input to a running process.
264
+
265
+ ## More
266
+
267
+ - [docs/architecture.md](docs/architecture.md): the stages, their order, and why
268
+ - [docs/result-schema.md](docs/result-schema.md): every field of the result, every error code with its phase, and every note
269
+ - `docs/hosts.md` in [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write): the bash defaults of every host, next to the file tools
@@ -0,0 +1,10 @@
1
+ import type { ToolCallContext } from "@better-fs-tools/read";
2
+ import type { BashInput } from "./input.ts";
3
+ import type { ShellResult } from "./result.ts";
4
+ /**
5
+ * The bash tool function. The context argument is optional, and `host` may be
6
+ * left out, when THost includes undefined.
7
+ */
8
+ export type BashTool<THost = undefined> = undefined extends THost ? (input: BashInput, ctx?: Omit<ToolCallContext<THost>, "host"> & {
9
+ readonly host?: THost;
10
+ }) => Promise<ShellResult> : (input: BashInput, ctx: ToolCallContext<THost>) => Promise<ShellResult>;
File without changes
@@ -0,0 +1,43 @@
1
+ import type { Clock, Digest, PathResolver, ToolCallContext } from "@better-fs-tools/read";
2
+ import type { AfterRunHook, BeforeRunHook, ShellAuthorizer, ShellEnv, SpillSink } from "./extensions.ts";
3
+ import type { ShellFormatter } from "./format.ts";
4
+ import type { ShellLimits } from "./limits.ts";
5
+ import type { ShellMessageCatalog } from "./messages.ts";
6
+ import type { CommandRunner } from "./runner.ts";
7
+ export interface ShellDependencies<THost = undefined> {
8
+ /** A runner, or a factory called once for each call. */
9
+ readonly runner: CommandRunner | ((call: ToolCallContext<THost>) => CommandRunner);
10
+ /** The default cwd, or a factory. Default the runner's cwd. */
11
+ readonly cwd: string | ((call: ToolCallContext<THost>) => string) | null;
12
+ readonly limits: Readonly<ShellLimits>;
13
+ readonly messages: Readonly<ShellMessageCatalog>;
14
+ /** Changes the requested cwd string, as for the read tool. null is identity. */
15
+ readonly resolve: PathResolver<THost> | null;
16
+ /** Checks and rewrites, in order, before authorize. */
17
+ readonly beforeRun: readonly BeforeRunHook<THost>[];
18
+ /** Host policy on the final command. null allows. */
19
+ readonly authorize: ShellAuthorizer<THost> | null;
20
+ /**
21
+ * The whole environment of each command. Pass shellEnv(() => process.env),
22
+ * an allow list such as shellEnv({ PATH }), or shellEnv() for defaultShellEnv alone.
23
+ */
24
+ readonly env: ShellEnv<THost>;
25
+ /** null turns spill off. */
26
+ readonly spill: SpillSink<THost> | null;
27
+ readonly afterRun: readonly AfterRunHook<THost>[];
28
+ readonly formatter: ShellFormatter<THost>;
29
+ /** Given to host functions as ctx.digest. Default null. */
30
+ readonly digest: Digest | null;
31
+ /** Given to host functions as ctx.clock. Default () => new Date(). */
32
+ readonly clock: Clock;
33
+ }
34
+ /**
35
+ * `runner` and `env` are required. `limits` and `messages` merge key by key.
36
+ * Every other dependency replaces its default.
37
+ */
38
+ export type ShellToolDeps<THost = undefined> = {
39
+ readonly runner: ShellDependencies<THost>["runner"];
40
+ readonly env: ShellDependencies<THost>["env"];
41
+ readonly limits?: Partial<ShellLimits>;
42
+ readonly messages?: Partial<ShellMessageCatalog>;
43
+ } & Partial<Omit<ShellDependencies<THost>, "runner" | "env" | "limits" | "messages">>;
File without changes
@@ -0,0 +1,94 @@
1
+ import type { AccessDecision, AccessTarget, Note, ToolCallContext, ToolHookContext } from "@better-fs-tools/read";
2
+ import type { BashRequest } from "./input.ts";
3
+ import type { ShellLimits } from "./limits.ts";
4
+ import type { ShellMessageCatalog } from "./messages.ts";
5
+ import type { ShellError, ShellOutput, ShellRun } from "./result.ts";
6
+ /** Given to every shell host function. One object for each call. */
7
+ export interface ShellHookContext<THost = undefined> extends ToolHookContext<THost> {
8
+ readonly tool: "bash";
9
+ readonly request: BashRequest;
10
+ readonly limits: Readonly<ShellLimits>;
11
+ readonly messages: Readonly<ShellMessageCatalog>;
12
+ readonly call: ToolCallContext<THost>;
13
+ }
14
+ /**
15
+ * The path fields name the working directory, so a read tool authorizer
16
+ * (a ToolAuthorizer) that checks paths also works on the cwd.
17
+ */
18
+ export interface ShellAuthorizeTarget extends AccessTarget {
19
+ readonly action: "run";
20
+ readonly command: string;
21
+ readonly cwd: string;
22
+ readonly timeoutMs: number;
23
+ }
24
+ /**
25
+ * Host policy. Runs after the beforeRun hooks, so it sees the command that
26
+ * will run. A ToolAuthorizer from read fits here.
27
+ */
28
+ export interface ShellAuthorizer<THost = undefined> {
29
+ readonly id: string;
30
+ readonly authorize: (target: ShellAuthorizeTarget, ctx: ShellHookContext<THost>) => AccessDecision | Promise<AccessDecision>;
31
+ }
32
+ export interface PlannedRun {
33
+ readonly command: string;
34
+ readonly cwd: string;
35
+ readonly timeoutMs: number;
36
+ }
37
+ /**
38
+ * The same `{ allow }` shape as an authorize decision. `command` rewrites the
39
+ * command. A refusal gives REFUSED, with `note` or a default refused note.
40
+ */
41
+ export type BeforeRunDecision = {
42
+ readonly allow: true;
43
+ readonly command?: string;
44
+ readonly notes?: readonly Note[];
45
+ } | {
46
+ readonly allow: false;
47
+ readonly note?: Note;
48
+ };
49
+ /**
50
+ * A reusable check or rewrite. Runs in order before authorize, so the
51
+ * authorizer sees the final command. A rewrite feeds the next hook.
52
+ */
53
+ export interface BeforeRunHook<THost = undefined> {
54
+ readonly id: string;
55
+ beforeRun(run: PlannedRun, ctx: ShellHookContext<THost>): BeforeRunDecision | Promise<BeforeRunDecision>;
56
+ }
57
+ /** Returns the whole environment of the command. */
58
+ export type ShellEnv<THost = undefined> = (run: PlannedRun, ctx: ShellHookContext<THost>) => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
59
+ export interface SpillWriter {
60
+ write(bytes: Uint8Array): Promise<void>;
61
+ /** Returns a reference the truncation note shows, for example a path. */
62
+ close(): Promise<string>;
63
+ }
64
+ /** Receives every output byte, in arrival order. */
65
+ export interface SpillSink<THost = undefined> {
66
+ readonly id: string;
67
+ open(run: PlannedRun, ctx: ShellHookContext<THost>): Promise<SpillWriter>;
68
+ }
69
+ /** What afterRun hooks see: a run that started, with its status. */
70
+ export type RunOutcome = {
71
+ readonly status: "ok" | "failed" | "timeout";
72
+ readonly run: ShellRun;
73
+ readonly output: ShellOutput;
74
+ readonly notes: readonly Note[];
75
+ } | {
76
+ readonly status: "error";
77
+ readonly error: ShellError;
78
+ readonly run: ShellRun;
79
+ readonly output: ShellOutput;
80
+ readonly notes: readonly Note[];
81
+ };
82
+ /**
83
+ * What an afterRun hook may change. A field left out keeps its value. `notes`
84
+ * replaces the whole list, so keep the notes you were given.
85
+ */
86
+ export interface AfterRunUpdate {
87
+ readonly output?: ShellOutput;
88
+ readonly notes?: readonly Note[];
89
+ }
90
+ export interface AfterRunHook<THost = undefined> {
91
+ readonly id: string;
92
+ /** Runs in order on every run that started. Returns the output view and the notes to keep. */
93
+ afterRun(outcome: RunOutcome, ctx: ShellHookContext<THost>): AfterRunUpdate | Promise<AfterRunUpdate>;
94
+ }
File without changes
@@ -0,0 +1,22 @@
1
+ import type { ContentPart, Digest, ToolCallContext } from "@better-fs-tools/read";
2
+ import type { ShellLimits } from "./limits.ts";
3
+ import type { ShellMessageCatalog } from "./messages.ts";
4
+ import type { ShellReport } from "./result.ts";
5
+ export interface ShellFormatContext<THost = undefined> {
6
+ /** The tool's digest dependency. */
7
+ readonly digest: Digest | null;
8
+ readonly limits: Readonly<ShellLimits>;
9
+ readonly messages: Readonly<ShellMessageCatalog>;
10
+ /** "view": status line and output only, no notes. */
11
+ readonly mode: "model" | "view";
12
+ readonly call: ToolCallContext<THost>;
13
+ }
14
+ export interface ShellFormatter<THost = undefined> {
15
+ readonly id: string;
16
+ /**
17
+ * Sync and pure. A string becomes one text part. After a throw, or a return
18
+ * that is neither a string nor an array, the default formatter formats the
19
+ * report with no added note. The status stays.
20
+ */
21
+ format(report: ShellReport, ctx: ShellFormatContext<THost>): string | readonly ContentPart[];
22
+ }
File without changes
@@ -0,0 +1,15 @@
1
+ /** The canonical input. A signature maps the model's input to it. */
2
+ export interface BashInput {
3
+ readonly command: string;
4
+ /** Milliseconds. Clamped to limits.maxTimeoutMs with a clamped note. Default limits.defaultTimeoutMs. */
5
+ readonly timeoutMs?: number;
6
+ /** Relative to the default cwd, or absolute. Default the default cwd. */
7
+ readonly cwd?: string;
8
+ }
9
+ /** The input after parse: every field set. */
10
+ export interface BashRequest {
11
+ readonly command: string;
12
+ readonly timeoutMs: number;
13
+ /** The requested cwd, as the model sent it. null when left out. */
14
+ readonly cwd: string | null;
15
+ }
File without changes
@@ -0,0 +1,16 @@
1
+ export interface ShellLimits {
2
+ /** Used when the input has no timeout. Default 120_000. */
3
+ readonly defaultTimeoutMs: number;
4
+ /** A larger input timeout is cut to this, with a note. Default 600_000. */
5
+ readonly maxTimeoutMs: number;
6
+ /** Time between SIGTERM and SIGKILL. The tool waits this long plus one second for the exit. Default 2_000. */
7
+ readonly killGraceMs: number;
8
+ /** Bytes of output in the model view. Default 30_000. */
9
+ readonly maxOutputBytes: number;
10
+ /** Lines of output in the model view. Default 2_000. */
11
+ readonly maxOutputLines: number;
12
+ /** Percent of the view budget given to the head. The tail gets the rest. Default 20. */
13
+ readonly headPercent: number;
14
+ /** Past this many output bytes the tool stops the command. Default 10_485_760 (10 MiB). */
15
+ readonly maxCaptureBytes: number;
16
+ }
File without changes
@@ -0,0 +1,69 @@
1
+ import type { ToolMessages } from "@better-fs-tools/read";
2
+ import type { ShellPhase } from "./result.ts";
3
+ export type ShellCanonicalParam = "command" | "timeoutMs" | "cwd";
4
+ /**
5
+ * Wording for every note and status line of the tool. Text that names a
6
+ * model parameter calls `param(name)`, which a signature overrides.
7
+ */
8
+ export interface ShellMessageCatalog extends ToolMessages {
9
+ /** Host name for a canonical parameter. An empty string means the host has no such parameter. */
10
+ param(name: ShellCanonicalParam): string;
11
+ /** How a timeout value reads in the host's unit, for example "120 s". */
12
+ duration(ms: number): string;
13
+ invalidInput(c: {
14
+ detail: string;
15
+ }): string;
16
+ cwdNotFound(c: {
17
+ cwd: string;
18
+ }): string;
19
+ cwdNotADirectory(c: {
20
+ cwd: string;
21
+ }): string;
22
+ refused(c: {
23
+ hook: string;
24
+ }): string;
25
+ spawnFailed(c: {
26
+ detail: string | null;
27
+ }): string;
28
+ extensionFailed(c: {
29
+ extension: string;
30
+ phase: ShellPhase;
31
+ }): string;
32
+ timeoutClamped(c: {
33
+ requestedMs: number;
34
+ maxMs: number;
35
+ }): string;
36
+ /** The first line of an ended run. */
37
+ exited(c: {
38
+ code: number | null;
39
+ signal: string | null;
40
+ durationMs: number;
41
+ }): string;
42
+ timedOut(c: {
43
+ timeoutMs: number;
44
+ }): string;
45
+ aborted(c: {
46
+ durationMs: number;
47
+ }): string;
48
+ outputCap(c: {
49
+ limit: number;
50
+ }): string;
51
+ unconfirmedStop(): string;
52
+ abortedBeforeStart(): string;
53
+ /**
54
+ * The output stream failed (`detail`), gave chunks that were not output
55
+ * (`skippedChunks`), or had not ended `drainMs` after the exit.
56
+ */
57
+ outputIncomplete(c: {
58
+ detail: string | null;
59
+ skippedChunks: number;
60
+ drainMs: number | null;
61
+ }): string;
62
+ noOutput(): string;
63
+ /** The line between the head and the tail. */
64
+ omitted(c: {
65
+ lines: number;
66
+ bytes: number;
67
+ spill: string | null;
68
+ }): string;
69
+ }
File without changes