Docs Packages

@procedures/shell

packages/client-shell/README.md on GitHub · Package map · Procedures

npm install @procedures/shell

Generic shell command execution procedures - the foundation layer for all CLI wrapper packages in the Mark ecosystem.

Table of Contents#


Overview#

client-shell provides low-level shell command execution as RPC procedures:

  • shell.run - Spawn processes with arguments (no shell interpretation, safer)
  • shell.exec - Execute command strings via shell (supports pipes, redirects)
  • shell.which - Find command paths cross-platform
  • shell.stream - Run a command with arguments, and give each output line while it runs

This package is the foundation for all CLI wrapper packages.

Command helpers#

The wrapper packages start their programs with the helpers of @procedures/shell/command. This entry point registers no procedures.

HelperWhat it does
runCommand(command, options)It runs a program to its end and gives exitCode, stdout, stderr and duration. It does not throw.
streamCommand(command, options)It gives each output line while the program runs, then an exit item.
processesIt records a long-running program: start, list, waitFor, output, stop.
killTree(child)It kills a child process and all the processes that it started.

The rules of the helpers:

  • A helper passes the arguments as a list. No shell reads them.
  • The timeout and the signal kill the whole process tree. Windows uses taskkill /T /F. The other systems use a process group.
  • Each output stream has a limit (maxOutputBytes, 64 MiB by default). The helper keeps the first bytes, or the last bytes with keep: "tail".
  • A program without an input gets an empty stdin.
  • When the reader of streamCommand is slow, the helper stops reading the pipes, so the program waits.
  • When the host ends, the helpers kill each child that still runs.
import { runCommand } from "@procedures/shell/command";

const result = await runCommand("git", { args: ["status", "--porcelain"], cwd, signal: ctx.signal });

Installation#

npm install github:mark1russell7/client-shell#main

Architecture#

System Overview#

Diagram source (Mermaid). GitHub draws it on the page of the file.
graph TB
    subgraph "Application Layer"
        App[Your Application]
    end

    subgraph "client-shell"
        Run[shell.run<br/>Spawn with args]
        Exec[shell.exec<br/>Shell string]
        Which[shell.which<br/>Find command]
    end

    subgraph "Node.js"
        Spawn[child_process.spawn]
        ExecFn[child_process.exec]
        WhichFn[which / where]
    end

    subgraph "Output"
        Result[exitCode, stdout, stderr,<br/>success, duration]
    end

    App --> Run
    App --> Exec
    App --> Which
    Run --> Spawn
    Exec --> ExecFn
    Which --> WhichFn
    Spawn --> Result
    ExecFn --> Result

Command Execution Flow#

Diagram source (Mermaid). GitHub draws it on the page of the file.
sequenceDiagram
    participant App as Application
    participant Shell as client-shell
    participant Node as Node.js
    participant OS as Operating System

    App->>Shell: shell.run({ command: "git", args: ["status"] })
    Shell->>Shell: Validate input
    Shell->>Node: spawn("git", ["status"], options)
    Node->>OS: Execute process
    OS-->>Node: stdout, stderr, exitCode
    Node-->>Shell: Process result
    Shell->>Shell: Build output
    Shell-->>App: { success, stdout, stderr, exitCode, duration }

Dependency Tree#

Diagram source (Mermaid). GitHub draws it on the page of the file.
graph TB
    subgraph "CLI Wrapper Packages"
        CLI[client-cli]
        Pnpm[client-pnpm]
        Git[client-git]
        Docker[client-docker]
        Vitest[client-vitest]
        Cue[client-cue]
    end

    subgraph "Foundation"
        Shell[client-shell]
    end

    subgraph "Core"
        Client[client]
    end

    CLI --> Shell
    Pnpm --> Shell
    Git --> Shell
    Docker --> Shell
    Vitest --> Shell
    Cue --> Shell
    Shell --> Client

Quick Start#

import { Client, LocalTransport, PROCEDURE_REGISTRY } from "@procedures/client";
import "@procedures/shell"; // registers its procedures // Auto-registers procedures

const client = new Client(new LocalTransport({ registry: PROCEDURE_REGISTRY }));

// Run a command with arguments
const result = await client.exec(["shell", "run"], {
  command: "git",
  args: ["status", "--short"],
  cwd: "/path/to/repo",
});

console.log(result.stdout);
// M  src/index.ts
// ?? new-file.ts

API Reference#

Procedures Summary#

