Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/cad0p/pi-steering-hooks/llms.txt

Use this file to discover all available pages before exploring further.

unbash-walker is the bash AST utility that pi-steering builds on. It provides command extraction, wrapper expansion, and an extensible per-command state tracker. Plugin authors who need to write custom trackers or call the walker directly can import from pi-steering, which re-exports everything from unbash-walker for forward compatibility — imports from the package root will not break if unbash-walker is ever published separately.

Import

// All unbash-walker exports are re-exported from pi-steering:
import {
  parse,
  extractAllCommandsFromAST,
  expandWrapperCommands,
  walk,
  cwdTracker,
  envTracker,
  resolveWord,
  getBasename,
  getCommandArgs,
  getCommandName,
  formatCommand,
} from "pi-steering";

Quick look

import {
  parse,
  extractAllCommandsFromAST,
  expandWrapperCommands,
  walk,
  cwdTracker,
  getBasename,
  getCommandArgs,
} from "pi-steering";

const raw = "cd /home/me/repo && sudo sh -c 'git push --force'";
const script = parse(raw);

const refs = extractAllCommandsFromAST(script, raw);
const { commands } = expandWrapperCommands(refs);
const state = walk(script, { cwd: "/tmp" }, { cwd: cwdTracker }, commands);

for (const cmd of commands) {
  const name = getBasename(cmd);
  const args = getCommandArgs(cmd);
  const cwd = state.get(cmd)?.cwd ?? "/tmp";
  console.log(`${cwd} :: ${name} ${args.join(" ")}`);
}

parse(source: string): Script

Re-exported from unbash. Parses a bash string into a Script AST node. The Script is the root of the unbash tree that all other unbash-walker functions consume.
import { parse } from "pi-steering";

const script = parse("git commit -m 'fix: typo'");

extractAllCommandsFromAST(script, source): CommandRef[]

Flattens a Script into the ordered list of CommandRefs that actually run. Unlike a naive tree walk, this function handles the full range of bash control structures:
  • AndOr (&& / || chains)
  • Pipeline (| chains)
  • Subshell ((...))
  • BraceGroup ({...})
  • Control flow (if, while, for, case, select)
  • Command substitution inside words ($(...), `...`, <(...))
function extractAllCommandsFromAST(
  script: Script,
  source: string,
): CommandRef[]

Parameters

script
Script
The parsed Script AST produced by parse(source).
source
string
The original bash source string. Stored on every returned CommandRef so position offsets within the AST node resolve to the correct characters.
Returns an array of CommandRef objects in AST order.

expandWrapperCommands(commands): { commands: CommandRef[] }

Given a list of extracted CommandRefs, recursively reveals sub-commands of wrapper programs — shells invoked with an inline -c script, privilege escalators, and process wrappers that run another command as their argument.
function expandWrapperCommands(
  commands: CommandRef[],
): ExpansionResult

// ExpansionResult shape:
type ExpansionResult = {
  commands: CommandRef[];
  expandedWrappers: Set<CommandRef>;
}
Wrapper registry consulted by default:
WrapperHow sub-commands are found
sh -c '...', bash -c '...', zsh -c '...'Parses the -c argument as a new Script and recurses
sudo CMD, env CMDTreats the first non-option argument as the wrapped command
xargs CMDTreats arguments after options as the wrapped command
nice CMD, nohup CMD, strace CMDSame as sudo
find -exec CMD ; / find -exec CMD +Extracts the -exec clause
fd -x CMD / fd --exec CMDSame as find -exec
Original refs are kept alongside their expansions so rule authors can match at either level — a rule targeting git push --force fires whether the command appears directly or wrapped inside sudo sh -c 'git push --force'.

Extending the wrapper registry

The registry expandWrapperCommands consults is exported as WRAPPER_COMMANDS and can be extended by plugin authors or test harnesses:
import { WRAPPER_COMMANDS } from "pi-steering";

// Inspect the registry
console.log(Object.keys(WRAPPER_COMMANDS));

walk(script, initialState, trackers, refs?): WalkResult<T>

Thread a registry of named state trackers through the AST. Returns a WalkResult<T> (= Map<CommandRef, T>) from each CommandRef to a snapshot carrying every tracker’s value at that command — after all preceding sequential modifiers have been applied.
function walk<T extends Record<string, unknown>>(
  script: Script,
  initialState: Partial<T>,
  trackers: { readonly [K in keyof T]: Tracker<T[K]> },
  refs?: readonly CommandRef[],
): WalkResult<T>

