Back to main

From Scripts to CLI: Unifying a Local Agent Workspace

[ AUTHORIAL INTENT & AI DISCLOSURE ]

This post was drafted with assistance from Gemini, drawing on development logs, command-line specifications, and codebase audits from the local workspace CLI.

Forensic Hygiene Active
View Policy Standard →

When you build a local agentic development workspace, it usually starts with a simple helper script. Then you write another script to manage your backlog. Then you write a shell script to resolve project paths. Before long, you are juggling dozens of loose files, calling them through a tangle of Zsh functions and hardcoded aliases in your ~/.zshrc.

This was the exact state of my local workspace. I had Python scripts for finalization, Zsh scripts for directory walking, and various sub-agents running different processes. It worked, but it was fragile, slow, and hard to maintain.

To fix this, I unified the entire system into a single, cohesive command-line tool: soul.

The Chaos of Loose Scripts

Initially, executing commands in my workspace was a manual exercise in path tracking. If I wanted to see my current task backlog, I had to invoke the script with full paths:

python3 ~/dotfiles/soul/kernel/commands/pulse.py $(pwd)

To make this bearable, I wrote wrappers inside ~/.zshrc. But shell wrappers introduce their own problems:

  • Path fragility: Resolving relative paths when jumping between different Git worktrees is notoriously difficult in shell scripts.
  • Process bloat: Shell wrappers often fork new subshells, increasing terminal startup latency and eating up CPU.
  • Dependency pollution: Heavy dependencies (like textual or rich for terminal UIs) had to be installed globally on the system python path to prevent import errors.

I needed a single entry point that could handle environment setup, resolve projects, run commands cleanly, and isolate heavy dependencies.

Designing a Lightweight Python Dispatcher

I wrote the entry point as a Python script at ~/dotfiles/soul/bin/soul. It acts as a lightweight router that maps subcommands to their canonical scripts under the hood.

Here is how the dispatcher is structured:

import os
import sys
from pathlib import Path

SOUL_PATH = Path(os.environ.get("SOUL_PATH", os.path.expanduser("~/dotfiles/soul")))
COMMANDS_DIR = SOUL_PATH / "kernel" / "commands"

# A static map prevents accidental execution of loose scripts
COMMAND_MAP: dict[str, str] = {
    "view":     "soul_view.py",
    "pulse":    "pulse.py",
    "finalize": "soul_finalize.py",
    "task":     "soul_task.py",
    "project":  "soul_project.py",
    "delegate": "soul_subagent.py",
}

def dispatch(cmd: str, args: list[str]) -> int:
    if cmd not in COMMAND_MAP:
        print("Usage: soul [" + "|".join(COMMAND_MAP.keys()) + "]")
        return 1

    script = COMMANDS_DIR / COMMAND_MAP[cmd]
    
    # Special Case: text-based UI needs heavy dependencies
    if cmd == "view":
        return run_in_virtual_env(script, args)

    # Replaces the current process cleanly
    os.execv(sys.executable, [sys.executable, str(script), *args])

Two critical architectural choices make this dispatcher work reliably:

1. Zero-bloat Process Replacement with os.execv

Normally, when executing a script from Python, you might use subprocess.run. But subprocess forks a child process and keeps the parent Python process hanging in memory.

By using os.execv, the dispatcher replaces the active process in-place with the target script. Signals (like Ctrl+C) and exit codes pass through natively to the parent terminal, exactly matching the behavior of a native executable.

2. Environment Isolation for the Terminal UI

The command-line interface includes a full terminal UI (soul view) built on top of textual and rich. These are heavy packages. To keep the host system clean, the dispatcher automatically isolates the UI execution:

def run_in_virtual_env(script: Path, args: list[str]) -> int:
    venv_py = SOUL_PATH / ".venv" / "bin" / "python"
    if not venv_py.exists():
        sys.stderr.write("Virtual environment missing. Please bootstrap first.\n")
        return 1
    # Exec through the isolated virtual env python
    os.execv(str(venv_py), [str(venv_py), str(script), *args])

By detecting the view subcommand, the dispatcher runs it inside the local .venv, while the rest of the lightweight commands run on the system’s global Python interpreter instantly.

Porting Zsh to Python: The Path Resolver

The final piece of the unification was replacing fragile shell dependencies with Python.

My legacy project key resolver was written as a native-zsh JSON scanner (soul_resolve_project.zsh). While fast, native-zsh JSON parsing is brittle. If a PROJECTS.json configuration file had a syntax error, the resolver failed silently, causing task logs to land in a fallback folder and splitting the state.

I rewrote the resolver as a robust Python module (resolve_project_key.py). It implements a clear, deterministic hierarchy:

1. Active environment override (env variable)
2. Central PROJECTS.json path verification
3. Local file markers (.soul_project)
4. Safe fallback to default workspace ('soul')

If the JSON configuration fails to load, the resolver refuses to guess or trust unverified files. It immediately alerts stderr and falls back to a safe default, preventing data leakage across different project directories.

The Result

Unifying my loose scripts into a cohesive command-line tool removed the friction of local agent development:

  • Instant boots: Terminal startup is fast since we removed slow shell-based path walks.
  • Hermetic dependencies: System updates won’t break the terminal UI because its packages are locked inside a private virtual environment.
  • Predictable execution: Typing soul task list or soul pulse from any repository resolves paths deterministically.

A solid CLI provides the stable foundation you need to keep your local agent workflows reliable and auditable.

Back to main