use crate::config::runtime::RuntimeConfig;
use crate::interactive::session_state::SessionState;
use crate::render::RenderFormat;
use clap::Parser as _;
use std::process::{Command, Stdio};
const SUBCOMMAND_WORDS: [&str; 5] = ["cancel", "resume", "show", "log", "list"];
pub(crate) fn separate_seed_flag(tail: &str) -> (bool, &str) {
let Some(rest) = tail.strip_prefix("--seed-grants") else {
return (false, tail);
};
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return (false, tail);
}
(true, rest.trim_start())
}
pub(crate) fn spawn_run_child(
runtime: &RuntimeConfig,
format: RenderFormat,
state: &SessionState,
seed_forwarded: &[String],
tail: &str,
) -> std::io::Result<()> {
let exe = std::env::current_exe()?;
let mut command = Command::new(exe);
command
.arg("--non-interactive")
.arg("--format")
.arg(format_flag(format))
.arg("run")
.args(
child_argv(tail)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?,
);
if !seed_forwarded.is_empty() {
command.arg("--allow").args(seed_forwarded);
}
if let Some(config) = &runtime.config_path {
command.arg("--config").arg(config);
}
if let Some(connections) = &runtime.connections_path {
command.arg("--connections").arg(connections);
}
if let Some(profile) = state.profile.as_deref() {
command.arg("--profile").arg(profile);
}
if let Some(mode) = forwarded_approval_mode(state.approval_mode.as_str()) {
command.arg("--approval-mode").arg(mode);
}
command
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
command.status().map(|_| ())
}
fn child_argv(tail: &str) -> Result<Vec<String>, String> {
let tokens = tokenize_tail(tail)?;
if matches!(tokens.first(), Some(first) if SUBCOMMAND_WORDS.contains(&first.as_str())) {
return Ok(tokens);
}
let boundary = tokens
.iter()
.position(|token| token.starts_with("--"))
.unwrap_or(tokens.len());
let mut argv = vec![tokens[..boundary].join(" ")];
argv.extend(tokens[boundary..].iter().cloned());
Ok(argv)
}
fn tokenize_tail(tail: &str) -> Result<Vec<String>, String> {
let mut tokens = Vec::new();
let mut token = String::new();
let mut quote = None;
let mut escaped = false;
let mut started = false;
for character in tail.chars() {
if escaped {
token.push(character);
escaped = false;
started = true;
continue;
}
match quote {
Some(delimiter) if character == delimiter => quote = None,
Some(_) => token.push(character),
None if character == '\\' => {
escaped = true;
started = true;
}
None if matches!(character, '\'' | '"') => {
quote = Some(character);
started = true;
}
None if character.is_whitespace() => {
if started {
tokens.push(std::mem::take(&mut token));
started = false;
}
}
None => {
token.push(character);
started = true;
}
}
}
if escaped {
return Err("unmatched escape in /run tail".to_string());
}
if quote.is_some() {
return Err("unmatched quote in /run tail".to_string());
}
if started {
tokens.push(token);
}
Ok(tokens)
}
fn forwarded_approval_mode(mode: &str) -> Option<&str> {
(mode != "bypass").then_some(mode)
}
fn format_flag(format: RenderFormat) -> &'static str {
match format {
RenderFormat::Text => "text",
RenderFormat::Json => "json",
RenderFormat::Ndjson => "ndjson",
}
}
#[derive(Debug)]
pub(crate) enum RunTail {
Start {
goal: Option<String>,
allow: Vec<String>,
budget: Vec<String>,
},
Manage(crate::cli::RunCommand),
Resume(String),
}
pub(crate) fn parse_run_tail(tail: &str) -> Result<RunTail, String> {
let mut argv = vec!["saya".to_string(), "--non-interactive".to_string()];
argv.push("run".to_string());
argv.extend(child_argv(tail)?);
match crate::cli::Cli::try_parse_from(argv) {
Ok(cli) => match cli.command {
Some(crate::cli::Command::Run {
prompt,
allow,
budget,
command,
}) => match command {
Some(crate::cli::RunCommand::Resume { run_id }) => Ok(RunTail::Resume(run_id)),
Some(other) => Ok(RunTail::Manage(other)),
None => Ok(RunTail::Start {
goal: prompt,
allow,
budget,
}),
},
_ => Err("expected a run command: /run <goal> --allow <scopes>".to_string()),
},
Err(error) => Err(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_goal_tail_becomes_one_positional_then_flags() {
assert_eq!(
child_argv("survey the data --allow workspace-write").unwrap(),
["survey the data", "--allow", "workspace-write"]
);
assert_eq!(child_argv("one goal").unwrap(), ["one goal"]);
assert_eq!(
child_argv("--allow workspace-write").unwrap(),
["", "--allow", "workspace-write"]
);
}
#[test]
fn quoted_and_escaped_tail_words_become_literal_argv() {
assert_eq!(
child_argv(r#""survey the data" --allow workspace-write"#).unwrap(),
["survey the data", "--allow", "workspace-write"]
);
assert_eq!(
child_argv(r#"survey\ the\ data --allow workspace-write"#).unwrap(),
["survey the data", "--allow", "workspace-write"]
);
assert_eq!(
child_argv(r#"echo '$(touch pwned)' --allow 'runner:echo'"#).unwrap(),
["echo $(touch pwned)", "--allow", "runner:echo"]
);
match parse_run_tail(r#""survey the data" --allow workspace-write"#) {
Ok(RunTail::Start { goal, allow, .. }) => {
assert_eq!(goal.as_deref(), Some("survey the data"));
assert_eq!(allow, ["workspace-write"]);
}
other => panic!("quoted tail parses to Start, got {other:?}"),
}
let tail = match crate::slash::parse_slash_command(
r#"/run "survey the data" --allow workspace-write"#,
)
.unwrap()
{
Some(crate::slash::SlashCommand::Run(tail)) => tail,
other => panic!("quoted slash line parses to Run, got {other:?}"),
};
assert!(matches!(parse_run_tail(&tail), Ok(RunTail::Start { .. })));
}
#[test]
fn unmatched_quotes_and_escapes_are_rejected_before_clap() {
assert!(child_argv(r#""unfinished goal"#).is_err());
assert!(child_argv("unfinished\\").is_err());
assert!(parse_run_tail(r#""unfinished goal"#).is_err());
}
#[test]
fn subcommand_tails_pass_through_verbatim() {
assert_eq!(child_argv("resume r-1").unwrap(), ["resume", "r-1"]);
assert_eq!(child_argv("list").unwrap(), ["list"]);
}
#[test]
fn the_panel_parses_the_tail_through_the_child_grammar() {
match parse_run_tail("survey the data --allow workspace-write") {
Ok(RunTail::Start {
goal,
allow,
budget,
}) => {
assert_eq!(goal.as_deref(), Some("survey the data"));
assert_eq!(allow, vec!["workspace-write".to_string()]);
assert!(budget.is_empty());
}
other => panic!("a goal tail parses to Start, got {other:?}"),
}
match parse_run_tail("show r-1") {
Ok(RunTail::Manage(crate::cli::RunCommand::Show { run_id })) => {
assert_eq!(run_id, "r-1");
}
other => panic!("a show tail parses to Manage, got {other:?}"),
}
match parse_run_tail("log r-1") {
Ok(RunTail::Manage(crate::cli::RunCommand::Log { run_id })) => {
assert_eq!(run_id, "r-1");
}
other => panic!("a log tail parses to Manage, got {other:?}"),
}
match parse_run_tail("resume r-1") {
Ok(RunTail::Resume(run_id)) => assert_eq!(run_id, "r-1"),
other => panic!("a resume tail parses to Resume, got {other:?}"),
}
assert!(parse_run_tail("--nonsense").is_err());
match parse_run_tail("--budget nonsense") {
Ok(RunTail::Start { budget, .. }) => {
assert_eq!(budget, vec!["nonsense".to_string()]);
}
other => panic!("a budget tail parses to Start, got {other:?}"),
}
}
#[test]
fn a_bypass_session_s_nested_run_child_gets_no_bypass_mode() {
assert_eq!(forwarded_approval_mode("bypass"), None);
for (mode, expected) in [
("ask", Some("ask")),
("read-only", Some("read-only")),
("never", Some("never")),
] {
assert_eq!(
forwarded_approval_mode(mode),
expected,
"a {mode} session's child resolves what the session resolved"
);
}
assert!(
forwarded_approval_mode("bypassish").is_some(),
"an unknown mode word is forwarded verbatim, never treated as bypass"
);
}
#[test]
fn the_seed_flag_is_the_slash_adapter_s_first_token_only() {
use super::separate_seed_flag;
let (requested, rest) = separate_seed_flag("--seed-grants survey --allow workspace-write");
assert!(requested, "the leading flag is the adapter's request");
assert_eq!(
rest, "survey --allow workspace-write",
"the tail loses only the flag"
);
let (requested, rest) = separate_seed_flag("--seed-grants");
assert!(
requested && rest.is_empty(),
"a bare request seeds an empty tail"
);
let (requested, rest) = separate_seed_flag("survey --seed-grants --allow x");
assert!(
!requested && rest == "survey --seed-grants --allow x",
"mid-tail, the word passes to the child verbatim — its parser is the \
authority and refuses it"
);
let (requested, rest) = separate_seed_flag("--seed-grants-only survey");
assert!(
!requested && rest == "--seed-grants-only survey",
"a longer flag spelling is the child's word, never the adapter's request"
);
let (requested, rest) = separate_seed_flag("--allow x");
assert!(!requested && rest == "--allow x");
}
}