Shell tool — let the model run commands in the project

Overview

mouaif ships a built-in shell tool the model can invoke. The tool runs a command in the project's working directory, captures stdout / stderr / exit code / duration, and returns the result to the model. Calls and results ride the chat as tool_call and tool_result SSE events (see docs/features/ai-client.md for the reserved event names) and are persisted with the transcript and written to the trace file when tracing is on. The tool is off by default per project; the user opts in through project settings and gates every call with the authorization system (docs/features/tool-authorization.md).

Usage

Enabling the tool

In Settings → Project settings → Tools (reachable from a project card's ⋯ menu → Settings…), the Shell tool carries an Off/Ask/Allow authorization segment. It persists tools.shell: { mode, allowlist } on the project's .mouaif.json. The mode is the enablement gate: off hides the tool from the model and rejects any call with ETOOL_DISABLED; ask prompts on first use; allow runs every call; allowlist auto-approves calls whose summary matches one of the patterns (see docs/features/tool-authorization.md). With a non-off mode, the AI client's outgoing request advertises the tool to the model using the OpenAI-compatible tool-call shape:

{
  "type": "function",
  "function": {
    "name": "shell",
    "description": "Run a shell command in the project directory through mouaif, the local AI coding assistant that provides this chat. Commands execute on <os> via <shell> (e.g. \"Windows via Command Prompt (cmd.exe) — Windows command-line syntax (cmd.exe batch-style quoting and escaping; not POSIX sh/bash)\" or \"macOS via zsh (/bin/zsh) — POSIX sh syntax\"). Returns stdout, stderr, and exit code. Non-interactive only: the child has no stdin, so REPLs, prompts, and commands that read from stdin fail or exit immediately — run the one-shot/flagged form instead.",
    "parameters": {
      "type": "object",
      "properties": {
        "cmd":       { "type": "string", "description": "The command to run, as a single string. Must be non-interactive (no stdin input, no REPL, no prompts)." },
        "shell":     { "type": "string", "description": "Optional. The exact interpreter to run the command with, e.g. \"cmd.exe\" (default), \"pwsh\", or \"powershell.exe\" on Windows; a path to a sh-compatible binary on POSIX. Falls back to the platform default when omitted." },
        "timeoutMs": { "type": "integer", "description": "Optional per-call timeout, 1 ms - 10 min. Default 30 000." }
      },
      "required": ["cmd"],
      "additionalProperties": false
    }
  }
}

The tool spec makes the shell dialect explicit — cmd.exe on Windows is not POSIX sh, so the model is told up front which syntax to write (see Model awareness below). An optional shell parameter lets the model (or a REST caller) pick a different interpreter for a single call; it is validated before use.

In a chat

When the model decides to call the tool, the server intercepts the call (the model only sees the tool's description; the actual execution lives behind the mouaif server), runs the command in projectDir, and forwards the result back to the upstream as a tool message before continuing the stream. This is a real multi-turn loop: the model can call the tool, read the result, and call again until it considers the task complete or the user aborts the request. The chat UI shows every call and result inline in the conversation.

The wire shape on the SSE stream:

event: tool_call
data: { "id": "call_abc123", "name": "shell", "args": { "cmd": "npm test", "timeoutMs": 60000 } }

event: shell_output
data: { "id": "call_abc123", "stream": "stdout", "delta": "> project@1.0.0 test\n" }

event: shell_output
data: { "id": "call_abc123", "stream": "stderr", "delta": "npm warn ...\n" }

event: tool_result
data: { "id": "call_abc123", "name": "shell", "ok": true, "result": { "stdout": "...", "stderr": "", "exitCode": 0, "durationMs": 4213, "identity": "mouaif shell · macOS · zsh (/bin/zsh)" } }

shell_output frames are live, best-effort output deltas emitted while the command is still running. They are never persisted to the transcript; the final tool_result carries the complete (truncated) output. The identity field names the software (mouaif shell) plus the OS and the exact shell that ran the command; the same line is prepended to the first model-facing tool message so the model always knows which environment executed its command.

The chat composer also accepts an @shell <cmd> command that runs the tool directly without going through the model. The output is rendered in the chat as a tool_result block, with the same live preview as a model-initiated call: the endpoint streams each stdout/stderr chunk to the card while the command is still running. This is the same code path as a model-initiated call — the only difference is that there is no prior tool_call from the model and the result is shown without a follow-up assistant message.

Behavior