@aliou/pi-processes 0.4.6 → 0.5.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
@@ -20,8 +20,8 @@ pi install git:github.com/aliou/pi-processes
20
20
 
21
21
  ## Features
22
22
 
23
- - **Tool**: `processes` with actions: `start`, `list`, `output`, `logs`, `kill`, `clear`
24
- - **Command**: `/processes` - interactive panel to view and manage processes
23
+ - **Tool**: `process` with actions: `start`, `list`, `output`, `logs`, `kill`, `clear`
24
+ - **Commands**: `/process:list` (interactive panel), `/process:stream` (stream logs), `/process:logs` (log paths), `/process:kill` (kill process), `/process:clear` (clear finished)
25
25
  - Auto-cleanup on session exit
26
26
  - File-based logging (logs written to temp files, not memory)
27
27
  - Friendly process names (auto-inferred or custom)
@@ -31,48 +31,55 @@ pi install git:github.com/aliou/pi-processes
31
31
  ### Tool (for agent)
32
32
 
33
33
  ```
34
- processes start "pnpm dev" name="backend-dev"
35
- processes start "pnpm build" name="build" notifyOnSuccess=true
36
- processes start "pnpm test" notifyOnFailure=true
37
- processes list
38
- processes output id="backend"
39
- processes logs id="proc_1"
40
- processes kill id="backend"
41
- processes clear
34
+ process start "pnpm dev" name="backend-dev"
35
+ process start "pnpm build" name="build" alertOnSuccess=true
36
+ process start "pnpm test" alertOnFailure=true
37
+ process list
38
+ process output id="backend"
39
+ process logs id="proc_1"
40
+ process kill id="backend"
41
+ process clear
42
42
  ```
43
43
 
44
- **Notification parameters** (for `start` action):
45
- - `notifyOnSuccess` (default: false) - Get notified when process completes successfully. Use for builds/tests where you need confirmation.
46
- - `notifyOnFailure` (default: true) - Get notified when process crashes/fails. Use to be alerted of unexpected failures.
47
- - `notifyOnKill` (default: false) - Get notified if killed by external signal. Note: killing via tool never notifies.
44
+ **Alert parameters** (for `start` action):
45
+ - `alertOnSuccess` (default: false) - Get a turn to react when process completes successfully. Use for builds/tests where you need confirmation.
46
+ - `alertOnFailure` (default: true) - Get a turn to react when process fails/crashes. Use to be alerted of unexpected failures.
47
+ - `alertOnKill` (default: false) - Get a turn to react if killed by external signal. Note: killing via tool never triggers a turn.
48
48
 
49
49
  **Important:** You don't need to poll or wait for processes. Notifications arrive automatically based on your preferences. Start processes and continue with other work - you'll be informed if something requires attention.
50
50
 
51
- Note: User always sees notifications in UI. Notification preferences only control whether the agent is informed.
51
+ Note: User always sees process updates in the UI. The alert flags control whether the agent gets a turn to react (e.g. check results, fix code, restart).
52
52
 
53
- ### Command (interactive)
53
+ ### Commands (interactive)
54
54
 
55
- Run `/processes` to open the panel:
55
+ Run `/process:list` to open the panel:
56
56
  - `j/k` - select process
57
57
  - `J/K` - scroll logs
58
+ - `enter` - stream logs for selected process
58
59
  - `x` - kill selected process
59
60
  - `c` - clear finished processes
60
61
  - `q` - quit
61
62
 
63
+ Other commands:
64
+ - `/process:stream [id|name]` - stream logs from a running process
65
+ - `/process:logs [id|name]` - show log file paths
66
+ - `/process:kill [id|name]` - kill a running process
67
+ - `/process:clear` - clear finished processes
68
+
62
69
  ## Test Scripts
63
70
 
64
- Test scripts in `test/` directory:
71
+ Test scripts in `src/test/` directory:
65
72
 
