Skip to main content

Manifest-driven symlink installer. Reads lash.json and executes operations forward (install) or backward (uninstall), keeping them in sync. CLI command: `lash`.

Project description

lash

A manifest-driven symlink installer. Define your project's install operations once in a lash.json file, and lash handles both install and uninstall — perfectly in sync, every time.

Born out of frustration with paired install.sh / uninstall.sh scripts that inevitably drift apart. With lash, the manifest is the single source of truth.

Why lash?

Most developer tools need to place files in specific locations — CLI binaries in ~/.local/bin/, config snippets in ~/.config/, hooks in ~/.claude/hooks/. The typical approach is a shell script that creates symlinks, patches config files, and runs setup commands. Then you need a matching uninstall script that reverses everything. These scripts always drift.

lash replaces both with a single JSON manifest. Install reads forward, uninstall reads backward. One file, zero drift.

Key properties:

  • Single source of truthlash.json defines both install and uninstall
  • Zero dependencies — Python 3 stdlib only, single file, no pip
  • Idempotent — safe to re-run install at any time
  • Reversible — uninstall perfectly undoes what install did
  • Discoverablelash list finds all managed projects at a glance

Quick start

Install

# Once on PyPI (preferred):
uv tool install lash-installer

# For now (or if you want to track main):
uv tool install git+https://github.com/johntrandall/lash

# Or the bootstrap path (clone + self-install via the bundled lash.json):
git clone https://github.com/johntrandall/lash.git ~/dev/lash && cd ~/dev/lash && ./lash install

After install, lash is on your PATH.

PyPI package name vs CLI name: the package is published as lash-installer (because lash is taken on PyPI by an unrelated project). The CLI command stays lash regardless of how you installed it.

Use

# Bootstrap lash itself if you cloned manually:
cd ~/dev/lash
./lash install

# Now 'lash' is on your PATH via ~/.local/bin/lash
# Create a manifest for your own project
cd ~/dev/my-project
cat > lash.json << 'EOF'
{
  "name": "my-project",
  "description": "My awesome tool",
  "operations": [
    {
      "type": "symlink",
      "src": "./bin/my-tool",
      "dest": "~/.local/bin/my-tool",
      "chmod": "+x"
    }
  ]
}
EOF

lash install

Commands

lash install   [path]   Create symlinks, patch JSON files, run shell commands
lash uninstall [path]   Reverse all operations (in reverse order)
lash status    [path]   Check what's installed vs missing
lash list      [dir]    Discover all lash.json manifests and show status

If no path is given, lash looks for lash.json in the current directory.

lash install

Reads the manifest forward and executes each operation. Existing files at destination paths are backed up to {path}.lash-backup before being replaced.

$ lash install
Installing my-project (3 operations)

  ✓ Linked: ~/.local/bin/my-tool → ~/dev/my-project/bin/my-tool
  ✓ JSON patched: ~/.config/app/settings.json at plugins.my-plugin
  ✓ Shell command succeeded

✓ my-project installed successfully.

lash uninstall

Reads the manifest backward (correct teardown order) and reverses each operation. Symlinks are removed. JSON patches are reverted. Shell uninstall commands run.

$ lash uninstall
Uninstalling my-project (3 operations)

  ✓ Shell command succeeded
  ✓ JSON key removed: plugins.my-plugin
  ✓ Removed symlink: ~/.local/bin/my-tool

✓ my-project uninstalled successfully.

lash status

Shows the current state of each operation without making changes.

$ lash status
Status: my-project

  ✓ ~/.local/bin/my-tool
  ✗ ~/.config/app/settings.json: plugins.my-plugin missing
  - Shell op (no status check)

◐ Partially installed (1/3)

Status indicators:

  • Fully installed — all operations green
  • Partially installed — some operations missing
  • Not installed — nothing is set up

lash list