Parameters

script
Script
The parsed Script AST.
initialState
Partial<T>
Starting values for each tracker. Any key missing here falls back to the tracker’s initial field. Typically callers pass { cwd: process.cwd() } to seed the cwd tracker from the session’s working directory.
trackers
Record<string, Tracker<unknown>>
Named tracker registry. Each key names a tracker dimension (e.g. "cwd", "env", "branch"). Registration order matters — trackers registered earlier see the pre-ref snapshot from later trackers when reading allState. Register envTracker before cwdTracker so the cwd tracker’s cd modifier can expand $VAR references via the env map.
refs
readonly CommandRef[]
Pass your own readonly CommandRef[] (from a prior extractAllCommandsFromAST call) to get the returned Map keyed by the same ref objects — enabling pointer-equality lookups. Omit to have walk extract refs internally.

Isolation and propagation semantics

ConstructBehavior
Sequential modifiers (cd, git checkout)Update the tracker’s threaded value — propagates to all subsequent sibling commands
Per-command modifiers (git -C, make -C)Update the command’s snapshot only — threaded value unchanged
Pipeline (A | B)Each peer runs in its own subshell; no cross-peer propagation
Subshell ((A; B))Respects each tracker’s subshellSemantics; "isolated" (default) means changes don’t escape
Brace group ({A; B})Not a subshell — sequential changes propagate out
if / case branchesOne branch runs at runtime; walker propagates a value only if all branches agree — otherwise falls back to the pre-branch value
while / for / selectBody may run zero times; body cwd never propagates forward to commands after the loop

cwdTracker

Built-in tracker for the effective working directory of each command. Models all common bash cwd-changing constructs and exposes them as per-command snapshots for the when.cwd predicate and plugin predicates.
import { cwdTracker } from "pi-steering";

const state = walk(script, { cwd: process.cwd() }, {
  env: envTracker,
  cwd: cwdTracker,
}, refs);

What it models

PatternEffect
cd /absolute/pathSets cwd to the absolute path
cd relative/pathResolves relative to current cwd
cd ~ or cd (no args)Expands ~ via envTracker.HOME
cd ~/subdirExpands ~ and appends the suffix
cd -No-op (OLDPWD not tracked)
git -C /dir CMDPer-command cwd override for the git invocation
make -C /dir targetPer-command cwd override for make
env -C /dir CMDPer-command cwd override for env-prefixed commands
Dynamic cd "$VAR"Resolved via envTracker if $VAR is statically known; "unknown" sentinel otherwise

"unknown" sentinel

When the walker can’t statically resolve a cd target — due to command substitution ($(cmd)), arithmetic, parameter-expansion with modifiers, or an env variable not in the current envTracker state — the tracker emits the string "unknown" for all subsequent commands in that scope. The engine’s when.cwd predicate applies onUnknown: "allow" | "block" (default "block", fail-closed) to decide how to treat the sentinel.

Known limitations

  • pushd/popd are not modelled as cwd changes — they are extracted as normal commands.
  • git --git-dir=/path and git --work-tree=/path are not modelled (narrower than -C; planned follow-up).
  • eval "..." — the string argument is not re-parsed; commands inside are invisible.
  • source script.sh / . script.sh — external files are never read; any cd effects they would perform are opaque.

envTracker

Built-in tracker for the shell environment map at each command. Seeded from process.env.{HOME, USER, PWD} at initialization; captures statically resolvable assignments from the command stream.
import { envTracker } from "pi-steering";

const state = walk(script, {}, { env: envTracker }, refs);
const envAtCmd = state.get(cmd)?.env; // ReadonlyMap<string, string>

What it captures

PatternEffect
FOO=bar (bare assignment)Sets FOO to "bar"
export FOO=valueSets FOO to "value"
unset FOORemoves FOO from the map

What it does not capture

  • readonly FOO=value, local FOO=value, declare / typeset — attribute-bearing assignments.
  • source/. — opaque file inclusion.
  • Function-body walking — function definitions are walked when defined, not when invoked.
  • FOO+=value (append), FOO=(a b c) (array init), FOO[0]=value (array-index) — compound forms.
Subshell semantics: "isolated" — env changes inside (A; B) don’t escape to the outer scope.

resolveWord(word: Word, env: EnvState): string | undefined

