# Plan: Pass custom command arguments as a single string

## Goal

Change `!command a b c` from calling `command <project_root> a b c` (4 params)
to calling `command <project_root> "a b c"` (2 params only).

Leading/trailing whitespace in the argument string is stripped.
If no arguments are given, the command is called with just the project root (1 param).

## Design decisions

- **Whitespace after `!`:** Tolerated. `!  cmd arg` works the same as `!cmd arg`. The implementation strips leading whitespace from the text after `!` before extracting the command name.
- **Argument semantics:** The raw text after the command name is stripped of leading/trailing whitespace, then passed as a single `$2`. No shell parsing, no quote handling — what you type is what the command gets.
- **No migration advice in docs.** The old multi-arg calling convention is simply replaced. We don't prescribe how to split `$2` — that's up to the command author.

## Current behavior

In `swival/agent.py:_repl_run_custom_command` (line ~5357-5408):

1. `shlex.split(raw)` splits `"echo-args hello world"` into `["echo-args", "hello", "world"]`
2. `args = parts[1:]` → `["hello", "world"]`
3. `subprocess.run([str(cmd_path), base_dir, *args], ...)` → 4 separate argv entries

## Changes

### 1. `swival/agent.py` — `_repl_run_custom_command`

Replace the `shlex.split` + spread with a simple split-on-first-whitespace:

```python
raw = line[1:].lstrip()

# Split command name from the raw argument string
parts = raw.split(None, 1)  # split on whitespace, max 1 split
if not parts:
    return None
cmd_name = parts[0]
arg_string = parts[1].strip() if len(parts) > 1 else ""
```

The `lstrip()` on `raw` handles whitespace between `!` and command name. The `split(None, 1)` naturally handles multiple spaces between command name and arguments.

Then change the subprocess call:

```python
cmd = [str(cmd_path), base_dir]
if arg_string:
    cmd.append(arg_string)
subprocess.run(cmd, ...)
```

Remove the `import shlex` if it's no longer used elsewhere in the function.

### 2. `tests/test_custom_commands.py`

**Update existing tests:**

- **`test_with_arguments`**: Change script from `shift; echo "$@"` to `echo "$2"`. Input `'!echo-args "hello world" --flag'` now produces `$2 = '"hello world" --flag'` (raw, no shell parsing of quotes). Update assertion accordingly.
- **`test_shlex_parse_error`**: Rename to `test_unclosed_quotes_passed_through`. Create a stub command script that echoes `$2`. Input `'!cmd "unclosed'` should succeed with `$2 = '"unclosed'`. The current test only creates the commands directory (not a `cmd` executable), so the new version must also create the script.
- **`test_command_name_validation`**: The `"foo bar"` case no longer tests validation — under the new parser it becomes command `foo` with arg `bar`, returning `None` only because `foo` doesn't exist (not because the name is invalid). Remove `"foo bar"` from the invalid-name list. The remaining cases (`"foo;bar"`, `"foo&bar"`, `""`) still exercise the regex validation correctly.

**Add new tests:**

- `test_args_as_single_string`: `!mycmd a b c` → script echoes `$2`, assert output is `"a b c"`
- `test_no_args_only_base_dir`: `!mycmd` → script echoes `$#` (arg count), assert `"1"`
- `test_leading_trailing_spaces_stripped`: `!mycmd   a b   ` → `$2 = "a b"`
- `test_whitespace_after_bang`: `!  greet` → should work (finds `greet` command), confirms `lstrip()` behavior

### 3. `docs.md/custom-commands.md`

Update these sections:

**"How Commands Are Called" section (line 52-58):**
- Change the calling convention from `$COMMANDS_DIR/name $base_dir [args...]` to `$COMMANDS_DIR/name $base_dir "$args"` and explain `$2` is the raw argument string with leading/trailing whitespace stripped.
- If no arguments, only `$1` (project root) is passed.

**"Usage" section (line 40-50):**
- Remove the "quoted arguments are handled correctly" example — there is no quote handling anymore, the raw text is passed through.
- Replace with an example showing that everything after the command name becomes a single `$2`.

**"History" section (line 80):**
- No change needed — the label format stays the same.

### 4. Validate

Run `make all` (tests + lint/format check) and `make website` (docs rebuild) separately — `make all` does not include docs.

### 5. `CLAUDE.md`

No changes needed.

## File checklist

| File | Action |
|------|--------|
| `swival/agent.py` | Replace shlex.split + spread with split-on-first-whitespace |
| `tests/test_custom_commands.py` | Update 2 tests, add 4 new tests |
| `docs.md/custom-commands.md` | Update calling convention and usage examples |
