Skip to content

Plugin System

Lash supports two plugin flavours:

  • Native plugins.lash files loaded in-process on session start. Best for typed shell shortcuts (gst, gco, …), wrapper commands, and argument-completion types. Zero IPC overhead.
  • External plugins — standalone processes that communicate with the backend over Unix domain sockets. Best for cross-language integrations (csv, yaml parsers) and persistent daemons. Crash-isolated, language-agnostic.

Pick native for anything that can be expressed as lash code. Pick external when you need another runtime (Python, Go, Rust) or long-running state.

Native plugins are plain .lash files. Every top-level fn and type declaration is registered into the session scope.

Lash walks three layers in order, with later layers overriding earlier same-named declarations:

  1. Bundled — compiled into the binary via string-import. Currently: git.lash.
  2. System/usr/share/lash/plugins/*.lash (for package maintainers).
  3. User~/.lash/plugins/*.lash.

Files are sorted alphabetically within each layer. Loading is done in a fresh scope; errors are emitted but don’t abort other plugins.

/// Push the current branch and set upstream.
fn gpsup(...args: [string]) {
git push --set-upstream origin HEAD ...args
}
/// A local or remote git branch name.
type GitBranch {
cache = 2s
values(query) { `git branch -a --format=%(refname:short)` }
validate(x) { x in values("") } // optional — derived if omitted
}
  • fn accepts typed params, union types (A | B), list types ([T]), and a single trailing rest param (...args: [T]).
  • type must define values(query) -> list. validate(x) -> bool is optional; if absent lash derives it as x in values("").
  • cache = <duration> memoizes values(query) per-query for the given TTL. Units: ms, s, m, h, d. Errors are never cached.
  • Doc comments (/// or /** */) on fn, type, or parameter attach to the AST node and feed --help output and LSP hover.

When a .lash file is executed (not loaded as a plugin), a top-level fn main(...) acts as the entry point:

/// Greet someone by name.
fn main(name: string, count: int) {
for _ in 1..count { echo "Hello, $name!" }
}

CLI args are coerced through the typed signature. Missing required args or failed coercion emits a user-friendly error plus auto-generated help:

Error: Missing required argument 'count' (int)
greet.lash — Greet someone by name.
Arguments:
name (string)
count (int)

Scripts without an explicit fn main receive CLI args through an implicit args list variable. $0 resolves to the script path.

$1..$N, $@, $*, and $# are not supported — typed parameters replace them.

Terminal window
lash --list-plugins

Prints native plugins (fn signatures with doc summary, types with method list and cache TTL) alongside external ones.

External plugins are standalone processes that communicate with the backend over Unix domain sockets. They are language-agnostic, crash-isolated, and independently deployable. The remainder of this page documents their protocol.

  1. Backend reads ~/.lash/plugins.conf on startup.
  2. For each plugin, spawns the process with a socket path.
  3. Plugin opens socket, performs handshake.
  4. During execution, plugin methods are dispatched over the socket.
  5. On shutdown, plugins receive a terminate message.

In ~/.lash/plugins.conf:

[plugins]
powerline = builtin:powerline
history = builtin:history
session = builtin:session
completions = builtin:completions
highlight = builtin:highlight
frequency = builtin:frequency
banner = builtin:banner
csv = /usr/local/bin/lash-csv
yaml = ~/.lash/plugins/yaml-plugin

Built-in plugins are spawned as lash --plugin <name> <socket-path>. External plugins are spawned directly with the socket path as the first argument.

The audit plugin is always loaded regardless of plugins.conf contents, since it provides the audit and profile builtin commands.

lash --list-plugins sends plugin.describe to each plugin and displays their names, descriptions, hooks, methods, and completions.

All plugin communication uses JSON lines over Unix domain socket at ~/.lash/plugins/<name>.sock.

Backend to plugin:

{"type":"handshake","version":"1.0"}

Plugin to backend:

{
"type": "handshake_ok",
"methods": ["csv"],
"hooks": ["pipeline.post_execute", "plugin.describe"],
"completions": ["kubectl"],
"hook_config": [
{"event": "input.key", "keys": ["ctrl+c", "escape"], "mode": "blocking"},
{"event": "prompt.render", "mode": "fire_and_forget"}
]
}
FieldDescription
methodsMethod names the plugin provides for functional chains.
hooksHook events subscribed to (default mode: blocking).
completionsCommand prefixes the plugin completes (["*"] for all).
hook_config(Optional) Per-event mode and key filter overrides.

Request (backend to plugin):

{"type":"method_call","method":"audit","args":["init"],"context":{"profile":"default","session_id":"s_123","audit_access":"true"}}
FieldDescription
methodMethod name declared in handshake.
argsArray of string arguments from the command line.
contextSession context: active profile, session ID, capabilities.

Success response:

{"type":"method_response","exit_code":0,"set_profile":"user"}
FieldDescription
exit_codeExit code for the command (0 = success).
set_profile(Optional) Requests the daemon apply a profile change to the session.

During a method call, the plugin may send inline output and prompt messages before the final response (see Interactive Prompt Protocol).

Error response:

{"type":"error","problem":"CSV parse failed at row 3","suggestion":"Check for unescaped commas"}

Request:

{"type":"complete_request","text":"git ch","cursor":6,"context":{"cwd":"/home/user","env":{"PATH":"/usr/bin"},"aliases":{"ll":"ls -la"},"scope_vars":["MY_VAR"]}}

Response:

{"type":"complete_response","prefix":"ch","items":["checkout","cherry-pick"]}

Request:

{"type":"highlight_request","text":"git commit -m 'msg'","context":{"aliases":{"ll":"ls -la"},"builtins":["exit","jobs"]}}

Response:

{"type":"highlight_response","tokens":[{"offset":0,"length":3,"style":"\u001b[32m"}]}

Triggered via the plugin.describe hook:

{"type":"hook","event":"plugin.describe","session_id":"","timestamp":"...","data":{}}

Response:

{"action":"modify","data":{"description":"Human-readable plugin description"}}

Plugins can request user input during a method call. When a plugin sends a prompt message, the backend relays it to the frontend, collects the response, and sends it back to the plugin. The password never touches other plugins — it flows only through the requesting plugin’s socket.

Three prompt types are available:

Collects hidden input (echo disabled). Used for sensitive data like the audit password.

Plugin to backend:

{"type":"prompt_password","text":"Audit password: "}

Backend to plugin (after user responds):

{"type":"prompt_response","value":"hunter2"}

Collects visible text input. Used for free-form answers.

Plugin to backend:

{"type":"prompt_input","text":"Default profile name: "}

Backend to plugin:

{"type":"prompt_response","value":"developer"}

Presents a numbered list the user can navigate with arrow keys. Used for choosing from a fixed set of options.

Plugin to backend:

{"type":"prompt_select","text":"Select a default profile:","options":["default","script","ci","restricted"]}

Backend to plugin:

{"type":"prompt_response","value":"user"}

The response contains the selected option’s text, not its index.

During a method call, plugins can send text to display on the user’s terminal:

Plugin to backend:

{"type":"output","text":"Audit initialized.\n"}

The backend relays this to the frontend as terminal output.

{"type":"terminate"}

Built-in methods always take priority over plugin methods. Two plugins providing the same method name is a startup error.

If a plugin process dies, the backend reports it via a Problem/Suggestion error and the chain fails at the plugin method step. Plugins run as separate processes, so a crashed plugin cannot take down the shell.