66
73
  ```bash
67
- ./test/test-output.sh # Continuous output (80 chars/sec)
68
- ./test/test-exit-success.sh 5 # Exits successfully after 5s
69
- ./test/test-exit-failure.sh 5 # Exits with code 1 after 5s
70
- ./test/test-exit-crash.sh 5 # Exits with code 137 after 5s
74
+ ./src/test/test-output.sh # Continuous output (80 chars/sec)
75
+ ./src/test/test-exit-success.sh 5 # Exits successfully after 5s
76
+ ./src/test/test-exit-failure.sh 5 # Exits with code 1 after 5s
77
+ ./src/test/test-exit-crash.sh 5 # Exits with code 137 after 5s
71
78
  ```
72
79
 
73
80
  ## Future Improvements
74
81
 
75
- - [ ] **Expandable log view**: Allow toggling between collapsed (current fixed height) and expanded (full height) log view in the `/processes` panel.
82
+ - [ ] **Expandable log view**: Allow toggling between collapsed (current fixed height) and expanded (full height) log view in the `/process:list` panel.
76
83
 
77
84
  - [ ] **Copy log file path**: Add keyboard shortcut to copy the stdout/stderr log file path to clipboard for easy access.
78
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -30,27 +30,38 @@
30
30
  "dependencies": {
31
31
  "@aliou/pi-utils-settings": "^0.4.0",
32
32
  "@aliou/pi-utils-ui": "^0.1.0",
33
+ "@aliou/sh": "^0.1.0",
33
34
  "@sinclair/typebox": "^0.34.41"
34
35
  },
35
36
  "peerDependencies": {
36
- "@mariozechner/pi-coding-agent": ">=0.51.0"
37
+ "@mariozechner/pi-coding-agent": ">=0.51.0",
38
+ "@mariozechner/pi-tui": ">=0.51.0"
37
39
  },
38
40
  "devDependencies": {
41
+ "@aliou/biome-plugins": "^0.3.2",
39
42
  "@aliou/pi-evals": "^0.3.0",
40
43
  "@biomejs/biome": "^2.3.13",
41
44
  "@changesets/cli": "^2.27.11",
42
45
  "@mariozechner/pi-ai": "0.52.7",
43
46
  "@mariozechner/pi-coding-agent": "0.52.7",
44
- "@mariozechner/pi-tui": "0.52.7",
45
47
  "@types/node": "^25.0.10",
46
48
  "husky": "^9.1.7",
47
49
  "typescript": "^5.9.3",
48
50
  "vitest": "^4.0.18"
49
51
  },
52
+ "peerDependenciesMeta": {
53
+ "@mariozechner/pi-coding-agent": {
54
+ "optional": true
55
+ },
56
+ "@mariozechner/pi-tui": {
57
+ "optional": true
58
+ }
59
+ },
50
60
  "scripts": {
51
61
  "typecheck": "tsc --noEmit",
52
62
  "lint": "biome check",
53
63
  "format": "biome check --write",
64
+ "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
54
65
  "changeset": "changeset",
55
66
  "version": "changeset version",
56
67
  "release": "pnpm changeset publish"
@@ -93,6 +93,23 @@ export function registerProcessesSettings(
93
93
  },
94
94
  ],
95
95
  },
