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 truth —
lash.jsondefines 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
- Discoverable —
lash listfinds 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(becauselashis taken on PyPI by an unrelated project). The CLI command stayslashregardless 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
destare created automatically - If
destexists as a regular file, it's backed up to{dest}.lash-backup - If
destis already a symlink to the correct target, it's left alone (idempotent) - If
destis a symlink to a different target, it's replaced - On uninstall, the symlink is removed and any
.lash-backupfile 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9e98c307d4d74badfaa1431d7aa65e4b60aa57b28c173d9cbb7b9505bf3a5096
|
|
| MD5 |
c1c633e011f5e1a42b3f01bd1c1fbf0d
|
|
| BLAKE2b-256 |
4240b9feab6d25cca064e88a6a4510f67cc0509af80a257138fd3b23f41c5529
|
File details
Details for the file lash_installer-0.1.0-py3-none-any.whl.
File metadata
- Download URL: lash_installer-0.1.0-py3-none-any.whl
- Upload date:
- Size: 10.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.16
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e072b9693aba98de09ada3932e0d054e3932d20ff6f1ac9ef5d942d00bf1171c
|
|
| MD5 |
bd88017eea39196f574d00aabeca18ea
|
|
| BLAKE2b-256 |
8885b10dfb8b35cd3cf79563fbb1e2aa2ad90788f0181401621c41bb1eee96c1
|