# Custom Commands (`!command`)

## Goal

Let REPL users run external scripts/programs via `!command [args...]`. The command is looked up in `~/.config/swival/commands/`, executed with the given arguments, and its stdout is injected into the conversation as if the user had typed it.

## Lookup

- Directory: `~/.config/swival/commands/`
- `!example foo bar` → find executable named `example` in that directory
- The file must be executable (`os.access(path, os.X_OK)`)
- No path traversal: reject names containing `/` or `..`
- If not found or not executable → print error to stderr, do not send anything to the LLM

## Execution

- Run via `subprocess.run([path, *args], capture_output=True, text=True, timeout=30)`
- `cwd` = the agent's `base_dir` (project root)
- Inherit environment variables (no sandboxing needed — user's own scripts)
- On non-zero exit: print stderr to stderr, do not inject into conversation
- On timeout: print timeout error to stderr

## Injection

- Take the command's stdout (stripped)
- If empty after strip → skip, print note to stderr
- Otherwise treat it exactly like user-typed input: append `{"role": "user", "content": output}` to `messages` and run the agent loop on it (same path as normal input)

## Where to Hook In

All changes in `swival/agent.py`, inside `repl_loop()`:

1. **Before slash-command parsing** (around line 5528), add a check:
   ```
   if line.startswith("!") and len(line) > 1:
       handle custom command
       continue
   ```
2. New helper function `_repl_run_custom_command(line, base_dir)` → returns `str | None` (the output to inject, or None on error)
3. If the helper returns a string, fall through to the normal "append user message + run agent loop" path with that string as the content

## Edge Cases

- `!` alone or `! ` (just bang + space) → ignore, treat as regular input
- Command name with special chars → reject anything that isn't `[a-zA-Z0-9_-]`
- Very large output → truncate to 100KB with a warning on stderr
- The commands directory doesn't exist → print a one-line hint on stderr ("no commands directory found at ..."), skip

## `/help` Update

Add a line to `_repl_help()`:
```
!command [args]   Run ~/.config/swival/commands/command with args; output becomes your next prompt
```

## Tests

In `tests/test_repl.py` (or a new `tests/test_custom_commands.py`):

1. Happy path: executable script returns text → verify it becomes a user message
2. Script not found → verify error on stderr, no message appended
3. Script not executable → same
4. Non-zero exit → error on stderr, no message
5. Empty output → no message
6. Path traversal attempt (`!../evil`) → rejected
7. Timeout → error on stderr
8. Large output → truncated

Use `tmp_path` to create a fake commands directory and mock scripts.