Statically resolve an unbash Word to its string value, expanding $NAME, ${NAME}, and ~ via the supplied env map.
function resolveWord(word: Word, env: EnvState): string | undefined
Returns undefined when any part of the word is intractable — command substitution, arithmetic, parameter-expansion with modifiers, or a variable not present in env. Shared by cwdTracker’s cd modifier and reusable by plugin predicates that need to expand arguments from ctx.input.args.
import { resolveWord } from "pi-steering";
import type { EnvState } from "pi-steering";

const env: EnvState = new Map([["HOME", "/home/me"], ["WS", "/home/me/work"]]);

// Resolves fully
resolveWord(wordNode, env); // "/home/me/work/pkg" if word is "$WS/pkg"

// Returns undefined — command substitution is intractable
resolveWord(cmdSubstWord, env); // undefined

Helper functions

getCommandName(cmd: CommandRef): string

Returns the full command name as it appears in source, including any leading path (e.g. /usr/bin/git).

getBasename(cmd: CommandRef): string

Strips any leading path from the command name. /usr/bin/git"git". Use this when matching command names in predicates — agents and scripts commonly invoke tools by full path.

getCommandArgs(cmd: CommandRef): Word[]

Returns the argument words after the command name. For git commit -m "fix" this gives [Word("-m"), Word("fix")]. Each Word carries .value (lexical, quote-stripped) and .text (raw source token).

isBareAssignment(cmd: CommandRef): boolean

Returns true for commands that are purely shell variable assignments with no command name — e.g. FOO=bar. The envTracker uses this to distinguish assignment-only commands (which never have a basename) from prefix assignments on a real command (FOO=bar myprogram).

formatCommand(cmd: CommandRef, options?): string

Re-serializes a CommandRef as a single-line display string with length-aware shrinking and path-aware elision. Useful for including the command in block reason messages.
import { formatCommand } from "pi-steering";

const display = formatCommand(cmd, { maxLength: 80 });

CommandRef type

type CommandRef = {
  /** The unbash AST Command node. */
  node: Command;

  /** Original bash source string — position offsets in node refer to this. */
  source: string;

  /**
   * Group ID. Commands in the same && / || / | chain share a group.
   * Different groups are separated by semicolons or blank lines.
   */
  group: number;

  /**
   * The operator connecting this command to the next.
   * Undefined for the last command in a group.
   */
  joiner?: "|" | "&&" | "||" | ";";
};

Tracker<T> and Modifier<T, TAll> types

Plugin authors implement these to introduce new walker state dimensions via Plugin.trackers.
interface Tracker<T> {
  /**
   * Starting value for this dimension.
   * walk() callers override this via initialState[name].
   */
  initial: T;

  /**
   * Sentinel emitted when a modifier returns undefined (unresolvable state).
   * For string trackers this is typically the string "unknown".
   */
  unknown: T;

  /**
   * Modifiers keyed by command basename.
   * An array value applies multiple modifiers left-to-right on the same ref.
   */
  modifiers: Record<string, Modifier<T> | Modifier<T>[]>;

  /**
   * How subshell boundaries affect this dimension.
   * Defaults to "isolated".
   */
  subshellSemantics?: "isolated" | "global";
}
Each modifier declares its propagation scope:
type Modifier<T, TAll extends Record<string, unknown> = Record<string, unknown>> =
  | {
      /** Updates the threaded value — propagates to all subsequent sibling commands. */
      scope: "sequential";
      apply(args: readonly Word[], current: T, allState: Readonly<TAll>): T | undefined;
    }
  | {
      /** Updates only the snapshot for this command — threaded value unchanged. */
      scope: "per-command";
      apply(args: readonly Word[], current: T, allState: Readonly<TAll>): T | undefined;
    };
allState is a read-only snapshot of every registered tracker’s pre-ref state. Use the TAll type parameter to declare the cross-tracker reads your modifier needs:
import type { Modifier, EnvState } from "pi-steering";

const myModifier: Modifier<string, { env: EnvState }> = {
  scope: "sequential",
  apply(args: readonly Word[], current: string, allState: Readonly<{ env: EnvState }>) {
    const expanded = allState.env.get("MY_VAR");
    if (!expanded) return undefined; // unresolvable — emit "unknown"
    return expanded;
  },
};
Return undefined from apply to signal that the new value can’t be resolved statically. The walker substitutes the tracker’s unknown sentinel and (for sequential modifiers) stops refining that dimension for subsequent commands in the current scope.

Build docs developers (and LLMs) love