@better-fs-tools/shell 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -25
- package/dist/contract/deps.d.ts +3 -3
- package/dist/contract/format.d.ts +4 -4
- package/dist/contract/messages.d.ts +0 -3
- package/dist/core/create-bash-tool.d.ts +4 -4
- package/dist/core/create-bash-tool.js +4 -4
- package/dist/core/deps.d.ts +2 -2
- package/dist/core/deps.js +4 -3
- package/dist/core/env.d.ts +1 -1
- package/dist/core/env.js +1 -1
- package/dist/core/execute.js +1 -8
- package/dist/core/messages.js +0 -1
- package/dist/core/pipeline.js +3 -9
- package/dist/formatters/default.d.ts +4 -1
- package/dist/formatters/default.js +18 -3
- package/dist/index.d.ts +1 -1
- package/dist/signature/definition.d.ts +24 -0
- package/dist/signature/definition.js +39 -0
- package/dist/signature/index.d.ts +2 -0
- package/dist/signature/index.js +1 -0
- package/docs/architecture.md +3 -3
- package/docs/result-schema.md +5 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -23,12 +23,12 @@ The package has no peers and imports no `node:` module. It starts no process by
|
|
|
23
23
|
## Quick start
|
|
24
24
|
|
|
25
25
|
```ts
|
|
26
|
-
import {
|
|
27
|
-
import { textOf } from "@better-fs-tools/shell";
|
|
26
|
+
import { nodeCommandRunner } from "@better-fs-tools/node";
|
|
27
|
+
import { createBashTool, shellEnv, textOf } from "@better-fs-tools/shell";
|
|
28
28
|
|
|
29
29
|
// bash -c in process.cwd(), with process.env, a 2 minute timeout, and a
|
|
30
30
|
// 30 000 byte view of the output.
|
|
31
|
-
const bash =
|
|
31
|
+
const bash = createBashTool({ runner: nodeCommandRunner(), env: shellEnv(() => process.env) });
|
|
32
32
|
|
|
33
33
|
const listed = await bash({ command: "ls package.json" });
|
|
34
34
|
console.log(textOf(listed));
|
|
@@ -52,12 +52,16 @@ Each call starts a new shell. A `cd` or an `export` does not carry to the next c
|
|
|
52
52
|
|
|
53
53
|
## Hosts
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
55
|
+
Every integration takes the options of `createBashTool()`, with `runner` and `env` required, plus `signature`.
|
|
56
|
+
|
|
57
|
+
| Host or runner | Package | Factories |
|
|
58
|
+
| ------------------ | ----------------------------- | ------------------------------------------------------------------------------------------- |
|
|
59
|
+
| Node or Bun | `@better-fs-tools/node` | `nodeCommandRunner()`, a runner for `createBashTool()` or a bundle |
|
|
60
|
+
| AI SDK 7 | `@better-fs-tools/ai-sdk` | `createAiSdkBashTool({ runner, env })` |
|
|
61
|
+
| Pi coding agent | `@better-fs-tools/pi` | `createPiBashTool({ runner, env })`. `piCwdCommandRunner()` from `./node` runs in `ctx.cwd` |
|
|
62
|
+
| Pi agent core | `@better-fs-tools/pi` | `createAgentCoreBashTool({ runner, env })` from `./agent-core` |
|
|
63
|
+
| Pi durable harness | `@better-fs-tools/pi-durable` | the `bash` option of `createDurableFsTools()`, with `durableEnvRunner()` |
|
|
64
|
+
| just-bash | `@better-fs-tools/just-bash` | `justBashCommandRunner(bash)`, an emulated shell with no process |
|
|
61
65
|
|
|
62
66
|
## Results
|
|
63
67
|
|
|
@@ -73,9 +77,10 @@ The error codes are `INVALID_INPUT`, `CWD_NOT_FOUND`, `CWD_NOT_A_DIRECTORY`, `DE
|
|
|
73
77
|
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
78
|
|
|
75
79
|
```ts
|
|
76
|
-
import {
|
|
80
|
+
import { nodeCommandRunner } from "@better-fs-tools/node";
|
|
81
|
+
import { createBashTool, shellEnv } from "@better-fs-tools/shell";
|
|
77
82
|
|
|
78
|
-
const bash =
|
|
83
|
+
const bash = createBashTool({ runner: nodeCommandRunner(), env: shellEnv(() => process.env) });
|
|
79
84
|
const result = await bash({ command: "ls" });
|
|
80
85
|
|
|
81
86
|
if (result.status === "error") {
|
|
@@ -85,7 +90,7 @@ if (result.status === "error") {
|
|
|
85
90
|
}
|
|
86
91
|
```
|
|
87
92
|
|
|
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`, `
|
|
93
|
+
[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
94
|
|
|
90
95
|
The model text starts with the status line:
|
|
91
96
|
|
|
@@ -117,13 +122,13 @@ A `defaultTimeoutMs` that you set above `maxTimeoutMs`, or a `headPercent` over
|
|
|
117
122
|
|
|
118
123
|
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
124
|
|
|
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`.
|
|
125
|
+
`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`. Every integration and bundle requires it, like the core.
|
|
121
126
|
|
|
122
127
|
## Hooks
|
|
123
128
|
|
|
124
|
-
`runner` and `env` are required. `limits` and `messages` merge key by key. Every other
|
|
129
|
+
`runner` and `env` are required. `limits` and `messages` merge key by key. Every other option replaces its default.
|
|
125
130
|
|
|
126
|
-
|
|
|
131
|
+
| Option | Default | What a host builds on it |
|
|
127
132
|
| ----------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
128
133
|
| `runner` | required | Node, just-bash, a sandbox wrapper, a remote runner. A factory gets the call. |
|
|
129
134
|
| `cwd` | the runner's `cwd` | Another default directory, or one for each call. |
|
|
@@ -135,7 +140,7 @@ stdout and stderr are merged in arrival order. The core decodes UTF-8 with repla
|
|
|
135
140
|
| `afterRun` | `[]` | Secret masking, output filters, exit-code meanings, read-record invalidation. |
|
|
136
141
|
| `formatter` | `defaultShellFormatter()` | Another layout of the model text. |
|
|
137
142
|
| `messages` | `defaultShellMessages` | Wording. A signature sets the parameter names and the timeout unit. |
|
|
138
|
-
| `digest` |
|
|
143
|
+
| `digest` | `sha256Digest()` | Passed to host functions as `ctx.digest`. The bash tool hashes nothing itself. |
|
|
139
144
|
| `clock` | `() => new Date()` | Passed to host functions as `ctx.clock`. |
|
|
140
145
|
|
|
141
146
|
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.
|
|
@@ -143,9 +148,9 @@ The stages run in this order: input, cwd, `beforeRun`, `authorize`, `env`, the r
|
|
|
143
148
|
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
149
|
|
|
145
150
|
```ts
|
|
146
|
-
import {
|
|
151
|
+
import { nodeCommandRunner } from "@better-fs-tools/node";
|
|
147
152
|
import type { AfterRunHook, BeforeRunHook, ShellAuthorizer } from "@better-fs-tools/shell";
|
|
148
|
-
import { defaultShellEnv } from "@better-fs-tools/shell";
|
|
153
|
+
import { createBashTool, defaultShellEnv } from "@better-fs-tools/shell";
|
|
149
154
|
|
|
150
155
|
// Host policy: allow a short list of command prefixes.
|
|
151
156
|
const allowPrefixes: ShellAuthorizer = {
|
|
@@ -184,7 +189,8 @@ const maskTokens: AfterRunHook = {
|
|
|
184
189
|
}),
|
|
185
190
|
};
|
|
186
191
|
|
|
187
|
-
export const bash =
|
|
192
|
+
export const bash = createBashTool({
|
|
193
|
+
runner: nodeCommandRunner(),
|
|
188
194
|
authorize: allowPrefixes,
|
|
189
195
|
beforeRun: [noInteractive],
|
|
190
196
|
afterRun: [maskTokens],
|
|
@@ -204,7 +210,8 @@ import { open } from "node:fs/promises";
|
|
|
204
210
|
import { tmpdir } from "node:os";
|
|
205
211
|
import { join } from "node:path";
|
|
206
212
|
|
|
207
|
-
import {
|
|
213
|
+
import { nodeCommandRunner } from "@better-fs-tools/node";
|
|
214
|
+
import { createBashTool, shellEnv } from "@better-fs-tools/shell";
|
|
208
215
|
import type { SpillSink } from "@better-fs-tools/shell";
|
|
209
216
|
|
|
210
217
|
// Save every byte to a file. The truncation line names it, so the model can
|
|
@@ -226,13 +233,17 @@ const fileSpill: SpillSink = {
|
|
|
226
233
|
},
|
|
227
234
|
};
|
|
228
235
|
|
|
229
|
-
export const bash =
|
|
236
|
+
export const bash = createBashTool({
|
|
237
|
+
runner: nodeCommandRunner(),
|
|
238
|
+
env: shellEnv(() => process.env),
|
|
239
|
+
spill: fileSpill,
|
|
240
|
+
});
|
|
230
241
|
// [… 196 000 lines (1.2 MB) not shown] Full output: /tmp/bash-….log
|
|
231
242
|
```
|
|
232
243
|
|
|
233
244
|
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
245
|
|
|
235
|
-
A formatter that throws, or returns neither a string nor an array, does not change the status. The core
|
|
246
|
+
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
247
|
|
|
237
248
|
## Runners
|
|
238
249
|
|
|
@@ -247,7 +258,7 @@ The runner's `id` goes into the tool description, so the model can see, for exam
|
|
|
247
258
|
|
|
248
259
|
## Signatures
|
|
249
260
|
|
|
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
|
|
261
|
+
`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 signature's own names as keys, as in the read and write signatures. `bashToolDefinition()` from the same subpath binds the bash tool to a signature for a framework of your own. `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
262
|
|
|
252
263
|
## Parse helper
|
|
253
264
|
|
|
@@ -265,5 +276,5 @@ Every tool has one parse helper with the same pattern: `parseReadInput`, `parseE
|
|
|
265
276
|
## More
|
|
266
277
|
|
|
267
278
|
- [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
|
|
279
|
+
- [docs/result-schema.md](docs/result-schema.md): every field of the result, every error code with its phase, and every note. The [bash reference](https://better-fs-tools.gandrew.com/reference/bash) on the docs site has the same facts
|
|
280
|
+
- [Choose an integration](https://better-fs-tools.gandrew.com/integrations) on the docs site, or `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
|
package/dist/contract/deps.d.ts
CHANGED
|
@@ -26,16 +26,16 @@ export interface ShellDependencies<THost = undefined> {
|
|
|
26
26
|
readonly spill: SpillSink<THost> | null;
|
|
27
27
|
readonly afterRun: readonly AfterRunHook<THost>[];
|
|
28
28
|
readonly formatter: ShellFormatter<THost>;
|
|
29
|
-
/** Given to host functions as ctx.digest. Default
|
|
29
|
+
/** Given to host functions as ctx.digest. Default sha256Digest(). */
|
|
30
30
|
readonly digest: Digest | null;
|
|
31
31
|
/** Given to host functions as ctx.clock. Default () => new Date(). */
|
|
32
32
|
readonly clock: Clock;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* `runner` and `env` are required. `limits` and `messages` merge key by key.
|
|
36
|
-
* Every other
|
|
36
|
+
* Every other option replaces its default.
|
|
37
37
|
*/
|
|
38
|
-
export type
|
|
38
|
+
export type BashToolOptions<THost = undefined> = {
|
|
39
39
|
readonly runner: ShellDependencies<THost>["runner"];
|
|
40
40
|
readonly env: ShellDependencies<THost>["env"];
|
|
41
41
|
readonly limits?: Partial<ShellLimits>;
|
|
@@ -3,7 +3,7 @@ import type { ShellLimits } from "./limits.ts";
|
|
|
3
3
|
import type { ShellMessageCatalog } from "./messages.ts";
|
|
4
4
|
import type { ShellReport } from "./result.ts";
|
|
5
5
|
export interface ShellFormatContext<THost = undefined> {
|
|
6
|
-
/** The tool's digest
|
|
6
|
+
/** The tool's digest option. */
|
|
7
7
|
readonly digest: Digest | null;
|
|
8
8
|
readonly limits: Readonly<ShellLimits>;
|
|
9
9
|
readonly messages: Readonly<ShellMessageCatalog>;
|
|
@@ -14,9 +14,9 @@ export interface ShellFormatContext<THost = undefined> {
|
|
|
14
14
|
export interface ShellFormatter<THost = undefined> {
|
|
15
15
|
readonly id: string;
|
|
16
16
|
/**
|
|
17
|
-
* Sync and pure. A string becomes one text part.
|
|
18
|
-
* is neither a string nor an array,
|
|
19
|
-
*
|
|
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
20
|
*/
|
|
21
21
|
format(report: ShellReport, ctx: ShellFormatContext<THost>): string | readonly ContentPart[];
|
|
22
22
|
}
|
|
@@ -50,9 +50,6 @@ export interface ShellMessageCatalog extends ToolMessages {
|
|
|
50
50
|
}): string;
|
|
51
51
|
unconfirmedStop(): string;
|
|
52
52
|
abortedBeforeStart(): string;
|
|
53
|
-
spillFailed(c: {
|
|
54
|
-
sink: string;
|
|
55
|
-
}): string;
|
|
56
53
|
/**
|
|
57
54
|
* The output stream failed (`detail`), gave chunks that were not output
|
|
58
55
|
* (`skippedChunks`), or had not ended `drainMs` after the exit.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { BashTool } from "../contract/context.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { BashToolOptions } from "../contract/deps.ts";
|
|
3
3
|
/**
|
|
4
4
|
* Creates the bash tool. Throws TypeError on an unknown key, a missing runner
|
|
5
|
-
* or env, or a malformed
|
|
6
|
-
* Every other
|
|
5
|
+
* or env, or a malformed option. `limits` and `messages` merge key by key.
|
|
6
|
+
* Every other option replaces its default.
|
|
7
7
|
*/
|
|
8
|
-
export declare function createBashTool<THost = undefined>(
|
|
8
|
+
export declare function createBashTool<THost = undefined>(options: BashToolOptions<THost>): BashTool<THost>;
|
|
@@ -3,11 +3,11 @@ import { isRecord } from "./input.js";
|
|
|
3
3
|
import { runBash } from "./pipeline.js";
|
|
4
4
|
/**
|
|
5
5
|
* Creates the bash tool. Throws TypeError on an unknown key, a missing runner
|
|
6
|
-
* or env, or a malformed
|
|
7
|
-
* Every other
|
|
6
|
+
* or env, or a malformed option. `limits` and `messages` merge key by key.
|
|
7
|
+
* Every other option replaces its default.
|
|
8
8
|
*/
|
|
9
|
-
export function createBashTool(
|
|
10
|
-
const resolved = resolveShellDependencies(
|
|
9
|
+
export function createBashTool(options) {
|
|
10
|
+
const resolved = resolveShellDependencies(options);
|
|
11
11
|
const bash = async (input, ctx) => {
|
|
12
12
|
if (ctx !== undefined && !isRecord(ctx))
|
|
13
13
|
throw new TypeError("bash context must be an object");
|
package/dist/core/deps.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ShellDependencies,
|
|
1
|
+
import type { ShellDependencies, BashToolOptions } from "../contract/deps.ts";
|
|
2
2
|
import type { CommandRunner } from "../contract/runner.ts";
|
|
3
|
-
export declare function resolveShellDependencies<THost>(deps:
|
|
3
|
+
export declare function resolveShellDependencies<THost>(deps: BashToolOptions<THost>): ShellDependencies<THost>;
|
|
4
4
|
export declare function isRunner(value: unknown): value is CommandRunner;
|
|
5
5
|
export declare function isAbsolute(value: unknown): value is string;
|
package/dist/core/deps.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sha256Digest } from "@better-fs-tools/read";
|
|
1
2
|
import { defaultShellFormatter } from "../formatters/default.js";
|
|
2
3
|
import { isRecord } from "./input.js";
|
|
3
4
|
import { resolveShellLimits } from "./limits.js";
|
|
@@ -19,10 +20,10 @@ const KNOWN = new Set([
|
|
|
19
20
|
]);
|
|
20
21
|
export function resolveShellDependencies(deps) {
|
|
21
22
|
if (!isRecord(deps))
|
|
22
|
-
throw new TypeError("bash tool
|
|
23
|
+
throw new TypeError("bash tool options must be an object");
|
|
23
24
|
for (const key of Object.keys(deps)) {
|
|
24
25
|
if (!KNOWN.has(key))
|
|
25
|
-
throw new TypeError(`Unknown bash tool
|
|
26
|
+
throw new TypeError(`Unknown bash tool option: ${key}`);
|
|
26
27
|
}
|
|
27
28
|
const { runner } = deps;
|
|
28
29
|
if (typeof runner !== "function" && !isRunner(runner)) {
|
|
@@ -62,7 +63,7 @@ export function resolveShellDependencies(deps) {
|
|
|
62
63
|
if (!isRecord(formatter) || typeof formatter.format !== "function") {
|
|
63
64
|
throw new TypeError("formatter must have a format function");
|
|
64
65
|
}
|
|
65
|
-
const digest = deps.digest
|
|
66
|
+
const digest = deps.digest === undefined ? sha256Digest() : deps.digest;
|
|
66
67
|
if (digest !== null &&
|
|
67
68
|
(!isRecord(digest) || typeof digest.create !== "function" || typeof digest.hash !== "function")) {
|
|
68
69
|
throw new TypeError("digest must be a digest with create and hash, or null");
|
package/dist/core/env.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ type EnvRecord = Readonly<Record<string, string | undefined>>;
|
|
|
5
5
|
*/
|
|
6
6
|
export declare const defaultShellEnv: Readonly<Record<string, string>>;
|
|
7
7
|
/**
|
|
8
|
-
* An env
|
|
8
|
+
* An env option for any host: `base` with defaultShellEnv over it.
|
|
9
9
|
* Entries whose value is undefined are left out. A function base is read
|
|
10
10
|
* again on each call, so `shellEnv(() => process.env)` follows changes to
|
|
11
11
|
* process.env.
|
package/dist/core/env.js
CHANGED
|
@@ -10,7 +10,7 @@ export const defaultShellEnv = Object.freeze({
|
|
|
10
10
|
TERM: "dumb",
|
|
11
11
|
});
|
|
12
12
|
/**
|
|
13
|
-
* An env
|
|
13
|
+
* An env option for any host: `base` with defaultShellEnv over it.
|
|
14
14
|
* Entries whose value is undefined are left out. A function base is read
|
|
15
15
|
* again on each call, so `shellEnv(() => process.env)` follows changes to
|
|
16
16
|
* process.env.
|
package/dist/core/execute.js
CHANGED
|
@@ -6,8 +6,7 @@ import { StageStop, errorNote, messageOf, warning } from "./stop.js";
|
|
|
6
6
|
const DRAIN_AFTER_EXIT_MS = 1_000;
|
|
7
7
|
const EXIT_WAIT_AFTER_KILL_GRACE_MS = 1_000;
|
|
8
8
|
export async function execute(scope, runner, planned, env) {
|
|
9
|
-
const {
|
|
10
|
-
const { limits, messages } = deps;
|
|
9
|
+
const { limits, messages } = scope.deps;
|
|
11
10
|
const spill = await openSpill(scope, planned);
|
|
12
11
|
const controller = new AbortController();
|
|
13
12
|
let stoppedBy = null;
|
|
@@ -96,9 +95,6 @@ export async function execute(scope, runner, planned, env) {
|
|
|
96
95
|
throw startError(scope, exit.error.reason, exit.error.detail ?? null, planned.cwd);
|
|
97
96
|
}
|
|
98
97
|
const spillRef = await (spill?.close() ?? Promise.resolve(null));
|
|
99
|
-
if (spill?.failed === true) {
|
|
100
|
-
scope.notes.push(warning("spill-failed", messages.spillFailed({ sink: deps.spill?.id ?? "unknown" })));
|
|
101
|
-
}
|
|
102
98
|
const reason = stoppedBy;
|
|
103
99
|
if (exit === null)
|
|
104
100
|
scope.notes.push(warning("unconfirmed-stop", messages.unconfirmedStop()));
|
|
@@ -235,9 +231,6 @@ async function openSpill(scope, planned) {
|
|
|
235
231
|
let chain = Promise.resolve();
|
|
236
232
|
let failed = false;
|
|
237
233
|
return {
|
|
238
|
-
get failed() {
|
|
239
|
-
return failed;
|
|
240
|
-
},
|
|
241
234
|
write(bytes) {
|
|
242
235
|
if (failed)
|
|
243
236
|
return;
|
package/dist/core/messages.js
CHANGED
|
@@ -45,7 +45,6 @@ function catalogFor(param, duration) {
|
|
|
45
45
|
outputCap: ({ limit }) => `The command wrote more than ${formatBytes(limit)} of output, so it was stopped. Send less output, for example with a filter, a count, or a redirect to a file.`,
|
|
46
46
|
unconfirmedStop: () => "The runner did not confirm that the process tree stopped. Processes may still be running.",
|
|
47
47
|
abortedBeforeStart: () => "The call was aborted before the command started.",
|
|
48
|
-
spillFailed: ({ sink }) => `The ${sink} spill sink failed, so the full output was not saved. The command was not affected.`,
|
|
49
48
|
outputIncomplete: ({ detail, skippedChunks, drainMs }) => {
|
|
50
49
|
const causes = [
|
|
51
50
|
...(detail === null ? [] : [`the output stream failed (${detail})`]),
|
package/dist/core/pipeline.js
CHANGED
|
@@ -3,7 +3,7 @@ import { AbortStop } from "./abort.js";
|
|
|
3
3
|
import { execute } from "./execute.js";
|
|
4
4
|
import { clampedTimeout, isRecord, parseBashInput } from "./input.js";
|
|
5
5
|
import { authorize, baseCwd, beforeRun, environment, extensionFailure, resolveCwd, runnerFor, } from "./stages.js";
|
|
6
|
-
import { StageStop, errorNote,
|
|
6
|
+
import { StageStop, errorNote, info, isNoteList, messageOf } from "./stop.js";
|
|
7
7
|
export async function runBash(deps, input, call) {
|
|
8
8
|
const notes = [];
|
|
9
9
|
const { messages, limits } = deps;
|
|
@@ -165,15 +165,9 @@ function finish(deps, call, done) {
|
|
|
165
165
|
catch { }
|
|
166
166
|
if (content !== null)
|
|
167
167
|
return Object.freeze({ ...done, content });
|
|
168
|
-
const id = extensionId(deps.formatter);
|
|
169
|
-
const note = warning("extension-failed", deps.messages.extensionFailed({ extension: id, phase: "format" }), { extension: "formatter", id });
|
|
170
|
-
const kept = Object.freeze({
|
|
171
|
-
...done,
|
|
172
|
-
notes: Object.freeze([...done.notes, note]),
|
|
173
|
-
});
|
|
174
168
|
return Object.freeze({
|
|
175
|
-
...
|
|
176
|
-
content: toContent(defaultShellFormatter().format(
|
|
169
|
+
...done,
|
|
170
|
+
content: toContent(defaultShellFormatter().format(done, context)) ?? [],
|
|
177
171
|
});
|
|
178
172
|
}
|
|
179
173
|
function toContent(value) {
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { Note } from "@better-fs-tools/read";
|
|
2
2
|
import type { ShellFormatter } from "../contract/format.ts";
|
|
3
3
|
export interface ShellFormatterOptions {
|
|
4
|
+
/** Filter or rewrite a note for the model text. null hides it. Default: show every note. */
|
|
5
|
+
readonly notes?: (note: Note) => Note | null;
|
|
4
6
|
/** Default: `[bash:${note.code}] ${note.message}`. */
|
|
5
7
|
readonly noteLine?: (note: Note) => string;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
10
|
* The status line, then the output view, then a blank line and the note
|
|
9
11
|
* lines. A call that did not run prints the note lines only. "view" mode
|
|
10
|
-
* leaves out the notes.
|
|
12
|
+
* leaves out the notes. The `notes` option hides or rewrites a note, as the
|
|
13
|
+
* read tool's formatters do.
|
|
11
14
|
*
|
|
12
15
|
* ```text
|
|
13
16
|
* Exit code 1 · 0.4 s
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The status line, then the output view, then a blank line and the note
|
|
3
3
|
* lines. A call that did not run prints the note lines only. "view" mode
|
|
4
|
-
* leaves out the notes.
|
|
4
|
+
* leaves out the notes. The `notes` option hides or rewrites a note, as the
|
|
5
|
+
* read tool's formatters do.
|
|
5
6
|
*
|
|
6
7
|
* ```text
|
|
7
8
|
* Exit code 1 · 0.4 s
|
|
@@ -12,7 +13,9 @@ export function defaultShellFormatter(options = {}) {
|
|
|
12
13
|
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
13
14
|
throw new TypeError("defaultShellFormatter options must be an object");
|
|
14
15
|
}
|
|
15
|
-
const { noteLine = defaultNoteLine } = options;
|
|
16
|
+
const { notes: filter = showNote, noteLine = defaultNoteLine } = options;
|
|
17
|
+
if (typeof filter !== "function")
|
|
18
|
+
throw new TypeError("notes must be a function");
|
|
16
19
|
if (typeof noteLine !== "function")
|
|
17
20
|
throw new TypeError("noteLine must be a function");
|
|
18
21
|
return Object.freeze({
|
|
@@ -21,7 +24,7 @@ export function defaultShellFormatter(options = {}) {
|
|
|
21
24
|
const main = body(report, ctx.messages);
|
|
22
25
|
if (ctx.mode === "view")
|
|
23
26
|
return main.join("\n");
|
|
24
|
-
const notes = report.notes
|
|
27
|
+
const notes = noteLines(report.notes, filter, noteLine);
|
|
25
28
|
if (notes.length === 0)
|
|
26
29
|
return main.join("\n");
|
|
27
30
|
if (main.length === 0)
|
|
@@ -30,6 +33,18 @@ export function defaultShellFormatter(options = {}) {
|
|
|
30
33
|
},
|
|
31
34
|
});
|
|
32
35
|
}
|
|
36
|
+
function noteLines(notes, filter, line) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const note of notes) {
|
|
39
|
+
const shown = filter(note);
|
|
40
|
+
if (shown !== null)
|
|
41
|
+
lines.push(line(shown));
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
function showNote(note) {
|
|
46
|
+
return note;
|
|
47
|
+
}
|
|
33
48
|
function defaultNoteLine(note) {
|
|
34
49
|
return `[bash:${note.code}] ${note.message}`;
|
|
35
50
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type { BashInput, BashRequest } from "./contract/input.ts";
|
|
|
2
2
|
export type { BashTool } from "./contract/context.ts";
|
|
3
3
|
export type { CommandRunner, OutputChunk, RunExit, RunHandle, RunRequest, RunStartError, } from "./contract/runner.ts";
|
|
4
4
|
export type { ShellError, ShellErrorCode, ShellFailure, ShellOutput, ShellPhase, ShellReport, ShellResult, ShellRun, ShellRunReport, ShellStatus, } from "./contract/result.ts";
|
|
5
|
-
export type {
|
|
5
|
+
export type { BashToolOptions } from "./contract/deps.ts";
|
|
6
6
|
export type { AfterRunHook, AfterRunUpdate, BeforeRunDecision, BeforeRunHook, PlannedRun, RunOutcome, ShellAuthorizer, ShellAuthorizeTarget, ShellEnv, ShellHookContext, SpillSink, SpillWriter, } from "./contract/extensions.ts";
|
|
7
7
|
export type { ShellFormatContext, ShellFormatter } from "./contract/format.ts";
|
|
8
8
|
export type { ShellLimits } from "./contract/limits.ts";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@better-fs-tools/read";
|
|
2
|
+
import type { BashToolOptions } from "../contract/deps.ts";
|
|
3
|
+
import type { BashInput } from "../contract/input.ts";
|
|
4
|
+
import type { ShellCanonicalParam } from "../contract/messages.ts";
|
|
5
|
+
import type { ShellResult } from "../contract/result.ts";
|
|
6
|
+
import type { BashSignature } from "./bash.ts";
|
|
7
|
+
/** The bash tool bound to its signature. */
|
|
8
|
+
export interface BashToolDefinition<THost = undefined> extends ToolDefinition<BashInput, ShellResult, THost, ShellCanonicalParam> {
|
|
9
|
+
readonly signature: BashSignature;
|
|
10
|
+
}
|
|
11
|
+
/** The bash tool's options, plus the signature the model sees. */
|
|
12
|
+
export type BashToolDefinitionOptions<THost = undefined> = BashToolOptions<THost> & {
|
|
13
|
+
/**
|
|
14
|
+
* Default defaultBashSignature() with the runner's id, when the runner is
|
|
15
|
+
* not a factory, and the tool's limits, so the description names them.
|
|
16
|
+
*/
|
|
17
|
+
readonly signature?: BashSignature;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Creates the bash tool and binds it to a signature. The signature's `param`
|
|
21
|
+
* and `duration` messages go under `options.messages`, so a host message
|
|
22
|
+
* still wins. Throws TypeError where createBashTool() does.
|
|
23
|
+
*/
|
|
24
|
+
export declare function bashToolDefinition<THost = undefined>(options: BashToolDefinitionOptions<THost>): BashToolDefinition<THost>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createBashTool } from "../core/create-bash-tool.js";
|
|
2
|
+
import { parseBashInput } from "../core/input.js";
|
|
3
|
+
import { resolveShellLimits } from "../core/limits.js";
|
|
4
|
+
import { bashSignatureMessages, defaultBashSignature } from "./bash.js";
|
|
5
|
+
/**
|
|
6
|
+
* Creates the bash tool and binds it to a signature. The signature's `param`
|
|
7
|
+
* and `duration` messages go under `options.messages`, so a host message
|
|
8
|
+
* still wins. Throws TypeError where createBashTool() does.
|
|
9
|
+
*/
|
|
10
|
+
export function bashToolDefinition(options) {
|
|
11
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
12
|
+
throw new TypeError("bashToolDefinition options must be an object");
|
|
13
|
+
}
|
|
14
|
+
const { signature: given, ...rest } = options;
|
|
15
|
+
const runnerId = typeof rest.runner === "function" ? undefined : rest.runner?.id;
|
|
16
|
+
const signature = given ??
|
|
17
|
+
defaultBashSignature({
|
|
18
|
+
...(runnerId === undefined ? {} : { runner: runnerId }),
|
|
19
|
+
...(rest.limits === undefined ? {} : { limits: rest.limits }),
|
|
20
|
+
});
|
|
21
|
+
const bash = createBashTool({
|
|
22
|
+
...rest,
|
|
23
|
+
messages: { ...bashSignatureMessages(signature), ...rest.messages },
|
|
24
|
+
});
|
|
25
|
+
const limits = resolveShellLimits(rest.limits);
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
name: signature.name,
|
|
28
|
+
description: signature.description,
|
|
29
|
+
inputSchema: signature.schema,
|
|
30
|
+
...(signature.grammar === undefined ? {} : { grammar: signature.grammar }),
|
|
31
|
+
signature,
|
|
32
|
+
validate(modelInput) {
|
|
33
|
+
const input = signature.toInput(modelInput);
|
|
34
|
+
parseBashInput(input, limits);
|
|
35
|
+
return input;
|
|
36
|
+
},
|
|
37
|
+
execute: async (modelInput, ctx) => bash(signature.toInput(modelInput), ctx),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
/** The `./signature` subpath: the model-facing name, schema, and description of the bash tool. */
|
|
2
2
|
export { bashSignatureMessages, defaultBashSignature } from "./bash.ts";
|
|
3
3
|
export type { BashParam, BashSignature, BashSignatureOptions } from "./bash.ts";
|
|
4
|
+
export { bashToolDefinition } from "./definition.ts";
|
|
5
|
+
export type { BashToolDefinition, BashToolDefinitionOptions } from "./definition.ts";
|
package/dist/signature/index.js
CHANGED
package/docs/architecture.md
CHANGED
|
@@ -23,7 +23,7 @@ formatter.format(report, { mode: "model", call, ... }) ─▶ content
|
|
|
23
23
|
ShellResult
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
| Stage |
|
|
26
|
+
| Stage | Option | What happens |
|
|
27
27
|
| --------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
28
28
|
| Input | `limits` | `parseBashInput` accepts only `command`, `timeoutMs`, and `cwd`. A timeout over `maxTimeoutMs` is clamped with a `clamped` note |
|
|
29
29
|
| Runner | `runner`, `cwd` | A `CommandRunner`, or a factory of the call context. The default cwd is `cwd`, or the runner's `cwd` |
|
|
@@ -63,9 +63,9 @@ A failing output stream, chunks that are not output, or a stream that has not en
|
|
|
63
63
|
|
|
64
64
|
## Host code
|
|
65
65
|
|
|
66
|
-
A throw or a malformed return from host code gives `EXTENSION_FAILED` with the phase of the stage. An `afterRun` failure keeps the run and the output. A formatter failure is the exception: the status stays, the core adds
|
|
66
|
+
A throw or a malformed return from host code gives `EXTENSION_FAILED` with the phase of the stage. An `afterRun` failure keeps the run and the output. A formatter failure is the exception: the status stays, the core adds no note, and `defaultShellFormatter()` makes the content, as in the read and write tools.
|
|
67
67
|
|
|
68
|
-
Every host function gets `ctx`: the tool name, the request, the limits, the messages, `digest`, `clock`, and the call context. The bash tool hashes nothing itself. `digest` and `clock` come from the
|
|
68
|
+
Every host function gets `ctx`: the tool name, the request, the limits, the messages, `digest`, `clock`, and the call context. The bash tool hashes nothing itself. `digest` and `clock` come from the options, so a bundle can give bash the same digest and clock as its file tools.
|
|
69
69
|
|
|
70
70
|
## Runners
|
|
71
71
|
|
package/docs/result-schema.md
CHANGED
|
@@ -91,7 +91,7 @@ The validated canonical input. `timeoutMs` is concrete: the input value clamped
|
|
|
91
91
|
omittedLines: number;
|
|
92
92
|
stdoutBytes: number;
|
|
93
93
|
stderrBytes: number;
|
|
94
|
-
spill: string | null; // the spill sink's reference, or null without a sink
|
|
94
|
+
spill: string | null; // the spill sink's reference, or null without a sink or when the sink failed
|
|
95
95
|
}
|
|
96
96
|
```
|
|
97
97
|
|
|
@@ -111,7 +111,7 @@ stdout and stderr are merged in arrival order. The view is `head`, a gap line, t
|
|
|
111
111
|
| `OUTPUT_CAP` | `run` | The command wrote more than `maxCaptureBytes`, so the core stopped it | set |
|
|
112
112
|
| `EXTENSION_FAILED` | `resolve`, `beforeRun`, `authorize`, `env`, `spill`, `run`, `afterRun` | Host code threw or returned a malformed value. `run` is a runner factory, `resolve` a cwd factory or resolver | set for `afterRun` |
|
|
113
113
|
|
|
114
|
-
`ShellPhase` also has `format`. A formatter failure is not an error:
|
|
114
|
+
`ShellPhase` also has `format`. A formatter failure is not an error: the default formatter runs, no note is added, and the status stays.
|
|
115
115
|
|
|
116
116
|
An `afterRun` hook that fails turns the result into `EXTENSION_FAILED`, and keeps `run`, `output`, and the notes so far. An abort during the run, and `OUTPUT_CAP`, keep them too. The formatter then shows the exit as the status line, and the reason in the error note.
|
|
117
117
|
|
|
@@ -131,14 +131,12 @@ Notes from the core:
|
|
|
131
131
|
| ----------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
132
132
|
| `clamped` | info | The input timeout was over `maxTimeoutMs` | `{ param: "timeoutMs", requested, max }` |
|
|
133
133
|
| `output-incomplete` | warning | The output stream failed, the runner gave chunks that were not output, or the stream had not ended `drainMs` after the exit. Not added after a stop the tool started (timeout, abort, output cap) | `{ skippedChunks, detail?, drainMs? }` |
|
|
134
|
-
| `spill-failed` | warning | The spill sink failed. The command was not affected | none |
|
|
135
134
|
| `unconfirmed-stop` | warning | The runner did not settle its exit in the grace time after a stop | none |
|
|
136
|
-
| `extension-failed` | warning | The formatter failed, and the default formatter ran. The status stays | `{ extension: "formatter", id }` |
|
|
137
135
|
| `invalid-input`, `cwd-not-found`, `cwd-not-a-directory`, `refused`, `denied`, `aborted`, `spawn-failed`, `output-cap`, `extension-failed` | warning | The error note of each error code | none |
|
|
138
136
|
|
|
139
137
|
A resolver can add its own note, for example `path-repaired` from `unicodeRepair()`. An allow decision from the authorizer and a `beforeRun` hook can add notes. They keep their codes.
|
|
140
138
|
|
|
141
|
-
Order: the error note comes first, then `clamped`, then the resolver note, then the `beforeRun` and authorizer notes, then the run notes (`
|
|
139
|
+
Order: the error note comes first, then `clamped`, then the resolver note, then the `beforeRun` and authorizer notes, then the run notes (`unconfirmed-stop`, `output-incomplete`). An `afterRun` hook can replace the notes.
|
|
142
140
|
|
|
143
141
|
## Content
|
|
144
142
|
|
|
@@ -149,3 +147,5 @@ The formatter makes `content` once, with `mode: "model"`. A formatter that retur
|
|
|
149
147
|
3. A blank line, then the note lines.
|
|
150
148
|
|
|
151
149
|
A call that never started prints the note lines only. In `mode: "view"` the notes are left out.
|
|
150
|
+
|
|
151
|
+
`defaultShellFormatter({ notes })` takes a function that rewrites a note, or returns `null` to hide it from the model text. `noteLine(note)` changes the text of one note line. The read and write formatters take the same `notes` option. The report keeps every note.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-fs-tools/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A bash tool for TypeScript agents: a small run core with hooks and defaults.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@better-fs-tools/fs": "^0.
|
|
42
|
-
"@better-fs-tools/read": "^0.
|
|
41
|
+
"@better-fs-tools/fs": "^0.3.0",
|
|
42
|
+
"@better-fs-tools/read": "^0.3.0"
|
|
43
43
|
}
|
|
44
44
|
}
|