Discovers all lash.json manifests under ~/dev/*/ (or a custom directory) and displays a summary table.

$ lash list
Name        Directory    Operations               Status
──────────  ──────────   ───────────────────────   ─────────────────
my-project  my-project   2 symlink, 1 json_merge   installed (3/3)
other-tool  other-tool   1 symlink                  not installed (0/1)

Pass a custom search directory: lash list ~/projects

Manifest format

A lash.json file lives in the root of each managed project:

{
  "name": "my-project",
  "description": "Optional human-readable description",
  "operations": [
    { "type": "symlink",    "...": "..." },
    { "type": "json_merge", "...": "..." },
    { "type": "shell",      "...": "..." }
  ]
}
Field Required Description
name no Display name (defaults to directory name)
description no What this project does
operations yes Array of operations to execute

Operations execute in array order on install, and in reverse order on uninstall.

Operation types

symlink

Creates a symbolic link from src to dest.

{
  "type": "symlink",
  "src": "./bin/my-tool",
  "dest": "~/.local/bin/my-tool",
  "chmod": "+x"
}
Field Required Description
src yes Source file (relative to manifest dir, or absolute)
dest yes Destination path (~ is expanded)
chmod no "+x" to make source executable

Behavior:

  • Parent directories for dest are created automatically
  • If dest exists as a regular file, it's backed up to {dest}.lash-backup
  • If dest is already a symlink to the correct target, it's left alone (idempotent)
  • If dest is a symlink to a different target, it's replaced
  • On uninstall, the symlink is removed and any .lash-backup file is restored

json_merge

Patches a JSON file at a dot-separated key path. Supports setting values and appending to arrays.

Action: set (default)

{
  "type": "json_merge",
  "file": "~/.config/app/settings.json",
  "path": "plugins.my-plugin.enabled",
  "value": true
}

Sets the value at the given path, creating intermediate objects as needed. On uninstall, the key is deleted.

Action: append_unique

{
  "type": "json_merge",
  "file": "~/.claude/settings.json",
  "path": "hooks.PreToolUse",
  "action": "append_unique",
  "match_key": "matcher",
  "value": {
    "matcher": "SendMessage",
    "hooks": [{"type": "command", "command": "~/.claude/hooks/my-hook.sh"}]
  }
}

Appends an object to an array, but only if no existing element matches on match_key. On uninstall, the matching element is removed. If the array becomes empty, it's cleaned up.

Field Required Description
file yes Path to the JSON file (~ expanded)
path yes Dot-separated key path (e.g. hooks.PreToolUse)
action no "set" (default) or "append_unique"
value yes Value to set or append
match_key no For append_unique: key used for idempotent matching and removal

shell

Runs arbitrary shell commands. An escape hatch for operations that don't fit symlink or json_merge.

{
  "type": "shell",
  "install": "launchctl load ~/Library/LaunchAgents/com.example.plist",
  "uninstall": "launchctl unload ~/Library/LaunchAgents/com.example.plist",
  "status": "launchctl list | grep com.example"
}
Field Required Description
install no Command to run on lash install
uninstall no Command to run on lash uninstall
status no Command to run on lash status (exit 0 = installed)

All commands run with the manifest directory as the working directory. If a field is omitted, that phase is a no-op.

Real-world examples

Self-installing CLI tool

lash dogfoods itself — here's its own manifest:

{
  "name": "lash",
  "description": "Manifest-driven symlink installer — installs itself and its skill",
  "operations": [
    {
      "type": "symlink",
      "src": "./lash",
      "dest": "~/.local/bin/lash",
      "chmod": "+x"
    },
    {
      "type": "symlink",
      "src": "./skill",
      "dest": "~/.claude/skills/lash-installer"
    }
  ]
}

Claude Code extension with hooks

A project that installs commands, hooks, and registers them in Claude's settings:

{
  "name": "claude-iterm-color",
  "description": "Dynamic iTerm2 tab coloring for Claude Code",
  "operations": [
    {
      "type": "symlink",
      "src": "./hooks/iterm-color-hook.sh",
      "dest": "~/.claude/hooks/iterm-color-hook.sh",
      "chmod": "+x"
    },
    {
      "type": "json_merge",
      "file": "~/.claude/settings.json",
      "path": "hooks.PreToolUse",
      "action": "append_unique",
      "match_key": "matcher",
      "value": {
        "matcher": "SendMessage",
        "hooks": [{"type": "command", "command": "~/.claude/hooks/iterm-color-hook.sh"}]
      }
    }
  ]
}

LaunchAgent with shell operations

{
  "name": "auto-render",
  "description": "Auto-renders diagrams on file change",
  "operations": [
    {
      "type": "symlink",
      "src": "./bin/render-diagrams",
      "dest": "~/.local/bin/render-diagrams",
      "chmod": "+x"
    },
    {
      "type": "symlink",
      "src": "./com.user.auto-render.plist",
      "dest": "~/Library/LaunchAgents/com.user.auto-render.plist"
    },
    {
      "type": "shell",
      "install": "launchctl load ~/Library/LaunchAgents/com.user.auto-render.plist",
      "uninstall": "launchctl unload ~/Library/LaunchAgents/com.user.auto-render.plist",
      "status": "launchctl list | grep -q com.user.auto-render"
    }
  ]
}

Design decisions

Why JSON, not YAML/TOML? Python's stdlib includes json. No dependencies means lash is a single file you can copy anywhere.

Why reverse order on uninstall? If operation A creates a directory and operation B places a file in it, uninstall must remove B before A. Reversing the array handles this naturally.

Why not GNU Stow? Stow maps directory trees to target trees 1:1. lash handles heterogeneous operations — symlinks to scattered destinations, JSON config patching, and arbitrary shell commands — all in one manifest.

Why not a Makefile? Makefiles are great for builds but awkward for declarative install/uninstall symmetry. lash guarantees that every install operation has a matching uninstall, with no manual bookkeeping.

Requirements

  • Python 3.9+
  • No pip packages required
  • Works on macOS and Linux

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lash_installer-0.1.0.tar.gz (9.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lash_installer-0.1.0-py3-none-any.whl (10.6 kB view details)

Uploaded Python 3

File details

Details for the file lash_installer-0.1.0.tar.gz.

File metadata

  • Download URL: lash_installer-0.1.0.tar.gz
  • Upload date:
  • Size: 9.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.16

File hashes

Hashes for lash_installer-0.1.0.tar.gz
Algorithm Hash digest
SHA256 9e98c307d4d74badfaa1431d7aa65e4b60aa57b28c173d9cbb7b9505bf3a5096
MD5 c1c633e011f5e1a42b3f01bd1c1fbf0d
BLAKE2b-256 4240b9feab6d25cca064e88a6a4510f67cc0509af80a257138fd3b23f41c5529

See more details on using hashes here.

File details

Details for the file lash_installer-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for lash_installer-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e072b9693aba98de09ada3932e0d054e3932d20ff6f1ac9ef5d942d00bf1171c
MD5 bd88017eea39196f574d00aabeca18ea
BLAKE2b-256 8885b10dfb8b35cd3cf79563fbb1e2aa2ad90788f0181401621c41bb1eee96c1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page