# Custom Commands (`!command`)

## Goal

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

## Lookup

- Directory: `config.global_config_dir() / "commands"` (respects `XDG_CONFIG_HOME`)
- `!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

## Argument Parsing

Parse `line[1:]` with `shlex.split()` to correctly handle quoted arguments (e.g. `!cmd "hello world" --flag`). On `ValueError` from shlex (unclosed quotes etc.), print error to stderr and skip. First token is the command name, rest are arguments.

## 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: report `stderr or stdout or f"exit code {returncode}"` to stderr — matching the existing command-provider pattern at agent.py:2101-2103. Do not inject into conversation.
- On timeout: print timeout error to stderr
- On success: always print stderr to stderr regardless of verbose/quiet (user's own script warnings should never be swallowed), inject stdout

## Injection and Truncation

Truncation happens in `repl_loop()` after the helper returns, not inside the helper — because the loop already has `messages`, `tools`, and `context_length` in scope.

Steps:
1. Take the command's stdout (stripped). If empty → print note to stderr, skip.
2. Build a candidate message list: `messages + [{"role": "user", "content": output}]`. Call `estimate_tokens(candidate, tools)` to get the true prompt cost including per-message overhead (the ~4 token/message padding at agent.py:490-491).
3. If `context_length` is known, compute `budget = context_length - estimate_tokens(messages, tools) - MIN_OUTPUT_TOKENS - 4` (the 4 accounts for the new message's overhead). If `budget <= 0`, warn on stderr ("not enough context headroom to inject command output") and skip — do not call `truncate_to_tokens()` with a nonpositive limit. If `budget > 0` and `count_tokens(output) > budget`, truncate using `truncate_to_tokens(output, budget)` and warn on stderr.
4. Hard cap of 100KB when `context_length` is None.
5. Append the (possibly truncated) user message and run the agent loop.

No custom binary-search or character-level truncation — `truncate_to_tokens()` already handles this correctly at the token boundary.

The helper `_repl_run_custom_command(line, base_dir)` only handles lookup, parsing, and subprocess execution. It returns `(cmd_name, stdout) | None`. Truncation is the caller's job.

## History and REPL Parity

The REPL path currently uses the same `line` for both the user message (line 5758) and the history header (line 5781). Custom commands need a dedicated branch with two variables:

- `prompt_content` = raw stdout (what the model sees in the user message)
- `history_label` = `[!cmd_name] <prompt_content>` (what gets logged to `.swival/HISTORY.md`)

The dedicated branch must maintain full parity with the normal REPL input path (agent.py:5758-5785):

```python
result = _repl_run_custom_command(line, base_dir)
if result is None:
    continue
cmd_name, prompt_content = result
prompt_content = _truncate_for_context(prompt_content, messages, tools, context_length)
if prompt_content is None:
    continue

messages.append({"role": "user", "content": prompt_content})
try:
    answer, exhausted = run_agent_loop(
        messages, tools, max_turns=turn_state["max_turns"],
        **_repl_loop_kwargs,
    )
except KeyboardInterrupt:
    fmt.warning("interrupted, question aborted.")
    if continue_here:
        from .continue_here import write_continue_file
        write_continue_file(
            base_dir, messages,
            todo_state=todo_state,
            snapshot_state=snapshot_state,
            thinking_state=thinking_state,
        )
    continue

history_label = f"[!{cmd_name}] {prompt_content}"
if not no_history and answer:
    append_history(base_dir, history_label, answer, diagnostics=verbose)
if answer is not None:
    print(answer)
if exhausted and verbose:
    fmt.warning("max turns reached for this question. Use /continue to resume.")
```

This duplicates the interrupt/answer/exhaustion handling from the normal path. The alternative (factoring out a shared `_run_and_finish()` helper) is not worth it for one call site.

## 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 and not line[1:].startswith(" "):
       # dedicated custom-command branch (see above)
       continue
   ```
2. Helper function `_repl_run_custom_command(line, base_dir)` → returns `(cmd_name, stdout_text) | None`. Uses `config.global_config_dir()` for the commands directory. Pure I/O, no message/token logic.
3. Truncation helper `_truncate_for_context(text, messages, tools, context_length)` → returns truncated string or None if output is empty. Uses `estimate_tokens()` and `truncate_to_tokens()`.

## Edge Cases

- `!` alone → ignore, treat as regular input
- `!` followed by a space (`! foo`) → ignore, treat as regular input (distinguishes from shell-escape patterns)
- Command name validation → reject anything that isn't `[a-zA-Z0-9_-]`
- The commands directory doesn't exist → print a one-line hint on stderr ("no commands directory at ..."), skip

## `/help` Update

Add a line to `_repl_help()`:
```
!command [args]   Run <config_dir>/commands/command with args; output becomes your next prompt
```

## Tests

New file `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 with stderr → stderr shown, no message
5. Non-zero exit with stdout only (empty stderr) → stdout used as error text, matching command-provider behavior
6. Non-zero exit with neither → exit code shown
7. Empty output → no message
8. Path traversal attempt (`!../evil`) → rejected
9. Timeout → error on stderr
10. Quoted arguments: `!cmd "hello world"` → verify script receives two args, not three
11. Shlex parse error (unclosed quote) → error on stderr, no message
12. XDG_CONFIG_HOME override: set env var, verify commands dir resolves correctly
13. Stderr from successful command → shown to user on stderr
14. Near-full context: messages already consume most of context_length → output truncated to actual headroom via `estimate_tokens()` on candidate messages, not a fixed fraction
15. Zero/negative headroom: messages + tools + MIN_OUTPUT_TOKENS already meet or exceed context_length → warn and skip, `truncate_to_tokens()` is never called
15. History vs prompt: `run_agent_loop()` receives raw stdout, `append_history()` receives `[!cmd_name] ...` form
16. KeyboardInterrupt during agent loop → continue file written (same as normal path)
17. Exhausted turns → warning printed (same as normal path)

Use `tmp_path` to create a fake commands directory and mock scripts. Set `XDG_CONFIG_HOME` via `monkeypatch.setenv()`.