96
+ {
97
+ label: "Interception",
98
+ items: [
99
+ {
100
+ id: "interception.blockBackgroundCommands",
101
+ label: "Block background commands",
102
+ description:
103
+ "Block bash background commands (&, nohup, disown, setsid) and guide the model to use the process tool",
104
+ currentValue:
105
+ (tabConfig?.interception?.blockBackgroundCommands ??
106
+ resolved.interception.blockBackgroundCommands)
107
+ ? "on"
108
+ : "off",
109
+ values: ["on", "off"],
110
+ },
111
+ ],
112
+ },
96
113
  {
97
114
  label: "Widget",
98
115
  items: [
@@ -114,6 +131,11 @@ export function registerProcessesSettings(
114
131
  onSettingChange: (id, newValue, config) => {
115
132
  const updated = structuredClone(config);
116
133
  // Boolean fields.
134
+ if (id === "interception.blockBackgroundCommands") {
135
+ if (!updated.interception) updated.interception = {};
136
+ updated.interception.blockBackgroundCommands = newValue === "on";
137
+ return updated;
138
+ }
117
139
  if (id === "widget.showStatusWidget") {
118
140
  if (!updated.widget) updated.widget = {};
119
141
  updated.widget.showStatusWidget = newValue === "on";
@@ -4,7 +4,12 @@ import {
4
4
  renderPanelTitleLine,
5
5
  } from "@aliou/pi-utils-ui";
6
6
  import type { Theme } from "@mariozechner/pi-coding-agent";
7
- import { type Component, matchesKey } from "@mariozechner/pi-tui";
7
+ import {
8
+ type Component,
9
+ matchesKey,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ } from "@mariozechner/pi-tui";
8
13
  import type { ProcessInfo } from "../constants";
9
14
  import type { ProcessManager } from "../manager";
10
15
  import { statusIcon, statusLabel } from "./status-format";
@@ -111,7 +116,14 @@ export class ProcessPickerComponent implements Component {
111
116
  const dim = (s: string) => theme.fg("dim", s);
112
117
  const accent = (s: string) => theme.fg("accent", s);
113
118
 
114
- const padLine = createPanelPadder(width);
119
+ const innerWidth = width - 2;
120
+ const basePadLine = createPanelPadder(width);
121
+ const padLine = (content: string): string =>
122
+ basePadLine(
123
+ visibleWidth(content) > innerWidth
124
+ ? truncateToWidth(content, innerWidth)
125
+ : content,
126
+ );
115
127
 
116
128
  const lines: string[] = [];
117
129
  const processes = this.getProcesses();
@@ -4,7 +4,12 @@ import {
4
4
  renderPanelTitleLine,
5
5
  } from "@aliou/pi-utils-ui";
6
6
  import type { Theme } from "@mariozechner/pi-coding-agent";
7
- import { type Component, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
7
+ import {
8
+ type Component,
9
+ matchesKey,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ } from "@mariozechner/pi-tui";
8
13
  import { configLoader } from "../config";
9
14
  import type { ProcessInfo } from "../constants";
10
15
  import type { ProcessManager } from "../manager";
@@ -214,7 +219,13 @@ export class ProcessesComponent implements Component {
214
219
  const processes = this.manager.list();
215
220
  const innerWidth = width - 2;
216
221
 
217
- const padLine = createPanelPadder(width);
222
+ const basePadLine = createPanelPadder(width);
223
+ const padLine = (content: string): string =>
224
+ basePadLine(
225
+ visibleWidth(content) > innerWidth
226
+ ? truncateToWidth(content, innerWidth)
227
+ : content,
228
+ );
218
229
 
219
230
  lines.push(renderPanelTitleLine("Background Processes", width, theme));
220
231
 
@@ -225,11 +236,18 @@ export class ProcessesComponent implements Component {
225
236
  lines.push(padLine(""));
226
237
  } else {
227
238
  const prefixWidth = 2;
228
- const idWidth = 9;
229
- const nameWidth = 15;
230
- const statusWidth = 18;
231
- const timeWidth = 8;
232
- const sizeWidth = 8;
239
+
240
+ // Responsive column widths based on available space
241
+ // Minimum widths: id=6, name=8, cmd=4, status=10, time=4, size=4 = ~40 chars minimum
242
+ const minTotalWidth = 40;
243
+ const scaleFactor =
244
+ innerWidth < minTotalWidth ? innerWidth / minTotalWidth : 1;
245
+
246
+ const idWidth = Math.max(6, Math.floor(9 * scaleFactor));
247
+ const nameWidth = Math.max(8, Math.floor(15 * scaleFactor));
248
+ const statusWidth = Math.max(10, Math.floor(18 * scaleFactor));
249
+ const timeWidth = Math.max(4, Math.floor(8 * scaleFactor));
250
+ const sizeWidth = Math.max(4, Math.floor(8 * scaleFactor));
233
251
 
234
252
  const hasProcessScroll = processes.length > maxVisibleProcesses;
235
253
  const headerSuffixText = hasProcessScroll
@@ -237,18 +255,16 @@ export class ProcessesComponent implements Component {
237
255
  : "";
238
256
  const headerSuffixLen = hasProcessScroll ? headerSuffixText.length : 0;
239
257
 
240
- // Reserve space for scroll suffix in the command column
241
- const cmdWidth = Math.max(
242
- 10,
243
- innerWidth -
244
- prefixWidth -
245
- idWidth -
246
- nameWidth -
247
- statusWidth -
248
- timeWidth -
249
- sizeWidth -
250
- headerSuffixLen,
251
- );
258
+ // Calculate command column width based on remaining space
259
+ const fixedWidth =
260
+ prefixWidth +
261
+ idWidth +
262
+ nameWidth +
263
+ statusWidth +
264
+ timeWidth +
265
+ sizeWidth +
266
+ headerSuffixLen;
267
+ const cmdWidth = Math.max(4, innerWidth - fixedWidth);
252
268
 
253
269
  lines.push(padLine(""));
254
270
  const header =
@@ -409,7 +425,12 @@ export class ProcessesComponent implements Component {
409
425
  const footerLeftLen = visibleWidth(footerLeft);
410
426
  const footerRightLen = visibleWidth(footerRight);
411
427
  const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
412
- const footer = footerLeft + " ".repeat(footerGap) + footerRight;
428
+ let footer = footerLeft + " ".repeat(footerGap) + footerRight;
429
+
430
+ // Truncate footer if it exceeds inner width (e.g., on very narrow terminals)
431
+ if (footerLeftLen + footerGap + footerRightLen > innerWidth) {
432
+ footer = truncateToWidth(footer, innerWidth);
433
+ }
413
434
 
414
435
  lines.push(padLine(footer));
415
436
  lines.push(renderPanelRule(width, theme));
package/src/config.ts CHANGED
@@ -28,6 +28,10 @@ export interface ProcessesConfig {
28
28
  /** Show the status widget below the editor. */
29
29
  showStatusWidget?: boolean;
30
30
  };
31
+ interception?: {
32
+ /** Block background bash commands (&, nohup, disown, setsid) and guide the model to use the process tool. */
33
+ blockBackgroundCommands?: boolean;
34
+ };
31
35
  }
32
36
 
33
37
  export interface ResolvedProcessesConfig {
@@ -45,6 +49,9 @@ export interface ResolvedProcessesConfig {
45
49
  widget: {
46
50
  showStatusWidget: boolean;
47
51
  };
52
+ interception: {
53
+ blockBackgroundCommands: boolean;
54
+ };
48
55
  }
49
56
 
50
57
  const DEFAULT_CONFIG: ResolvedProcessesConfig = {
@@ -60,6 +67,9 @@ const DEFAULT_CONFIG: ResolvedProcessesConfig = {
60
67
  widget: {
61
68
  showStatusWidget: true,
62
69
  },
70
+ interception: {
71
+ blockBackgroundCommands: false,
72
+ },
63
73
  };
64
74
 
65
75
  export const configLoader = new ConfigLoader<
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Blocks background bash commands (e.g. `cmd &`, `nohup cmd`) and guides
3
+ * the model to use the process tool instead.
4
+ *
5
+ * Opt-in via config: `interception.blockBackgroundCommands`.
6
+ */
7
+
8
+ import { parse } from "@aliou/sh";
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { walkCommands, wordToString } from "../utils/shell-utils";
11
+
12
+ const BACKGROUND_CMD_NAMES = new Set(["nohup", "disown", "setsid"]);
13
+ const BACKGROUND_PATTERN = /&\s*$/;
14
+
15
+ export function setupBackgroundBlocker(pi: ExtensionAPI): void {
16
+ pi.on("tool_call", async (event, ctx) => {
17
+ if (event.toolName !== "bash") return;
18
+
19
+ const command = String(event.input.command ?? "");
20
+
21
+ let hasBackground = false;
22
+ try {
23
+ const { ast } = parse(command);
24
+
25
+ // Check statement-level background flag (cmd &)
26
+ for (const stmt of ast.body) {
27
+ if (stmt.background) {
28
+ hasBackground = true;
29
+ break;
30
+ }
31
+ }
32
+
33
+ // Check for nohup/disown/setsid as command names
34
+ if (!hasBackground) {
35
+ walkCommands(ast, (cmd) => {
36
+ const name = cmd.words?.[0] ? wordToString(cmd.words[0]) : undefined;
37
+ if (name && BACKGROUND_CMD_NAMES.has(name)) {
38
+ hasBackground = true;
39
+ return true;
40
+ }
41
+ return false;
42
+ });
43
+ }
44
+ } catch {
45
+ // Fallback to regex on parse failure
46
+ hasBackground = BACKGROUND_PATTERN.test(command);
47
+ }
48
+
49
+ if (hasBackground) {
50
+ ctx.ui?.notify(
51
+ "Blocked background command. Use the process tool instead.",
52
+ "warning",
53
+ );
54
+
55
+ return {
56
+ block: true,
57
+ reason:
58
+ "Background commands (&, nohup, disown, setsid) are not supported in bash. " +
59
+ 'Use the "process" tool with action "start" to run commands in the background. ' +
60
+ 'Example: process({ action: "start", name: "my-server", command: "npm run dev" })',
61
+ };
62
+ }
63
+
64
+ return;
65
+ });
66
+ }
@@ -1,14 +1,24 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ResolvedProcessesConfig } from "../config";
2
3
  import type { ProcessManager } from "../manager";
4
+ import { setupBackgroundBlocker } from "./background-blocker";
3
5
  import { setupCleanupHook } from "./cleanup";
4
6
  import { setupMessageRenderer } from "./message-renderer";
5
7
  import { setupProcessEndHook } from "./process-end";
6
8
  import { setupProcessWidget } from "./widget";
7
9
 
8
- export function setupProcessesHooks(pi: ExtensionAPI, manager: ProcessManager) {
10
+ export function setupProcessesHooks(
11
+ pi: ExtensionAPI,
12
+ manager: ProcessManager,
13
+ config: ResolvedProcessesConfig,
14
+ ) {
9
15
  setupCleanupHook(pi, manager);
10
16
  setupProcessEndHook(pi, manager);
11
17
 
18
+ if (config.interception.blockBackgroundCommands) {
19
+ setupBackgroundBlocker(pi);
20
+ }
21
+
12
22
  // Set up widget AFTER process-end so it chains onto the existing callback
13
23
  const widget = setupProcessWidget(pi, manager);
14
24
 
@@ -2,7 +2,7 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@mariozechner/pi-coding-agent";
5
- import { visibleWidth } from "@mariozechner/pi-tui";
5
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
6
  import { configLoader } from "../config";
7
7
  import type { ProcessInfo } from "../constants";
8
8
  import type { ProcessManager } from "../manager";
@@ -75,16 +75,28 @@ function renderWidget(
75
75
  for (const proc of allProcs) {
76
76
  const formatted = formatProcessStatus(proc, theme);
77
77
  const formattedLen = visibleWidth(formatted);
78
+ const remaining = allProcs.length - includedCount - 1;
78
79
 
79
80
  // Check if adding this part would exceed the width
80
81
  const needed =
81
82
  includedCount > 0 ? separatorLen + formattedLen : formattedLen;
82
83
 
83
- if (currentLen + needed > effectiveMax && includedCount > 0) {
84
+ // If there are more processes after this one, reserve space for the
85
+ // overflow suffix (separator + "+N more") so the final line fits.
86
+ let reservedForSuffix = 0;
87
+ if (remaining > 0) {
88
+ const suffixText = `+${remaining} more`;
89
+ reservedForSuffix = separatorLen + visibleWidth(suffixText);
90
+ }
91
+
92
+ if (
93
+ currentLen + needed + reservedForSuffix > effectiveMax &&
94
+ includedCount > 0
95
+ ) {
84
96
  // Show how many are hidden
85
- const remaining = allProcs.length - includedCount;
86
- if (remaining > 0) {
87
- parts.push(theme.fg("dim", `+${remaining} more`));
97
+ const hiddenCount = allProcs.length - includedCount;
98
+ if (hiddenCount > 0) {
99
+ parts.push(theme.fg("dim", `+${hiddenCount} more`));
88
100
  }
89
101
  break;
90
102
  }
@@ -94,11 +106,25 @@ function renderWidget(
94
106
  includedCount++;
95
107
  }
96
108
 
109
+ // Edge case: the very first element was too wide and the loop's overflow
110
+ // check was skipped (it only fires when includedCount > 0). The suffix
111
+ // reservation already shrinks the budget, but a single process that fills
112
+ // the whole line still slips through. Show it truncated rather than nothing.
113
+ if (includedCount === 0 && allProcs.length > 0) {
114
+ const formatted = formatProcessStatus(allProcs[0], theme);
115
+ parts.push(formatted);
116
+ }
117
+
97
118
  if (parts.length === 0) {
98
119
  return [];
99
120
  }
100
121
 
101
- return [prefix + parts.join(separator)];
122
+ const line = prefix + parts.join(separator);
123
+ return [
124
+ visibleWidth(line) > effectiveMax
125
+ ? truncateToWidth(line, effectiveMax)
126
+ : line,
127
+ ];
102
128
  }
103
129
 
104
130
  export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
package/src/index.ts CHANGED
@@ -20,7 +20,8 @@ export default async function (pi: ExtensionAPI) {
20
20
  getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
21
21
  });
22
22
 
23
- const { update: updateWidget } = setupProcessesHooks(pi, manager);
23
+ const config = configLoader.getConfig();
24
+ const { update: updateWidget } = setupProcessesHooks(pi, manager, config);
24
25
  setupProcessesCommands(pi, manager);
25
26
  setupProcessesTools(pi, manager);
26
27
  registerProcessesSettings(pi, () => {
@@ -1,9 +1,10 @@
1
+ import type * as nodeFs from "node:fs";
1
2
  import { existsSync } from "node:fs";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import { resolveShellExecutable } from "./command-executor";
4
5
 
5
6
  vi.mock("node:fs", async (importOriginal) => {
6
- const actual = await importOriginal<typeof import("node:fs")>();
7
+ const actual = await importOriginal<typeof nodeFs>();
7
8
  return { ...actual, existsSync: vi.fn() };
8
9
  });
9
10
 
@@ -0,0 +1,133 @@
1
+ // Shell AST helpers. Duplicated from pi-toolchain since cross-extension imports are not allowed.
2
+
3
+ import type {
4
+ Command,
5
+ Program,
6
+ SimpleCommand,
7
+ Statement,
8
+ Word,
9
+ WordPart,
10
+ } from "@aliou/sh";
11
+
12
+ /**
13
+ * Resolve a Word node to its literal string value.
14
+ * Concatenates Literal, SglQuoted, and simple DblQuoted parts.
15
+ * For parts containing parameter expansions, command substitutions, etc.,
16
+ * includes the raw text representation (e.g. `$VAR`).
17
+ */
18
+ export function wordToString(word: Word): string {
19
+ return word.parts.map(partToString).join("");
20
+ }
21
+
22
+ function partToString(part: WordPart): string {
23
+ switch (part.type) {
24
+ case "Literal":
25
+ return part.value;
26
+ case "SglQuoted":
27
+ return part.value;
28
+ case "DblQuoted":
29
+ return part.parts.map(partToString).join("");
30
+ case "ParamExp":
31
+ return part.short
32
+ ? `$${part.param.value}`
33
+ : `\${${part.param.value}${part.op ?? ""}${part.value ? wordToString(part.value) : ""}}`;
34
+ case "CmdSubst":
35
+ return "$(...)";
36
+ case "ArithExp":
37
+ return `$((${part.expr}))`;
38
+ case "ProcSubst":
39
+ return `${part.op}(...)`;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Walk the AST and call `callback` for every SimpleCommand found at any
45
+ * nesting depth. Returns early if callback returns `true`.
46
+ */
47
+ export function walkCommands(
48
+ node: Program,
49
+ callback: (cmd: SimpleCommand) => boolean | undefined,
50
+ ): void {
51
+ for (const stmt of node.body) {
52
+ if (walkStatement(stmt, callback)) return;
53
+ }
54
+ }
55
+
56
+ function walkStatement(
57
+ stmt: Statement,
58
+ callback: (cmd: SimpleCommand) => boolean | undefined,
59
+ ): boolean {
60
+ return walkCommand(stmt.command, callback);
61
+ }
62
+
63
+ function walkStatements(
64
+ stmts: Statement[],
65
+ callback: (cmd: SimpleCommand) => boolean | undefined,
66
+ ): boolean {
67
+ for (const stmt of stmts) {
68
+ if (walkStatement(stmt, callback)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ function walkCommand(
74
+ cmd: Command,
75
+ callback: (cmd: SimpleCommand) => boolean | undefined,
76
+ ): boolean {
77
+ switch (cmd.type) {
78
+ case "SimpleCommand":
79
+ return callback(cmd) === true;
80
+
81
+ case "Pipeline":
82
+ return walkStatements(cmd.commands, callback);
83
+
84
+ case "Logical":
85
+ return (
86
+ walkStatement(cmd.left, callback) || walkStatement(cmd.right, callback)
87
+ );
88
+
89
+ case "Subshell":
90
+ case "Block":
91
+ return walkStatements(cmd.body, callback);
92
+
93
+ case "IfClause":
94
+ return (
95
+ walkStatements(cmd.cond, callback) ||
96
+ walkStatements(cmd.then, callback) ||
97
+ (cmd.else ? walkStatements(cmd.else, callback) : false)
98
+ );
99
+
100
+ case "ForClause":
101
+ case "SelectClause":
102
+ case "WhileClause":
103
+ return (
104
+ ("cond" in cmd && cmd.cond
105
+ ? walkStatements(cmd.cond, callback)
106
+ : false) || walkStatements(cmd.body, callback)
107
+ );
108
+
109
+ case "CaseClause":
110
+ for (const item of cmd.items) {
111
+ if (walkStatements(item.body, callback)) return true;
112
+ }
113
+ return false;
114
+
115
+ case "FunctionDecl":
116
+ return walkStatements(cmd.body, callback);
117
+
118
+ case "TimeClause":
119
+ return walkStatement(cmd.command, callback);
120
+
121
+ case "CoprocClause":
122
+ return walkStatement(cmd.body, callback);
123
+
124
+ case "CStyleLoop":
125
+ return walkStatements(cmd.body, callback);
126
+
127
+ case "TestClause":
128
+ case "ArithCmd":
129
+ case "DeclClause":
130
+ case "LetClause":
131
+ return false;
132
+ }
133
+ }