Files
lush/issues/30-user-builtin-shell-integration.md
Cormac Shannon 973dd90d65 Add user-defined commands via lush.commands table (issue #30)
User commands run in forked children like external commands, so they
support piping, redirection, and capture seamlessly.
2026-03-21 19:34:29 +00:00

3.7 KiB

30 — User-defined commands should behave like real shell commands

Status: open

Related: #26 (user builtins), #06 (piping), #15 (builtins)

Problem

User-defined builtins registered via lush.builtins.mycmd = function(name, arg, ...) end are called directly as Lua functions in the parent process. This means:

  1. No stdout/stderr captureprint() inside the function writes directly to the terminal, not into the {code, stdout, stderr} result table. Backtick invocation returns nil instead of a result table.
  2. No pipe support — piping to/from a user builtin fails because exec_pipeline() forks children that call execvp(), which can't find the function as an external command.
  3. No redirection support — for the same reason, >, >>, 2> etc. won't work.

Current behavior:

$ pat hello          -- prints "hello", returns nil (no result table)
$ `pat hello`        -- prints "hello", returns nil (no .stdout)
$ !pat hello | nvim  -- "pat: No such file or directory"

In bash, user-defined functions work exactly like external commands at the call site — they can be piped, redirected, and captured. Lush should match this.

Proposal

Restructure the lush table

Separate built-in commands from user-defined commands:

  • lush.builtins — reserved for C builtins (cd, exec, umask) that must run in-process because they modify parent process state. Read-only / not user-extensible.
  • lush.commands — user-defined commands. These are Lua functions that behave like shell commands: they run in a forked subprocess, their stdout/stderr are captured, and they work with pipes and redirection.

Dispatch order: alias expansion → lush.commandslush.builtins$PATH lookup.

Fork user commands into a subprocess

When dispatching a lush.commands entry:

  1. Set up stdout/stderr capture pipes (same as external command execution)
  2. fork()
  3. Child: wire stdout/stderr to the pipe write ends, call the Lua function, _exit() with the return code
  4. Parent: read captured output, waitpid(), build and return the {code, stdout, stderr} result table

This makes user commands compatible with exec_pipeline() — each pipeline stage already forks a child, so the child just needs to check lush.commands before falling through to execvp().

Important caveat: because user commands run in a forked child, they cannot modify parent Lua state. Setting a global variable inside a lush.commands function will not be visible after the command returns. This matches how bash functions behave when used in a pipeline (bash also forks subshells for pipeline stages). This trade-off should be clearly documented.

Return protocol

User commands should follow the same return convention as C builtins:

  • Return a table {code=int, stdout=string, stderr=string}, OR
  • Return an integer exit code (0 = success), OR
  • Return nothing (implies exit code 0)

The forked child captures whatever the function print()s as stdout, and uses the return value for the exit code.

Files

File Change
lcmd.c Split try_builtin() into try_builtin() (in-process, lush.builtins only) and try_user_command() (fork+capture, lush.commands); add lush.commands check in pipeline child processes
lbuiltin.c Register C builtins under lush.builtins; create empty lush.commands table for user use

Open questions

  • Should user commands receive stdin naturally (child inherits the pipe fd, io.read() works) or as a string argument? Leaning toward natural stdin inheritance — matches bash and the child's stdin is already wired.
  • Naming: lush.commands vs lush.functions vs lush.cmds? commands is clearest.