PathDescription
shell.runSpawn process with arguments (no shell)
shell.execExecute command string through shell
shell.whichFind full path to a command

shell.run#

Spawn a process directly without shell interpretation. Safer for untrusted input.

interface ShellRunInput {
  command: string;              // Command to run
  args?: string[];              // Arguments (default: [])
  cwd?: string;                 // Working directory
  env?: Record<string, string>; // Environment variables
  timeout?: number;             // Timeout in ms
  encoding?: string;            // Output encoding (default: "utf8")
}

interface ShellRunOutput {
  exitCode: number;
  stdout: string;
  stderr: string;
  success: boolean;  // true if exitCode === 0
  duration: number;  // ms
}

Example:

await client.exec(["shell", "run"], {
  command: "npm",
  args: ["install", "--save-dev", "typescript"],
  cwd: "/my/project",
  timeout: 60000,
});

shell.exec#

Execute a command string through the system shell. Supports pipes, redirects, etc.

interface ShellExecInput {
  command: string;              // Full command string
  cwd?: string;                 // Working directory
  env?: Record<string, string>; // Environment variables
  timeout?: number;             // Timeout in ms
  shell?: boolean | string;     // Shell to use (default: true)
  maxBuffer?: number;           // Max stdout/stderr buffer
  stdin?: string;               // Input to pipe to stdin
}

interface ShellExecOutput {
  exitCode: number;
  stdout: string;
  stderr: string;
  success: boolean;
  duration: number;
  signal?: string;  // Signal that terminated process
}

Example:

await client.exec(["shell", "exec"], {
  command: "git log --oneline | head -5",
  cwd: "/my/repo",
});

shell.which#

Find the full path to a command. Uses where on Windows, which on Unix.

interface ShellWhichInput {
  command: string;  // Command to find
}

interface ShellWhichOutput {
  path: string | null;  // Full path or null if not found
  found: boolean;
}

Example:

const { path, found } = await client.exec(["shell", "which"], {
  command: "node",
});
// { path: "/usr/local/bin/node", found: true }

Use Cases#

Building CLI Wrappers#

The shell.run procedure is the foundation for CLI wrapper packages:

// In client-git/src/procedures/git/status.ts
export async function gitStatus(input: GitStatusInput, ctx: ProcedureContext) {
  const args = ["status"];
  if (input.short) args.push("--short");

  const result = await ctx.client.call(["shell", "run"], {
    command: "git",
    args,
    cwd: input.cwd,
  });

  return parseGitStatus(result.stdout);
}

Checking Command Availability#

async function ensureGitAvailable() {
  const { found } = await client.exec(["shell", "which"], { command: "git" });
  if (!found) {
    throw new Error("Git is not installed or not in PATH");
  }
}

Piping Commands#

// Using shell.exec for pipes
const result = await client.exec(["shell", "exec"], {
  command: "cat package.json | jq '.dependencies'",
  cwd: "/my/project",
});

Cross-Platform Notes#

shell.which Behavior#

Diagram source (Mermaid). GitHub draws it on the page of the file.
graph LR
    subgraph "Unix/macOS"
        UnixWhich["which command"]
    end

    subgraph "Windows"
        WinWhere["where command"]
    end

    subgraph "Result"
        Path["/usr/bin/git<br/>or C:\\Program Files\\Git\\bin\\git.exe"]
    end

    UnixWhich --> Path
    WinWhere --> Path

shell.run vs shell.exec#

Featureshell.runshell.exec
Shell interpretationNoYes
Pipes & redirectsNoYes
Argument safetySaferLess safe
Use caseKnown commandsShell scripts
Platform quotingHandledManual

Integration#

With Other Packages#

Diagram source (Mermaid). GitHub draws it on the page of the file.
graph TB
    subgraph "High-Level"
        Lib[client-lib<br/>lib.refresh]
    end

    subgraph "Mid-Level"
        Git[client-git<br/>git.commit]
        Pnpm[client-pnpm<br/>pnpm.install]
    end

    subgraph "Foundation"
        Shell[client-shell<br/>shell.run]
    end

    Lib --> Git
    Lib --> Pnpm
    Git --> Shell
    Pnpm --> Shell

With MCP (Claude)#

When using the MCP server with bundle-dev, Claude has access to shell.* procedures:

User: "Run npm install in /my/project"
Claude: [Uses shell.run with command: "npm", args: ["install"]]

Requirements#

  • Node.js >= 22
  • Dependencies:
    • @procedures/client (peer dependency)
    • zod ^3.24.0

License#

MIT