Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,52 @@ These messages are handled by the gateway before backend dispatch:
| `/clear`, `/new`, `/reset` | Start a fresh backend session for that conversation |
| `/stop` | Stop the active request; already queued messages continue in order |
| `/help` | Return the available chat commands |
| any command in `[command_hooks]` | Run the configured shell command; stdout is the reply, no backend turn |

Starting a fresh session preserves canonical history. Push can seed the new
backend session with bounded recent turns from the exact channel-qualified
conversation.

### Command hooks

`[command_hooks]` maps chat slash commands to deterministic shell commands.
The reply is the command's trimmed stdout, relayed verbatim — no agent turn,
no tokens. A mapped command owns all its forms: message arguments are
appended to the command line as trailing positional parameters (`$1`, `$2`,
... after the command's own arguments), so command-shaped input stays
deterministic and never becomes prompt content. Names are normalized to
lowercase at load and must be ASCII letters, digits, `-` or `_` — names
shadowing built-in commands (`/clear`, `/stop`, `/help`) are rejected:

```toml
[command_hooks]
# Quick sanity check: send `/ping` in chat, expect `pong` back instantly.
ping = "echo pong"
# A longer report: the typing indicator stays on for the whole run,
# and /report <topic> reaches the script as $1.
report = "~/bin/report.sh"
```

With this config, `/report` runs `~/bin/report.sh` and `/report agents` runs
`~/bin/report.sh agents`. Built-in commands keep exact matching: `/clear
typo` reaches the backend as a regular message, unchanged. Hooks run with a
timeout and a stdout/stderr cap (output beyond 64 KiB is an error reply); a
failing or timed-out hook replies with a short error and never falls back
to the backend. Unknown slash commands still reach the backend as regular
messages. Hook output is delivered like any gateway reply and is recorded
in canonical history. `/help` lists configured hook commands under
"Custom commands" (sorted).

Hooks run in the thread's queue like any other message: when the backend is
mid-reply, a hook command waits for that turn to finish and runs afterward —
replies stay in order and never interleave. `/stop` is the exception: it acts
on the in-flight request immediately.

Hooks receive the message context as environment variables: `PUSH_THREAD`,
`PUSH_BACKEND`, `PUSH_ROW_ID`, and `PUSH_SESSION_ID` (only set when the thread
already has a backend session) — enough for info commands to report on the
current conversation.

Never put secrets in hook commands: the config is read at startup and hook
commands run with the gateway's permissions **and environment** (including
any tokens set in the service environment).
74 changes: 73 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Gateway configuration loaded from a TOML file.

use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use std::time::Duration;

Expand Down Expand Up @@ -76,6 +76,10 @@ pub struct Config {
pub agent: String,
#[serde(default)]
pub routes: Vec<RouteRule>,
/// Gateway-level slash commands: `/name [args...]` runs the mapped shell
/// command and relays its stdout verbatim, without an agent turn.
#[serde(default)]
pub command_hooks: HashMap<String, String>,
/// Canonical root of the single user-owned assistant repository.
#[serde(default)]
pub assistant_root: String,
Expand Down Expand Up @@ -243,6 +247,7 @@ impl Config {
],
)?;
let mut c: Config = value.try_into().context("parse TOML config")?;
validate_command_hooks(&mut c.command_hooks)?;
let config_path = std::fs::canonicalize(&expanded_path)
.with_context(|| format!("resolve config {expanded_path}"))?;
c.db_path = expand_home(&c.db_path);
Expand Down Expand Up @@ -864,6 +869,43 @@ impl AgentBackend {
}
}

/// Names of the built-in gateway commands; hooks may not shadow them.
const BUILTIN_COMMANDS: &[&str] = &["clear", "new", "reset", "help", "stop"];

/// Normalizes hook names to lowercase and rejects names the dispatcher can
/// never reach: empty, whitespace or path separators, or shadowing a built-in.
/// Dispatch lowercases the incoming command, so an uppercase config key would
/// be accepted but unreachable, and `/help` would advertise it.
fn validate_command_hooks(hooks: &mut HashMap<String, String>) -> Result<()> {
let mut normalized = HashMap::with_capacity(hooks.len());
for (name, command) in std::mem::take(hooks) {
if command.trim().is_empty() {
bail!("command_hooks.{name}: command must not be empty");
}
let lowered = name.to_lowercase();
if lowered.is_empty()
|| !lowered
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
bail!(
"command_hooks.{name}: names must be ASCII letters, digits, '-' or '_' (no whitespace or slashes)"
);
}
if BUILTIN_COMMANDS.contains(&lowered.as_str()) {
bail!("command_hooks.{name}: \"/{lowered}\" is a built-in command and cannot be overridden");
}
if normalized.contains_key(&lowered) {
bail!(
"command_hooks.{name}: duplicate after case-normalization — \"/{lowered}\" is already defined"
);
}
normalized.insert(lowered, command);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
*hooks = normalized;
Ok(())
}

fn default_db_path() -> String {
"~/Library/Messages/chat.db".to_string()
}
Expand Down Expand Up @@ -921,6 +963,7 @@ mod tests {
voice_name: DEFAULT_VOICE_NAME.to_string(),
agent: "codex".to_string(),
routes: Vec::new(),
command_hooks: HashMap::new(),
assistant_root: root.to_string_lossy().to_string(),
jobs_dir: root.join("jobs").to_string_lossy().to_string(),
jobs_agent: None,
Expand Down Expand Up @@ -1048,4 +1091,33 @@ mod tests {
let _ = std::fs::remove_dir_all(assistant);
let _ = std::fs::remove_dir_all(outside);
}

#[test]
fn command_hooks_normalize_names_and_reject_collisions() {
let mut hooks = HashMap::new();
hooks.insert("Status".to_string(), "echo ok".to_string());
validate_command_hooks(&mut hooks).unwrap();
assert!(hooks.contains_key("status"));
assert!(!hooks.contains_key("Status"));

let mut hooks = HashMap::new();
hooks.insert("Report".to_string(), "echo a".to_string());
hooks.insert("report".to_string(), "echo b".to_string());
let error = validate_command_hooks(&mut hooks).unwrap_err();
assert!(error
.to_string()
.contains("duplicate after case-normalization"));
}

#[test]
fn command_hooks_reject_whitespace_names_and_builtins() {
let mut hooks = HashMap::new();
hooks.insert("bad name".to_string(), "echo hi".to_string());
assert!(validate_command_hooks(&mut hooks).is_err());

let mut hooks = HashMap::new();
hooks.insert("clear".to_string(), "echo hi".to_string());
let error = validate_command_hooks(&mut hooks).unwrap_err();
assert!(error.to_string().contains("built-in"));
}
}
Loading