@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 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 { createNodeBashTool } from "@better-fs-tools/node";
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 = createNodeBashTool();
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
- | 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 |
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 { createNodeBashTool } from "@better-fs-tools/node";
80
+ import { nodeCommandRunner } from "@better-fs-tools/node";
81
+ import { createBashTool, shellEnv } from "@better-fs-tools/shell";
77
82
 
78
- const bash = createNodeBashTool();
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`, `spill-failed`, `unconfirmed-stop`, `extension-failed`, and the error note of an error result, whose code is the error code in kebab case (`denied`, `aborted`, `output-cap`, and so on).
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`. `createNodeBashTool()`, `createPiBashTool()`, and `createNodeFsTools({ bash: true })` default `env` to `shellEnv(() => process.env)`. `createAiSdkBashTool()` and `createFsTools()` require it, like the core.
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 dependency replaces its default.
129
+ `runner` and `env` are required. `limits` and `messages` merge key by key. Every other option replaces its default.
125
130
 
126
- | Dependency | Default | What a host builds on it |
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` | null | Passed to host functions as `ctx.digest`. The bash tool hashes nothing itself. |
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 { createNodeBashTool } from "@better-fs-tools/node";
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 = createNodeBashTool({
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 { createNodeBashTool } from "@better-fs-tools/node";
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 = createNodeBashTool({ spill: fileSpill });
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 adds an `extension-failed` warning and formats with `defaultShellFormatter()`, as the read and write tools do. The format context has `digest`, `limits`, `messages`, `mode`, and `call`.
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 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.
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
@@ -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 null. */
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 dependency replaces its default.
36
+ * Every other option replaces its default.
37
37
  */
38
- export type ShellToolDeps<THost = undefined> = {
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 dependency. */
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. A throw, or a return that
18
- * is neither a string nor an array, adds an extension-failed warning, and the
19
- * default formatter formats the report. The status stays.
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 { ShellToolDeps } from "../contract/deps.ts";
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 dependency. `limits` and `messages` merge key by key.
6
- * Every other dependency replaces its default.
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>(deps: ShellToolDeps<THost>): BashTool<THost>;
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 dependency. `limits` and `messages` merge key by key.
7
- * Every other dependency replaces its default.
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(deps) {
10
- const resolved = resolveShellDependencies(deps);
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");
@@ -1,5 +1,5 @@
1
- import type { ShellDependencies, ShellToolDeps } from "../contract/deps.ts";
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: ShellToolDeps<THost>): ShellDependencies<THost>;
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 dependencies must be an object");
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 dependency: ${key}`);
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 ?? null;
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");
@@ -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 dependency for any host: `base` with defaultShellEnv over it.
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 dependency for any host: `base` with defaultShellEnv over it.
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.
@@ -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 { deps } = scope;
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;
@@ -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})`]),
@@ -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, extensionId, info, isNoteList, messageOf, warning } from "./stop.js";
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
- ...kept,
176
- content: toContent(defaultShellFormatter().format(kept, context)) ?? [],
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.map(noteLine);
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 { ShellDependencies, ShellToolDeps } from "./contract/deps.ts";
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";
@@ -1,2 +1,3 @@
1
1
  /** The `./signature` subpath: the model-facing name, schema, and description of the bash tool. */
2
2
  export { bashSignatureMessages, defaultBashSignature } from "./bash.js";
3
+ export { bashToolDefinition } from "./definition.js";
@@ -23,7 +23,7 @@ formatter.format(report, { mode: "model", call, ... }) ─▶ content
23
23
  ShellResult
24
24
  ```
25
25
 
26
- | Stage | Dependency | What happens |
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 an `extension-failed` warning, and `defaultShellFormatter()` makes the content, as in the read and write tools.
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 dependencies, so a bundle can give bash the same digest and clock as its file tools.
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
 
@@ -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: it gives an `extension-failed` warning, and the status stays.
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 (`spill-failed`, `unconfirmed-stop`, `output-incomplete`). An `afterRun` hook can replace the notes. A formatter failure adds its `extension-failed` note at the very end.
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.1.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.1.0",
42
- "@better-fs-tools/read": "^0.1.0"
41
+ "@better-fs-tools/fs": "^0.3.0",
42
+ "@better-fs-tools/read": "^0.3.0"
43
43
  }
44
44
  }