@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
@@ -0,0 +1,20 @@
1
+ import type { Note, ToolCallContext } from "@better-fs-tools/read";
2
+ import type { ShellDependencies } from "../contract/deps.ts";
3
+ import type { PlannedRun, ShellHookContext } from "../contract/extensions.ts";
4
+ import type { ShellPhase } from "../contract/result.ts";
5
+ import type { CommandRunner } from "../contract/runner.ts";
6
+ import { StageStop } from "./stop.ts";
7
+ export interface CallScope<THost> {
8
+ readonly deps: ShellDependencies<THost>;
9
+ readonly call: ToolCallContext<THost>;
10
+ readonly notes: Note[];
11
+ readonly ctx: ShellHookContext<THost>;
12
+ }
13
+ export declare function extensionFailure<THost>(scope: Pick<CallScope<THost>, "deps">, extension: unknown, phase: ShellPhase): StageStop;
14
+ export declare function runnerFor<THost>(deps: ShellDependencies<THost>, call: ToolCallContext<THost>): CommandRunner;
15
+ export declare function baseCwd<THost>(deps: ShellDependencies<THost>, call: ToolCallContext<THost>, runner: CommandRunner): string;
16
+ export declare function resolveCwd<THost>(scope: CallScope<THost>, base: string, requested: string | null): Promise<string>;
17
+ export declare function beforeRun<THost>(scope: CallScope<THost>, planned: PlannedRun): Promise<PlannedRun>;
18
+ export declare function authorize<THost>(scope: CallScope<THost>, run: PlannedRun, base: string): Promise<void>;
19
+ export declare function relativePosix(base: string, path: string): string;
20
+ export declare function environment<THost>(scope: CallScope<THost>, run: PlannedRun): Promise<Readonly<Record<string, string>>>;
@@ -0,0 +1,166 @@
1
+ import { containsPosix, posixPaths, resolvePosix } from "@better-fs-tools/fs";
2
+ import { AbortStop, raceAbort } from "./abort.js";
3
+ import { isAbsolute, isRunner } from "./deps.js";
4
+ import { isRecord } from "./input.js";
5
+ import { StageStop, errorNote, extensionId, hostErrorNote, isNote, isNoteList } from "./stop.js";
6
+ export function extensionFailure(scope, extension, phase) {
7
+ const message = scope.deps.messages.extensionFailed({ extension: extensionId(extension), phase });
8
+ return new StageStop("EXTENSION_FAILED", phase, errorNote("EXTENSION_FAILED", message));
9
+ }
10
+ async function hostCall(scope, extension, phase, start) {
11
+ try {
12
+ return await raceAbort(start, scope.call.signal);
13
+ }
14
+ catch (error) {
15
+ if (error instanceof AbortStop)
16
+ throw new AbortStop(phase);
17
+ throw extensionFailure(scope, extension, phase);
18
+ }
19
+ }
20
+ export function runnerFor(deps, call) {
21
+ const { runner } = deps;
22
+ if (typeof runner !== "function")
23
+ return runner;
24
+ let made;
25
+ try {
26
+ made = runner(call);
27
+ }
28
+ catch {
29
+ throw extensionFailure({ deps }, { id: "runner" }, "run");
30
+ }
31
+ if (!isRunner(made))
32
+ throw extensionFailure({ deps }, { id: "runner" }, "run");
33
+ return made;
34
+ }
35
+ export function baseCwd(deps, call, runner) {
36
+ const { cwd } = deps;
37
+ if (cwd === null)
38
+ return runner.cwd;
39
+ if (typeof cwd === "string")
40
+ return cwd;
41
+ let made;
42
+ try {
43
+ made = cwd(call);
44
+ }
45
+ catch {
46
+ throw extensionFailure({ deps }, { id: "cwd" }, "resolve");
47
+ }
48
+ if (!isAbsolute(made))
49
+ throw extensionFailure({ deps }, { id: "cwd" }, "resolve");
50
+ return made;
51
+ }
52
+ export async function resolveCwd(scope, base, requested) {
53
+ if (requested === null)
54
+ return base;
55
+ const resolver = scope.deps.resolve;
56
+ if (resolver === null)
57
+ return resolvePosix(base, requested);
58
+ const ctx = {
59
+ ...scope.ctx,
60
+ paths: posixPaths,
61
+ list: async () => ({
62
+ ok: false,
63
+ error: { reason: "unsupported", detail: "the bash tool cannot list" },
64
+ }),
65
+ };
66
+ const outcome = await hostCall(scope, resolver, "resolve", () => resolver.resolve(requested, ctx));
67
+ const malformed = () => extensionFailure(scope, resolver, "resolve");
68
+ if (!isRecord(outcome))
69
+ throw malformed();
70
+ const { note } = outcome;
71
+ if (note !== undefined && !isNote(note))
72
+ throw malformed();
73
+ if (note !== undefined)
74
+ scope.notes.push(note);
75
+ if (outcome.kind === "not-found") {
76
+ const message = scope.deps.messages.cwdNotFound({ cwd: requested });
77
+ throw new StageStop("CWD_NOT_FOUND", "resolve", errorNote("CWD_NOT_FOUND", message));
78
+ }
79
+ const { path } = outcome;
80
+ if (outcome.kind !== "path" || typeof path !== "string" || path.trim() === "")
81
+ throw malformed();
82
+ return resolvePosix(base, path);
83
+ }
84
+ export async function beforeRun(scope, planned) {
85
+ let run = planned;
86
+ for (const hook of scope.deps.beforeRun) {
87
+ const current = run;
88
+ const decision = await hostCall(scope, hook, "beforeRun", () => hook.beforeRun(current, scope.ctx));
89
+ const malformed = () => extensionFailure(scope, hook, "beforeRun");
90
+ if (!isRecord(decision))
91
+ throw malformed();
92
+ if (decision.allow === false) {
93
+ if (decision.note !== undefined && !isNote(decision.note))
94
+ throw malformed();
95
+ const note = decision.note === undefined
96
+ ? errorNote("REFUSED", scope.deps.messages.refused({ hook: hook.id }))
97
+ : hostErrorNote("REFUSED", decision.note);
98
+ throw new StageStop("REFUSED", "beforeRun", note);
99
+ }
100
+ if (decision.allow !== true)
101
+ throw malformed();
102
+ const { command, notes } = decision;
103
+ if (notes !== undefined && !isNoteList(notes))
104
+ throw malformed();
105
+ if (command !== undefined) {
106
+ if (typeof command !== "string" || command.trim() === "" || command.includes("\u0000")) {
107
+ throw malformed();
108
+ }
109
+ run = { ...run, command };
110
+ }
111
+ scope.notes.push(...(notes ?? []));
112
+ }
113
+ return run;
114
+ }
115
+ export async function authorize(scope, run, base) {
116
+ const authorizer = scope.deps.authorize;
117
+ if (authorizer === null)
118
+ return;
119
+ const target = {
120
+ action: "run",
121
+ requestedPath: scope.ctx.request.cwd ?? ".",
122
+ resolvedPath: run.cwd,
123
+ displayPath: relativePosix(base, run.cwd),
124
+ command: run.command,
125
+ cwd: run.cwd,
126
+ timeoutMs: run.timeoutMs,
127
+ };
128
+ const decision = await hostCall(scope, authorizer, "authorize", () => authorizer.authorize(target, scope.ctx));
129
+ const malformed = () => extensionFailure(scope, authorizer, "authorize");
130
+ if (!isRecord(decision))
131
+ throw malformed();
132
+ if (decision.allow === true) {
133
+ if (decision.notes !== undefined && !isNoteList(decision.notes))
134
+ throw malformed();
135
+ scope.notes.push(...(decision.notes ?? []));
136
+ return;
137
+ }
138
+ if (decision.allow !== false)
139
+ throw malformed();
140
+ if (decision.note !== undefined && !isNote(decision.note))
141
+ throw malformed();
142
+ const note = decision.note === undefined
143
+ ? errorNote("DENIED", scope.deps.messages.denied({ path: run.cwd, detail: null }))
144
+ : hostErrorNote("DENIED", decision.note);
145
+ throw new StageStop("DENIED", "authorize", note);
146
+ }
147
+ export function relativePosix(base, path) {
148
+ if (path === base)
149
+ return ".";
150
+ if (containsPosix(base, path))
151
+ return path.slice(base === "/" ? 1 : base.length + 1);
152
+ const from = base.split("/").filter(Boolean);
153
+ const to = path.split("/").filter(Boolean);
154
+ let shared = 0;
155
+ while (shared < from.length && shared < to.length && from[shared] === to[shared])
156
+ shared += 1;
157
+ return [...from.slice(shared).map(() => ".."), ...to.slice(shared)].join("/");
158
+ }
159
+ export async function environment(scope, run) {
160
+ const { env } = scope.deps;
161
+ const made = await hostCall(scope, { id: "env" }, "env", () => env(run, scope.ctx));
162
+ if (!isRecord(made) || !Object.values(made).every((value) => typeof value === "string")) {
163
+ throw extensionFailure(scope, { id: "env" }, "env");
164
+ }
165
+ return made;
166
+ }
@@ -0,0 +1,17 @@
1
+ import type { JsonObject, Note } from "@better-fs-tools/read";
2
+ import type { ShellErrorCode, ShellPhase } from "../contract/result.ts";
3
+ export declare class StageStop extends Error {
4
+ readonly code: ShellErrorCode;
5
+ readonly phase: ShellPhase;
6
+ readonly note: Note;
7
+ constructor(code: ShellErrorCode, phase: ShellPhase, note: Note);
8
+ }
9
+ export declare function warning(code: string, message: string, data?: JsonObject): Note;
10
+ export declare function info(code: string, message: string, data?: JsonObject): Note;
11
+ export declare function noteCode(code: ShellErrorCode): string;
12
+ export declare function errorNote(code: ShellErrorCode, message: string, data?: JsonObject): Note;
13
+ export declare function hostErrorNote(code: ShellErrorCode, note: Note): Note;
14
+ export declare function isNote(value: unknown): value is Note;
15
+ export declare function isNoteList(value: unknown): value is readonly Note[];
16
+ export declare function messageOf(error: unknown): string;
17
+ export declare function extensionId(extension: unknown): string;
@@ -0,0 +1,50 @@
1
+ export class StageStop extends Error {
2
+ code;
3
+ phase;
4
+ note;
5
+ constructor(code, phase, note) {
6
+ super(code);
7
+ this.code = code;
8
+ this.phase = phase;
9
+ this.note = note;
10
+ }
11
+ }
12
+ export function warning(code, message, data) {
13
+ return data === undefined
14
+ ? { code, severity: "warning", message }
15
+ : { code, severity: "warning", message, data };
16
+ }
17
+ export function info(code, message, data) {
18
+ return data === undefined
19
+ ? { code, severity: "info", message }
20
+ : { code, severity: "info", message, data };
21
+ }
22
+ export function noteCode(code) {
23
+ return code.toLowerCase().replaceAll("_", "-");
24
+ }
25
+ export function errorNote(code, message, data) {
26
+ return warning(noteCode(code), message, data);
27
+ }
28
+ export function hostErrorNote(code, note) {
29
+ const kebab = noteCode(code);
30
+ return errorNote(code, note.message, note.code === kebab ? note.data : { ...note.data, source: note.code });
31
+ }
32
+ export function isNote(value) {
33
+ if (value === null || typeof value !== "object" || Array.isArray(value))
34
+ return false;
35
+ const note = value;
36
+ return (typeof note.code === "string" &&
37
+ typeof note.message === "string" &&
38
+ (note.severity === "info" || note.severity === "warning"));
39
+ }
40
+ export function isNoteList(value) {
41
+ return Array.isArray(value) && value.every(isNote);
42
+ }
43
+ export function messageOf(error) {
44
+ return error instanceof Error ? error.message : String(error);
45
+ }
46
+ export function extensionId(extension) {
47
+ if (extension === null || typeof extension !== "object" || !("id" in extension))
48
+ return "unknown";
49
+ return typeof extension.id === "string" ? extension.id : "unknown";
50
+ }
@@ -0,0 +1,20 @@
1
+ import type { Note } from "@better-fs-tools/read";
2
+ import type { ShellFormatter } from "../contract/format.ts";
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;
6
+ /** Default: `[bash:${note.code}] ${note.message}`. */
7
+ readonly noteLine?: (note: Note) => string;
8
+ }
9
+ /**
10
+ * The status line, then the output view, then a blank line and the note
11
+ * lines. A call that did not run prints the note lines only. "view" mode
12
+ * leaves out the notes. The `notes` option hides or rewrites a note, as the
13
+ * read tool's formatters do.
14
+ *
15
+ * ```text
16
+ * Exit code 1 · 0.4 s
17
+ * <output>
18
+ * ```
19
+ */
20
+ export declare function defaultShellFormatter(options?: ShellFormatterOptions): ShellFormatter<unknown>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The status line, then the output view, then a blank line and the note
3
+ * lines. A call that did not run prints the note lines only. "view" mode
4
+ * leaves out the notes. The `notes` option hides or rewrites a note, as the
5
+ * read tool's formatters do.
6
+ *
7
+ * ```text
8
+ * Exit code 1 · 0.4 s
9
+ * <output>
10
+ * ```
11
+ */
12
+ export function defaultShellFormatter(options = {}) {
13
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
14
+ throw new TypeError("defaultShellFormatter options must be an object");
15
+ }
16
+ const { notes: filter = showNote, noteLine = defaultNoteLine } = options;
17
+ if (typeof filter !== "function")
18
+ throw new TypeError("notes must be a function");
19
+ if (typeof noteLine !== "function")
20
+ throw new TypeError("noteLine must be a function");
21
+ return Object.freeze({
22
+ id: "default",
23
+ format(report, ctx) {
24
+ const main = body(report, ctx.messages);
25
+ if (ctx.mode === "view")
26
+ return main.join("\n");
27
+ const notes = noteLines(report.notes, filter, noteLine);
28
+ if (notes.length === 0)
29
+ return main.join("\n");
30
+ if (main.length === 0)
31
+ return notes.join("\n");
32
+ return `${main.join("\n")}\n\n${notes.join("\n")}`;
33
+ },
34
+ });
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
+ }
48
+ function defaultNoteLine(note) {
49
+ return `[bash:${note.code}] ${note.message}`;
50
+ }
51
+ function body(report, messages) {
52
+ const { run, output } = report;
53
+ if (run === null)
54
+ return [];
55
+ return [statusLine(run, messages), ...outputLines(output, messages)];
56
+ }
57
+ function statusLine(run, messages) {
58
+ if (run.stoppedBy === "timeout")
59
+ return messages.timedOut({ timeoutMs: run.timeoutMs });
60
+ return messages.exited({ code: run.exitCode, signal: run.signal, durationMs: run.durationMs });
61
+ }
62
+ function outputLines(output, messages) {
63
+ if (output === null || (output.head === "" && output.tail === null))
64
+ return [messages.noOutput()];
65
+ if (output.tail === null)
66
+ return [output.head];
67
+ const gap = messages.omitted({
68
+ lines: output.omittedLines,
69
+ bytes: output.omittedBytes,
70
+ spill: output.spill,
71
+ });
72
+ return [
73
+ ...(output.head === "" ? [] : [output.head]),
74
+ gap,
75
+ ...(output.tail === "" ? [] : [output.tail]),
76
+ ];
77
+ }
@@ -0,0 +1,2 @@
1
+ export { defaultShellFormatter } from "./default.ts";
2
+ export type { ShellFormatterOptions } from "./default.ts";
@@ -0,0 +1 @@
1
+ export { defaultShellFormatter } from "./default.js";
@@ -0,0 +1,17 @@
1
+ export type { BashInput, BashRequest } from "./contract/input.ts";
2
+ export type { BashTool } from "./contract/context.ts";
3
+ export type { CommandRunner, OutputChunk, RunExit, RunHandle, RunRequest, RunStartError, } from "./contract/runner.ts";
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";
6
+ export type { AfterRunHook, AfterRunUpdate, BeforeRunDecision, BeforeRunHook, PlannedRun, RunOutcome, ShellAuthorizer, ShellAuthorizeTarget, ShellEnv, ShellHookContext, SpillSink, SpillWriter, } from "./contract/extensions.ts";
7
+ export type { ShellFormatContext, ShellFormatter } from "./contract/format.ts";
8
+ export type { ShellLimits } from "./contract/limits.ts";
9
+ export type { ShellCanonicalParam, ShellMessageCatalog } from "./contract/messages.ts";
10
+ export { createBashTool } from "./core/create-bash-tool.ts";
11
+ export { parseBashInput } from "./core/input.ts";
12
+ export { defaultShellLimits, resolveShellLimits } from "./core/limits.ts";
13
+ export { defaultShellMessages, formatBytes, formatDuration, resolveShellMessages, } from "./core/messages.ts";
14
+ export { defaultShellEnv, shellEnv } from "./core/env.ts";
15
+ export { defaultShellFormatter } from "./formatters/index.ts";
16
+ export type { ShellFormatterOptions } from "./formatters/index.ts";
17
+ export { textOf } from "@better-fs-tools/read";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createBashTool } from "./core/create-bash-tool.js";
2
+ export { parseBashInput } from "./core/input.js";
3
+ export { defaultShellLimits, resolveShellLimits } from "./core/limits.js";
4
+ export { defaultShellMessages, formatBytes, formatDuration, resolveShellMessages, } from "./core/messages.js";
5
+ export { defaultShellEnv, shellEnv } from "./core/env.js";
6
+ export { defaultShellFormatter } from "./formatters/index.js";
7
+ export { textOf } from "@better-fs-tools/read";
@@ -0,0 +1,28 @@
1
+ import type { SignatureDocs, ToolSignature } from "@better-fs-tools/read";
2
+ import type { BashInput } from "../contract/input.ts";
3
+ import type { ShellLimits } from "../contract/limits.ts";
4
+ import type { ShellCanonicalParam, ShellMessageCatalog } from "../contract/messages.ts";
5
+ /** The bash tool's model-facing signature, with `duration` for the timeout unit. */
6
+ export interface BashSignature extends ToolSignature<BashInput, ShellCanonicalParam> {
7
+ /** A duration in the unit of the timeout parameter, for messages. */
8
+ duration(ms: number): string;
9
+ }
10
+ /** The parameter names of `defaultBashSignature`. */
11
+ export type BashParam = "command" | "timeout" | "cwd";
12
+ export interface BashSignatureOptions extends SignatureDocs<BashParam> {
13
+ /** Unit of the timeout parameter. Default "ms". */
14
+ readonly timeoutUnit?: "ms" | "s";
15
+ /** Add the cwd parameter. Default true. With false, `names.cwd` and `describe.cwd` are ignored. */
16
+ readonly cwd?: boolean;
17
+ /** Put the runner id in the description, for example "just-bash (emulated)". */
18
+ readonly runner?: string;
19
+ /** The tool's limits, so the description names the right timeouts. Default defaultShellLimits. */
20
+ readonly limits?: Partial<ShellLimits>;
21
+ }
22
+ /**
23
+ * bash({ command, timeout?, cwd? }). The timeout unit is milliseconds unless
24
+ * `timeoutUnit` is "s".
25
+ */
26
+ export declare function defaultBashSignature(options?: BashSignatureOptions): BashSignature;
27
+ /** The parts of the message catalog a signature owns: parameter names and the timeout unit. */
28
+ export declare function bashSignatureMessages(signature: BashSignature): Pick<ShellMessageCatalog, "param" | "duration">;
@@ -0,0 +1,158 @@
1
+ import { resolveShellLimits } from "../core/limits.js";
2
+ import { formatDuration } from "../core/messages.js";
3
+ const ALL_PARAMS = ["command", "timeout", "cwd"];
4
+ /**
5
+ * bash({ command, timeout?, cwd? }). The timeout unit is milliseconds unless
6
+ * `timeoutUnit` is "s".
7
+ */
8
+ export function defaultBashSignature(options = {}) {
9
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
10
+ throw new TypeError("defaultBashSignature options must be an object");
11
+ }
12
+ const { name = "bash", describe = {}, names = {}, timeoutUnit = "ms", cwd: withCwd = true, runner, } = options;
13
+ if (typeof name !== "string" || name.trim() === "") {
14
+ throw new TypeError("defaultBashSignature name must be a non-blank string");
15
+ }
16
+ if (timeoutUnit !== "ms" && timeoutUnit !== "s") {
17
+ throw new TypeError('defaultBashSignature timeoutUnit must be "ms" or "s"');
18
+ }
19
+ if (typeof withCwd !== "boolean")
20
+ throw new TypeError("defaultBashSignature cwd must be a boolean");
21
+ if (runner !== undefined && typeof runner !== "string") {
22
+ throw new TypeError("defaultBashSignature runner must be a string");
23
+ }
24
+ if (options.description !== undefined && typeof options.description !== "string") {
25
+ throw new TypeError("defaultBashSignature description must be a string");
26
+ }
27
+ const enabledParams = withCwd
28
+ ? ["command", "timeout", "cwd"]
29
+ : ["command", "timeout"];
30
+ checkKeys(describe, "describe", ALL_PARAMS);
31
+ checkKeys(names, "names", ALL_PARAMS);
32
+ const host = {};
33
+ for (const param of enabledParams) {
34
+ const value = names[param] ?? param;
35
+ if (value.trim() === "")
36
+ throw new TypeError(`names.${param} must be a non-blank string`);
37
+ if (Object.values(host).includes(value)) {
38
+ throw new TypeError(`Parameter name ${value} is used twice`);
39
+ }
40
+ host[param] = value;
41
+ }
42
+ const params = enabledParams.map((param) => host[param]);
43
+ const limits = resolveShellLimits(options.limits);
44
+ const scale = timeoutUnit === "s" ? 1_000 : 1;
45
+ const unitWord = timeoutUnit === "s" ? "seconds" : "milliseconds";
46
+ const inUnit = (ms) => `${ms / scale} ${unitWord}`;
47
+ const properties = [
48
+ [
49
+ host.command,
50
+ {
51
+ type: "string",
52
+ minLength: 1,
53
+ description: describe.command ?? "The shell command to run with bash -c.",
54
+ },
55
+ ],
56
+ [
57
+ host.timeout,
58
+ {
59
+ type: "number",
60
+ exclusiveMinimum: 0,
61
+ description: describe.timeout ??
62
+ `Timeout in ${unitWord}. Default ${inUnit(limits.defaultTimeoutMs)}, maximum ${inUnit(limits.maxTimeoutMs)}.`,
63
+ },
64
+ ],
65
+ ];
66
+ if (withCwd) {
67
+ properties.push([
68
+ host.cwd,
69
+ {
70
+ type: "string",
71
+ minLength: 1,
72
+ description: describe.cwd ??
73
+ "Working directory for this command, relative to the default directory or absolute. Use it instead of cd.",
74
+ },
75
+ ]);
76
+ }
77
+ const schema = deepFreeze({
78
+ type: "object",
79
+ additionalProperties: false,
80
+ properties: Object.fromEntries(properties),
81
+ required: [host.command],
82
+ });
83
+ const description = options.description ??
84
+ [
85
+ "Run a shell command with bash -c and return its exit code and output.",
86
+ "Each call starts a new shell: a cd, an export, or a variable does not carry to the next call.",
87
+ "stdin is closed, so a command that waits for input gets end of file. Do not start interactive programs.",
88
+ `The command stops after ${formatDuration(limits.defaultTimeoutMs)} unless you set a timeout. The whole process tree is stopped at the timeout.`,
89
+ `stdout and stderr are merged. Long output keeps the first and the last lines, up to ${limits.maxOutputLines} lines or ${limits.maxOutputBytes} bytes.`,
90
+ "Prefer the read, edit, and write tools for files.",
91
+ ...(runner === undefined ? [] : [`Commands run in: ${runner}.`]),
92
+ ].join(" ");
93
+ const canonical = {
94
+ command: host.command,
95
+ timeoutMs: host.timeout,
96
+ cwd: withCwd ? host.cwd : "",
97
+ };
98
+ return Object.freeze({
99
+ name,
100
+ description,
101
+ schema,
102
+ toInput(input) {
103
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
104
+ throw new TypeError(`bash input must be an object with ${params.join(", ")}`);
105
+ }
106
+ const record = input;
107
+ for (const key of Object.keys(record)) {
108
+ if (!params.includes(key)) {
109
+ throw new TypeError(`Unknown bash input key: ${key}. Expected ${params.join(", ")}`);
110
+ }
111
+ }
112
+ const command = record[host.command];
113
+ const timeout = record[host.timeout];
114
+ const cwd = withCwd ? record[host.cwd] : undefined;
115
+ if (typeof command !== "string" || command.trim() === "") {
116
+ throw new TypeError(`${host.command} must be a non-blank string`);
117
+ }
118
+ if (timeout !== undefined &&
119
+ (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
120
+ throw new TypeError(`${host.timeout} must be a positive number`);
121
+ }
122
+ if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
123
+ throw new TypeError(`${host.cwd} must be a non-blank string`);
124
+ }
125
+ return {
126
+ command,
127
+ ...(timeout === undefined ? {} : { timeoutMs: timeout * scale }),
128
+ ...(cwd === undefined ? {} : { cwd: cwd }),
129
+ };
130
+ },
131
+ param: (name) => canonical[name],
132
+ duration: inUnit,
133
+ });
134
+ }
135
+ /** The parts of the message catalog a signature owns: parameter names and the timeout unit. */
136
+ export function bashSignatureMessages(signature) {
137
+ return { param: signature.param, duration: signature.duration };
138
+ }
139
+ function checkKeys(map, key, params) {
140
+ if (map === null || typeof map !== "object" || Array.isArray(map)) {
141
+ throw new TypeError(`defaultBashSignature ${key} must be an object`);
142
+ }
143
+ for (const [param, value] of Object.entries(map)) {
144
+ if (!params.includes(param)) {
145
+ throw new TypeError(`Unknown parameter in ${key}: ${param}. Expected ${params.join(", ")}`);
146
+ }
147
+ if (typeof value !== "string")
148
+ throw new TypeError(`${key}.${param} must be a string`);
149
+ }
150
+ }
151
+ function deepFreeze(value) {
152
+ if (value !== null && typeof value === "object") {
153
+ for (const child of Object.values(value))
154
+ deepFreeze(child);
155
+ Object.freeze(value);
156
+ }
157
+ return value;
158
+ }
@@ -0,0 +1,3 @@
1
+ /** The `./signature` subpath: the model-facing name, schema, and description of the bash tool. */
2
+ export { bashSignatureMessages, defaultBashSignature } from "./bash.ts";
3
+ export type { BashParam, BashSignature, BashSignatureOptions } from "./bash.ts";
@@ -0,0 +1,2 @@
1
+ /** The `./signature` subpath: the model-facing name, schema, and description of the bash tool. */
2
+ export { bashSignatureMessages, defaultBashSignature } from "./bash.js";
@@ -0,0 +1,74 @@
1
+ # Architecture
2
+
3
+ This page explains how `@better-fs-tools/shell` runs one `bash` call, and why it is built this way. [result-schema.md](result-schema.md) describes the result. The [README](../README.md) shows how to configure each step.
4
+
5
+ ## The pipeline
6
+
7
+ ```text
8
+ bash(input, ctx?)
9
+ │ parseBashInput ........... strict canonical validation, timeout clamp phase input
10
+ │ runner(call), cwd(call) .. once; a runner and a default cwd, or factories phase run, resolve
11
+ ▼
12
+ BashRequest { command, timeoutMs, cwd }
13
+ │ resolve .................. the requested cwd string phase resolve
14
+ │ beforeRun ................ checks and rewrites, in order phase beforeRun
15
+ │ authorize ................ host policy on the final command phase authorize
16
+ │ env ...................... the whole environment of the command phase env
17
+ │ spill.open ............... optional sink for every output byte phase spill
18
+ │ runner.run ............... start, capture, timeout, abort, output cap phase run
19
+ │ afterRun ................. output and note changes, in order phase afterRun
20
+ ▼
21
+ formatter.format(report, { mode: "model", call, ... }) ─▶ content
22
+ ▼
23
+ ShellResult
24
+ ```
25
+
26
+ | Stage | Dependency | What happens |
27
+ | --------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
28
+ | Input | `limits` | `parseBashInput` accepts only `command`, `timeoutMs`, and `cwd`. A timeout over `maxTimeoutMs` is clamped with a `clamped` note |
29
+ | Runner | `runner`, `cwd` | A `CommandRunner`, or a factory of the call context. The default cwd is `cwd`, or the runner's `cwd` |
30
+ | Resolve | `resolve` | A read tool resolver may change the requested cwd string, or report it not found (`CWD_NOT_FOUND`) |
31
+ | beforeRun | `beforeRun` | Each hook returns `{ allow: true, command?, notes? }` or `{ allow: false, note? }`. A rewrite feeds the next hook |
32
+ | Authorize | `authorize` | One target with the final command, the absolute cwd, and a display path relative to the default cwd. A denial is `DENIED` |
33
+ | Env | `env` | The host function returns the whole environment. Nothing else reaches the command |
34
+ | Run | `runner`, `spill` | The runner starts the command. The core reads the output into a bounded capture, and stops the process tree when it must |
35
+ | afterRun | `afterRun` | Each hook returns `{ output?, notes? }`. The status, the error, and the run stay as the core set them |
36
+ | Format | `formatter`, `messages` | Turns the report into content parts |
37
+
38
+ The core owns the stage order, the timeout, the abort, the output budget and view, the status, and note assembly. Policy, rewrites, the environment, and where output is saved belong to the host.
39
+
40
+ ## Why `authorize` runs after `beforeRun`
41
+
42
+ A `beforeRun` hook can rewrite the command, for example to add a flag or wrap it in a sandbox call. The authorizer sees the command after every rewrite, so an approval prompt shows the command that runs. A `beforeRun` refusal stops the call before `authorize` runs.
43
+
44
+ ## `env` is required
45
+
46
+ The core has no default environment. A default of `process.env` would leak the host's secrets on every host, and an empty default would break `PATH` and `HOME` without a sign. The host chooses once: `shellEnv(() => process.env)`, an allow list such as `shellEnv({ PATH, HOME })`, or `shellEnv()` for `defaultShellEnv` alone. The Node and Pi factories default it to `shellEnv(() => process.env)`, because they run a local shell.
47
+
48
+ ## Stopping a command
49
+
50
+ A timeout, the caller's abort, and the capture cap all abort the runner's signal. The runner must then stop the whole process tree: SIGTERM, and SIGKILL after `killGraceMs`. The core waits `killGraceMs` plus one second for the exit. When the runner does not settle it, the run has `unconfirmedStop: true` and an `unconfirmed-stop` warning.
51
+
52
+ - A timeout gives `status: "timeout"` and keeps the output.
53
+ - An abort gives `error` with `ABORTED`. Before the start, `run` and `output` are null.
54
+ - The capture cap gives `error` with `OUTPUT_CAP`, and keeps the run and the output.
55
+
56
+ After a normal exit, the core reads output for one more second, then lets go of the stream. A background child that holds the pipes open keeps running.
57
+
58
+ ## Output
59
+
60
+ The core keeps at most `maxCaptureBytes` in memory. The model view is at most `maxOutputBytes` and `maxOutputLines`: a head of `headPercent`, then a gap line, then the tail. Errors are usually at the end, so the tail gets most of it. A spill sink gets every byte in arrival order, and its reference goes into the gap line.
61
+
62
+ A failing output stream, chunks that are not output, or a stream that has not ended one second after the exit, end the capture with an `output-incomplete` warning. After that deadline the core lets go of the stream and captures no later chunk. The status still comes from the exit. A stop the tool started (timeout, abort, output cap) has its own note and adds no `output-incomplete` for the stream it cut.
63
+
64
+ ## Host code
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 no note, and `defaultShellFormatter()` makes the content, as in the read and write tools.
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.
69
+
70
+ ## Runners
71
+
72
+ A runner starts one command and never throws for a failed command. `run(request)` gets the command, an absolute cwd, the whole environment, an abort signal, and `killGraceMs`. It returns `output`, an async iterable of `{ stream, bytes }` chunks, and `exit`, a promise that never rejects. A start that fails is an exit with `code: null` and an `error` reason: `cwd-not-found`, `cwd-not-a-directory`, or `spawn-failed`.
73
+
74
+ `nodeCommandRunner()` in `@better-fs-tools/node` runs `bash -c` in its own process group. `justBashCommandRunner()` in `@better-fs-tools/just-bash` runs an emulated shell with no process.