1 //! Headless programmatic mode: `sigit run "<prompt>"` runs one prompt and exits.
2 //!
3 //! This is the entry point for CI, scripts, cron, and the Cloud Agent sandbox
4 //! runner: no client, no TTY, just plain stdio. Assistant text streams to
5 //! stdout as it is generated (`--quiet` restricts stdout to the final message);
6 //! logs and tool progress go to stderr.
7 //!
8 //! Cross-platform by design — unlike the ratatui TUI this is NOT gated on
9 //! `#[cfg(unix)]`, so Windows gets it too.
10 //!
11 //! Permission model: nobody is around to answer `ask`, so `ask` collapses to a
12 //! denial telling the model the tool was not pre-approved (mentioning
13 //! `--allow-tool`). `--allow-tool <name>` pre-grants a tool for the run's
14 //! session; `--deny-tool <name>` blocks a tool even if settings would allow it.
15 //! `SIGIT_PERMISSIONS=allow` remains the blunt instrument.
16 //!
17 //! Exit codes: 0 — turn completed; 1 — inference/tool-loop or backend
18 //! resolution error; 2 — bad invocation (handled by the caller in `main`).
19
20 use std::collections::HashSet;
21 use std::io::Write;
22 use std::path::PathBuf;
23 use std::sync::Arc;
24
25 use crate::backend::{
26 self, InferenceBackend, OpenAiBackend, ToolResult as BackendToolResult, TurnResult,
27 };
28 use crate::{permissions, provider, session_store, settings, tools};
29
30 pub const USAGE: &str = "Usage: sigit run \"<prompt>\" [--cwd <dir>] [--add-dir <dir>]... \
31 [--output text|jsonl] [--quiet] [--resume <session-id>] \
32 [--allow-tool <name>]... [--deny-tool <name>]... \
33 [--max-tool-rounds <n>]\n \
34 sigit -p \"<prompt>\" [same options]";
35
36 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37 pub enum OutputFormat {
38 #[default]
39 Text,
40 Jsonl,
41 }
42
43 /// Parsed `sigit run` or legacy `sigit -p` invocation.
44 #[derive(Debug, Clone, PartialEq, Eq)]
45 pub struct HeadlessConfig {
46 pub prompt: String,
47 /// Durable session id. A fresh UUID is generated unless `--resume` names
48 /// an existing session.
49 pub session_id: String,
50 /// Restore the saved conversation before sending `prompt`.
51 pub resume: bool,
52 /// Working directory to enter before anything loads (instruction files and
53 /// project-local MCP config then resolve from it).
54 pub cwd: Option<PathBuf>,
55 /// Extra project roots beyond `cwd`, the headless counterpart of a
56 /// multi-root project in the editor (see `workspace.rs`).
57 pub add_dirs: Vec<PathBuf>,
58 /// Print only the final assistant message to stdout (no streaming).
59 pub quiet: bool,
60 /// Machine-readable event output for CI and factory clients.
61 pub output: OutputFormat,
62 /// Tools pre-approved for the run (fed to `permissions::grant_for_session`).
63 pub allow_tools: Vec<String>,
64 /// Tools blocked for the run, overriding even settings-level allow.
65 pub deny_tools: Vec<String>,
66 /// How many rounds of tool calls the run may make before the model is
67 /// asked for its final message. `--max-tool-rounds`, else
68 /// `SIGIT_MAX_TOOL_ROUNDS`, else the built-in cap.
69 pub max_tool_rounds: usize,
70 }
71
72 /// Upper bound for `--max-tool-rounds` / `SIGIT_MAX_TOOL_ROUNDS`.
73 pub const MAX_TOOL_ROUNDS_LIMIT: usize = 500;
74
75 /// Environment override for the tool-round cap of a headless run or an ACP
76 /// prompt turn.
77 pub const MAX_TOOL_ROUNDS_ENV: &str = "SIGIT_MAX_TOOL_ROUNDS";
78
79 fn parse_tool_rounds(value: &str, source: &str) -> Result<usize, String> {
80 match value.trim().parse::<usize>() {
81 Ok(rounds) if (1..=MAX_TOOL_ROUNDS_LIMIT).contains(&rounds) => Ok(rounds),
82 _ => Err(format!(
83 "{source} must be a whole number from 1 to {MAX_TOOL_ROUNDS_LIMIT}"
84 )),
85 }
86 }
87
88 /// The tool-round cap for a run: the flag wins, then the environment, then
89 /// the built-in cap. An empty environment value counts as unset.
90 fn resolve_max_tool_rounds(flag: Option<usize>, env: Option<&str>) -> Result<usize, String> {
91 if let Some(rounds) = flag {
92 return Ok(rounds);
93 }
94 match env.map(str::trim).filter(|value| !value.is_empty()) {
95 Some(value) => parse_tool_rounds(value, MAX_TOOL_ROUNDS_ENV),
96 None => Ok(crate::MAX_TOOL_ROUNDS),
97 }
98 }
99
100 /// The tool-round cap for a surface with no flag of its own (an ACP prompt
101 /// turn). A value that does not parse is logged and ignored: the editor
102 /// launched the agent, so there is nobody to show a startup error to.
103 pub fn max_tool_rounds_from_env() -> usize {
104 resolve_max_tool_rounds(None, std::env::var(MAX_TOOL_ROUNDS_ENV).ok().as_deref())
105 .unwrap_or_else(|error| {
106 log::warn!(
107 "{error}; using the built-in cap of {}",
108 crate::MAX_TOOL_ROUNDS
109 );
110 crate::MAX_TOOL_ROUNDS
111 })
112 }
113
114 /// What the model is told when a run would otherwise end with no final
115 /// message. `cap_reached` says the tool-round cap is why.
116 fn wrap_up_prompt(cap_reached: bool, max_tool_rounds: usize) -> String {
117 let lead = if cap_reached {
118 format!("You have used all {max_tool_rounds} tool rounds available for this run. ")
119 } else {
120 String::new()
121 };
122 format!(
123 "{lead}Write your final message now, without calling any tools: what you did, \
124 what you found, and anything left unfinished."
125 )
126 }
127
128 /// Parse the process arguments (without argv[0]) for headless mode.
129 ///
130 /// Returns `Ok(None)` when neither `run` nor `-p`/`--prompt` is present — the
131 /// invocation is not headless and falls through to the TTY/ACP dispatch. Once
132 /// headless mode is selected,
133 /// every remaining argument must be a recognized flag (position-insensitive);
134 /// anything else is a usage error the caller reports on stderr with exit 2.
135 pub fn parse_args(args: &[String]) -> Result<Option<HeadlessConfig>, String> {
136 let run_command = args.first().is_some_and(|arg| arg == "run");
137 if !run_command && !args.iter().any(|arg| arg == "-p" || arg == "--prompt") {
138 return Ok(None);
139 }
140
141 let mut prompt: Option<String> = None;
142 let mut cwd: Option<PathBuf> = None;
143 let mut add_dirs: Vec<PathBuf> = Vec::new();
144 let mut quiet = false;
145 let mut output = OutputFormat::Text;
146 let mut resume_session: Option<String> = None;
147 let mut allow_tools: Vec<String> = Vec::new();
148 let mut deny_tools: Vec<String> = Vec::new();
149 let mut max_tool_rounds: Option<usize> = None;
150
151 let mut iter = args.iter().skip(usize::from(run_command));
152 while let Some(arg) = iter.next() {
153 match arg.as_str() {
154 "-p" | "--prompt" => {
155 let value = iter
156 .next()
157 .ok_or_else(|| format!("{arg} requires a prompt argument"))?;
158 if prompt.is_some() {
159 return Err("the prompt was given more than once".to_string());
160 }
161 prompt = Some(value.clone());
162 }
163 "--cwd" => {
164 let value = iter
165 .next()
166 .ok_or_else(|| "--cwd requires a directory argument".to_string())?;
167 cwd = Some(PathBuf::from(value));
168 }
169 "--add-dir" => {
170 let value = iter
171 .next()
172 .ok_or_else(|| "--add-dir requires a directory argument".to_string())?;
173 add_dirs.push(PathBuf::from(value));
174 }
175 "--quiet" => quiet = true,
176 "--output" => {
177 let value = iter
178 .next()
179 .ok_or_else(|| "--output requires text or jsonl".to_string())?;
180 output = match value.as_str() {
181 "text" => OutputFormat::Text,
182 "jsonl" => OutputFormat::Jsonl,
183 _ => return Err("--output must be text or jsonl".to_string()),
184 };
185 }
186 "--resume" => {
187 let value = iter
188 .next()
189 .ok_or_else(|| "--resume requires a session id".to_string())?;
190 if resume_session.is_some() {
191 return Err("--resume was given more than once".to_string());
192 }
193 validate_session_id(value)?;
194 resume_session = Some(value.clone());
195 }
196 "--allow-tool" => {
197 let value = iter
198 .next()
199 .ok_or_else(|| "--allow-tool requires a tool name".to_string())?;
200 allow_tools.push(value.clone());
201 }
202 "--deny-tool" => {
203 let value = iter
204 .next()
205 .ok_or_else(|| "--deny-tool requires a tool name".to_string())?;
206 deny_tools.push(value.clone());
207 }
208 "--max-tool-rounds" => {
209 let value = iter
210 .next()
211 .ok_or_else(|| "--max-tool-rounds requires a number".to_string())?;
212 max_tool_rounds = Some(parse_tool_rounds(value, "--max-tool-rounds")?);
213 }
214 // A mistyped flag must not become the prompt. A prompt that
215 // really starts with '-' can still go through -p.
216 other if other.starts_with('-') => return Err(format!("unknown argument: {other}")),
217 other if run_command && prompt.is_none() => prompt = Some(other.to_string()),
218 other if run_command => {
219 return Err(format!(
220 "unexpected extra argument: {other} (quote the prompt as one argument)"
221 ));
222 }
223 other => return Err(format!("unknown argument: {other}")),
224 }
225 }
226
227 // Reaching this without a prompt means `run` had no positional prompt, or
228 // a legacy prompt flag was consumed as another flag's value.
229 let prompt = prompt.ok_or_else(|| {
230 if run_command {
231 "missing prompt".to_string()
232 } else {
233 "missing -p/--prompt".to_string()
234 }
235 })?;
236 if prompt.trim().is_empty() {
237 return Err("the prompt must not be empty".to_string());
238 }
239 if quiet && output == OutputFormat::Jsonl {
240 return Err("--quiet cannot be combined with --output jsonl".to_string());
241 }
242
243 let resume = resume_session.is_some();
244 let session_id = resume_session.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
245 let max_tool_rounds = resolve_max_tool_rounds(
246 max_tool_rounds,
247 std::env::var(MAX_TOOL_ROUNDS_ENV).ok().as_deref(),
248 )?;
249
250 Ok(Some(HeadlessConfig {
251 prompt,
252 session_id,
253 resume,
254 cwd,
255 add_dirs,
256 quiet,
257 output,
258 allow_tools,
259 deny_tools,
260 max_tool_rounds,
261 }))
262 }
263
264 fn validate_session_id(session_id: &str) -> Result<(), String> {
265 if session_id.is_empty()
266 || !session_id.chars().all(|character| {
267 character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
268 })
269 {
270 return Err(
271 "session ids may contain only ASCII letters, numbers, '.', '_', and '-'".to_string(),
272 );
273 }
274 Ok(())
275 }
276
277 /// Denial fed to the model when a tool at `ask` level fires in a headless run.
278 fn not_preapproved_denial(tool_name: &str) -> String {
279 format!(
280 "`{tool_name}` was not executed: this is a non-interactive headless run and \
281 nobody can answer a permission prompt, so tools at the `ask` level are denied \
282 unless pre-approved. The user can re-run with `--allow-tool {tool_name}` to \
283 approve it (or set SIGIT_PERMISSIONS=allow). Do not retry the same call; \
284 continue without this tool or report what remains to be done."
285 )
286 }
287
288 /// Denial fed to the model when a tool was blocked with `--deny-tool`.
289 fn deny_flag_denial(tool_name: &str) -> String {
290 format!(
291 "`{tool_name}` is blocked for this headless run (--deny-tool). Do not retry it; \
292 continue without this tool or report what remains to be done."
293 )
294 }
295
296 /// Run one headless prompt to completion. Returns the process exit code.
297 ///
298 /// The caller (`main`) has already applied `--cwd` and `--add-dir`, initialized
299 /// logging to stderr, set up the model cache, and run MCP discovery.
300 pub async fn run(config: HeadlessConfig) -> i32 {
301 // Fresh permission state for the run, then apply the flag grants.
302 permissions::reset_session(&config.session_id);
303 tools::set_active_session(Some(&config.session_id));
304 for tool in &config.allow_tools {
305 // No call arguments at grant time: the empty string records the bare
306 // tool name, granting the whole tool — the --allow-tool contract.
307 permissions::grant_for_session(&config.session_id, tool, "");
308 }
309 let denied: HashSet<&str> = config.deny_tools.iter().map(String::as_str).collect();
310
311 // Backend resolution mirrors the ACP server: the explicit provider override
312 // first, else the signed-in cloud tier when local inference is off (what
313 // `apply_startup_inference_mode` does at every ACP session entry). There is
314 // never an implicit on-device load — a fresh process has no model in memory
315 // and headless mode must not silently download gigabytes.
316 let provider_cfg = provider::active_provider().or_else(|| {
317 if settings::local_inference_enabled() {
318 None
319 } else {
320 provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
321 }
322 });
323 let Some(cfg) = provider_cfg else {
324 emit_error(
325 &config,
326 "headless mode needs a remote inference provider — running on-device would require \
327 loading (and possibly downloading) a local model, which headless mode never does \
328 implicitly. Set OPENAI_BASE_URL and OPENAI_API_KEY (or configure providers.toml), \
329 or sign in with `sigit login` and turn local inference off.",
330 );
331 return 1;
332 };
333
334 log::info!(
335 "headless: using {} (model {}) at {}",
336 cfg.display_name,
337 cfg.model,
338 cfg.base_url
339 );
340 crate::register_subagent_factory_for(&cfg);
341
342 // Same always-on project context the other surfaces inject: cwd guidance
343 // plus AGENTS.md / CLAUDE.md instruction files.
344 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
345 let mut system_prompt = crate::system_prompt_for_model(true);
346 system_prompt.push_str("\n\n");
347 system_prompt.push_str(&crate::session_context_message(
348 &cwd,
349 &crate::workspace::additional_roots(),
350 ));
351
352 let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
353 cfg.base_url,
354 cfg.api_key,
355 cfg.model.clone(),
356 Some(system_prompt),
357 ));
358
359 if config.resume {
360 let Some(history) = session_store::load(&config.session_id) else {
361 emit_error(&config, "saved session not found");
362 return 1;
363 };
364 backend::adopt_carryover(backend.as_ref(), backend::carryover_history(history)).await;
365 }
366
367 emit_event(
368 &config,
369 serde_json::json!({
370 "type": "session",
371 "session_id": config.session_id,
372 "resumed": config.resume,
373 }),
374 );
375 if config.output == OutputFormat::Text {
376 eprintln!("Session: {}", config.session_id);
377 }
378
379 let outcome = run_prompt(backend.as_ref(), &config, &denied).await;
380
381 // Persist the conversation like the other surfaces, so a follow-up feature
382 // can resume it. Saved even on error: a partial transcript beats none.
383 let snapshot = backend.history_snapshot().await;
384 if let Err(error) = session_store::save(&config.session_id, &snapshot) {
385 log::warn!("headless: session save failed: {error}");
386 }
387 session_store::save_meta(
388 &config.session_id,
389 &cwd,
390 &crate::workspace::additional_roots(),
391 Some(&cfg.model),
392 );
393
394 match outcome {
395 Ok(()) => 0,
396 Err(error) => {
397 emit_error(&config, &error.to_string());
398 1
399 }
400 }
401 }
402
403 fn emit_event(config: &HeadlessConfig, event: serde_json::Value) {
404 if config.output == OutputFormat::Jsonl {
405 println!("{event}");
406 let _ = std::io::stdout().flush();
407 }
408 }
409
410 fn emit_error(config: &HeadlessConfig, message: &str) {
411 if config.output == OutputFormat::Jsonl {
412 emit_event(
413 config,
414 serde_json::json!({
415 "type": "error",
416 "session_id": config.session_id,
417 "message": message,
418 }),
419 );
420 } else {
421 eprintln!("sigit: {message}");
422 }
423 }
424
425 /// The turn loop: send the prompt, execute tool calls under the permission
426 /// policy, feed results back, repeat up to `config.max_tool_rounds` — the same
427 /// shape as the ACP `handle_prompt`, minus the ACP notifications.
428 async fn run_prompt(
429 backend: &dyn InferenceBackend,
430 config: &HeadlessConfig,
431 denied: &HashSet<&str>,
432 ) -> Result<(), backend::BackendError> {
433 let tools = crate::agent_tools_as_specs();
434
435 // Token sink: assistant text streams through this while a turn runs; the
436 // drain loop forwards the visible portion to stdout live. In quiet mode no
437 // sink is passed and only the final message is printed.
438 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
439 let sink_opt = if config.quiet { None } else { Some(&sink) };
440 let mut reply = crate::StreamedReply::default();
441
442 let mut result = drain_to_stdout(
443 backend.send_message_with_tools(&config.prompt, &tools, sink_opt),
444 &mut sink_rx,
445 &mut reply,
446 config,
447 )
448 .await?;
449
450 let mut round = 0;
451
452 while !result.tool_calls.is_empty() && round < config.max_tool_rounds {
453 round += 1;
454
455 // Auto-compaction: long tool runs grow history fast; fold it into a
456 // summary before the next round rather than blowing the window.
457 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
458 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
459 log::info!(
460 "headless: history ≈{estimate} tokens exceeds budget {} — compacting",
461 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
462 );
463 if let Err(error) = backend.compact_history(backend::COMPACT_KEEP_LAST).await {
464 log::warn!("headless: compaction failed: {error}");
465 }
466 }
467
468 let mut tool_results = Vec::new();
469
470 for tc in &result.tool_calls {
471 let args_preview: String = tc.arguments.chars().take(120).collect();
472 if config.output == OutputFormat::Jsonl {
473 emit_event(
474 config,
475 serde_json::json!({
476 "type": "tool_call",
477 "session_id": config.session_id,
478 "tool_call_id": tc.id,
479 "name": tc.name,
480 "arguments": tc.arguments,
481 }),
482 );
483 } else {
484 eprintln!("→ {}({args_preview})", tc.name);
485 }
486
487 // The headless deny set outranks everything, including a
488 // settings-level allow and a --allow-tool grant for the same name.
489 let output = if denied.contains(tc.name.as_str()) {
490 log::info!("headless: {} blocked by --deny-tool", tc.name);
491 deny_flag_denial(&tc.name)
492 } else {
493 match permissions::decision_for(&config.session_id, &tc.name, &tc.arguments) {
494 permissions::Decision::Allow => {
495 tools::execute_tool(&tc.name, &tc.arguments).await
496 }
497 permissions::Decision::Deny(reason) => {
498 log::info!("headless: {} denied by policy", tc.name);
499 reason
500 }
501 // Nobody can answer an interactive prompt here: ask
502 // collapses to a denial pointing at --allow-tool.
503 permissions::Decision::Ask => {
504 log::info!("headless: {} not pre-approved — denied", tc.name);
505 not_preapproved_denial(&tc.name)
506 }
507 }
508 };
509
510 emit_event(
511 config,
512 serde_json::json!({
513 "type": "tool_result",
514 "session_id": config.session_id,
515 "tool_call_id": tc.id,
516 "name": tc.name,
517 "content": output,
518 }),
519 );
520
521 tool_results.push(BackendToolResult {
522 tool_call_id: tc.id.clone(),
523 content: output,
524 });
525 }
526
527 let allow_tool_calls = round < config.max_tool_rounds;
528
529 // Whatever this round says starts a new paragraph rather than
530 // continuing the sentence the tool calls interrupted.
531 reply.interrupt();
532
533 result = drain_to_stdout(
534 backend.send_tool_results(tool_results, &tools, allow_tool_calls, sink_opt),
535 &mut sink_rx,
536 &mut reply,
537 config,
538 )
539 .await?;
540 }
541
542 // A run can reach this point with nothing to show: the last round was
543 // forced to answer in text and the model answered with nothing, most
544 // often because it was cut off at the tool-round cap mid-task. A caller
545 // reading stdout could not tell that from a run with nothing to report,
546 // so ask once, plainly, for the final message.
547 if crate::chat::strip_think_blocks(result.text.trim())
548 .1
549 .is_empty()
550 {
551 let cap_reached = round >= config.max_tool_rounds;
552 if cap_reached {
553 log::warn!(
554 "headless: reached the cap of {} tool round(s) without a final message",
555 config.max_tool_rounds
556 );
557 }
558 reply.interrupt();
559 match drain_to_stdout(
560 backend.send_message_with_tools(
561 &wrap_up_prompt(cap_reached, config.max_tool_rounds),
562 &[],
563 sink_opt,
564 ),
565 &mut sink_rx,
566 &mut reply,
567 config,
568 )
569 .await
570 {
571 Ok(wrap_up) => result = wrap_up,
572 Err(error) => log::warn!("headless: asking for a final message failed: {error}"),
573 }
574 }
575
576 // Final text: in quiet mode nothing streamed, so print the final assistant
577 // message now; otherwise print only what streaming did not already cover.
578 let (_think, final_visible) = crate::chat::strip_think_blocks(result.text.trim());
579 let mut stdout = std::io::stdout();
580 if config.output == OutputFormat::Jsonl {
581 emit_event(
582 config,
583 serde_json::json!({
584 "type": "result",
585 "session_id": config.session_id,
586 "text": final_visible,
587 "tool_rounds": round,
588 }),
589 );
590 } else if config.quiet {
591 if !final_visible.is_empty() {
592 let _ = writeln!(stdout, "{final_visible}");
593 }
594 } else if reply.streamed_any {
595 // The reply is already on stdout; end the line for the shell.
596 let _ = writeln!(stdout);
597 } else if !final_visible.is_empty() {
598 let _ = writeln!(stdout, "{final_visible}");
599 }
600 let _ = stdout.flush();
601
602 log::info!("headless: prompt complete — {round} tool round(s)");
603 Ok(())
604 }
605
606 /// Run one inference turn while forwarding streamed tokens to stdout as they
607 /// arrive — the stdio counterpart of `SiGitAgent::drain_turn`.
608 async fn drain_to_stdout<F>(
609 fut: F,
610 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
611 reply: &mut crate::StreamedReply,
612 config: &HeadlessConfig,
613 ) -> Result<TurnResult, backend::BackendError>
614 where
615 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
616 {
617 tokio::pin!(fut);
618 let result = loop {
619 tokio::select! {
620 done = &mut fut => break done,
621 Some(piece) = sink_rx.recv() => {
622 emit_visible_chunk(&piece, reply, config);
623 }
624 }
625 };
626 // Flush tokens that landed between the last poll and the future resolving.
627 while let Ok(piece) = sink_rx.try_recv() {
628 emit_visible_chunk(&piece, reply, config);
629 }
630 result
631 }
632
633 /// Fold a streamed fragment into `reply` and print whatever it newly reveals —
634 /// the stdio counterpart of `SiGitAgent::emit_visible_chunk`.
635 fn emit_visible_chunk(piece: &str, reply: &mut crate::StreamedReply, config: &HeadlessConfig) {
636 if let Some(extra) = reply.push(piece) {
637 if config.output == OutputFormat::Jsonl {
638 emit_event(
639 config,
640 serde_json::json!({
641 "type": "assistant_delta",
642 "session_id": config.session_id,
643 "text": extra,
644 }),
645 );
646 } else {
647 print!("{extra}");
648 }
649 let _ = std::io::stdout().flush();
650 }
651 }
652
653 #[cfg(test)]
654 mod tests {
655 use super::*;
656
657 fn args(list: &[&str]) -> Vec<String> {
658 list.iter().map(|s| s.to_string()).collect()
659 }
660
661 #[test]
662 fn absent_prompt_flag_is_not_headless() {
663 assert_eq!(parse_args(&args(&[])), Ok(None));
664 assert_eq!(parse_args(&args(&["login"])), Ok(None));
665 // Flags alone don't trigger headless mode; only -p/--prompt does.
666 assert_eq!(parse_args(&args(&["--quiet"])), Ok(None));
667 }
668
669 #[test]
670 fn parses_minimal_invocation() {
671 let config = parse_args(&args(&["-p", "do the thing"])).unwrap().unwrap();
672 assert_eq!(config.prompt, "do the thing");
673 assert!(!config.session_id.is_empty());
674 assert!(!config.resume);
675 assert_eq!(config.cwd, None);
676 assert!(!config.quiet);
677 assert_eq!(config.output, OutputFormat::Text);
678 assert!(config.allow_tools.is_empty());
679 assert!(config.deny_tools.is_empty());
680 }
681
682 #[test]
683 fn run_subcommand_accepts_a_positional_prompt() {
684 let config = parse_args(&args(&["run", "do the thing"]))
685 .unwrap()
686 .unwrap();
687 assert_eq!(config.prompt, "do the thing");
688 assert!(!config.resume);
689 }
690
691 #[test]
692 fn long_form_prompt_flag_works() {
693 let config = parse_args(&args(&["--prompt", "hello"])).unwrap().unwrap();
694 assert_eq!(config.prompt, "hello");
695 }
696
697 #[test]
698 fn parses_all_flags_position_insensitively() {
699 let config = parse_args(&args(&[
700 "--quiet",
701 "--allow-tool",
702 "run_command",
703 "--cwd",
704 "/tmp/project",
705 "-p",
706 "build it",
707 "--deny-tool",
708 "delete_file",
709 "--output",
710 "text",
711 "--allow-tool",
712 "edit_file",
713 ]))
714 .unwrap()
715 .unwrap();
716 assert_eq!(config.prompt, "build it");
717 assert_eq!(config.cwd, Some(PathBuf::from("/tmp/project")));
718 assert!(config.quiet);
719 assert_eq!(config.output, OutputFormat::Text);
720 assert_eq!(config.allow_tools, vec!["run_command", "edit_file"]);
721 assert_eq!(config.deny_tools, vec!["delete_file"]);
722 }
723
724 #[test]
725 fn resume_reuses_the_named_session() {
726 let config = parse_args(&args(&[
727 "run",
728 "continue",
729 "--resume",
730 "session-123",
731 "--output",
732 "jsonl",
733 ]))
734 .unwrap()
735 .unwrap();
736 assert_eq!(config.session_id, "session-123");
737 assert!(config.resume);
738 assert_eq!(config.output, OutputFormat::Jsonl);
739 }
740
741 #[test]
742 fn resume_rejects_unsafe_session_ids() {
743 let error = parse_args(&args(&["run", "continue", "--resume", "../escape"])).unwrap_err();
744 assert!(error.contains("session ids"), "{error}");
745 }
746
747 #[test]
748 fn max_tool_rounds_flag_is_parsed_and_bounded() {
749 let config = parse_args(&args(&["-p", "work", "--max-tool-rounds", "60"]))
750 .unwrap()
751 .unwrap();
752 assert_eq!(config.max_tool_rounds, 60);
753
754 for bad in ["0", "501", "-1", "many", ""] {
755 let error = parse_args(&args(&["-p", "work", "--max-tool-rounds", bad])).unwrap_err();
756 assert!(error.contains("--max-tool-rounds"), "{bad}: {error}");
757 }
758 assert!(parse_args(&args(&["-p", "work", "--max-tool-rounds"])).is_err());
759 }
760
761 #[test]
762 fn max_tool_rounds_resolves_flag_then_environment_then_default() {
763 assert_eq!(resolve_max_tool_rounds(Some(7), Some("60")), Ok(7));
764 assert_eq!(resolve_max_tool_rounds(None, Some(" 60 ")), Ok(60));
765 assert_eq!(
766 resolve_max_tool_rounds(None, None),
767 Ok(crate::MAX_TOOL_ROUNDS)
768 );
769 // An empty variable is unset, not an error.
770 assert_eq!(
771 resolve_max_tool_rounds(None, Some("")),
772 Ok(crate::MAX_TOOL_ROUNDS)
773 );
774 let error = resolve_max_tool_rounds(None, Some("lots")).unwrap_err();
775 assert!(error.contains(MAX_TOOL_ROUNDS_ENV), "{error}");
776 assert!(resolve_max_tool_rounds(None, Some("0")).is_err());
777 }
778
779 #[test]
780 fn wrap_up_prompt_names_the_cap_only_when_it_was_reached() {
781 let capped = wrap_up_prompt(true, 24);
782 assert!(capped.contains("all 24 tool rounds"), "{capped}");
783 assert!(capped.contains("without calling any tools"));
784
785 let plain = wrap_up_prompt(false, 24);
786 assert!(!plain.contains("tool rounds"), "{plain}");
787 assert!(plain.starts_with("Write your final message now"));
788 }
789
790 #[test]
791 fn quiet_and_jsonl_are_mutually_exclusive() {
792 let error =
793 parse_args(&args(&["run", "work", "--quiet", "--output", "jsonl"])).unwrap_err();
794 assert!(error.contains("cannot be combined"), "{error}");
795 }
796
797 #[test]
798 fn add_dir_is_repeatable_and_keeps_its_order() {
799 let config = parse_args(&args(&[
800 "-p",
801 "look around",
802 "--add-dir",
803 "/tmp/lib",
804 "--add-dir",
805 "/tmp/docs",
806 ]))
807 .unwrap()
808 .unwrap();
809 assert_eq!(
810 config.add_dirs,
811 vec![PathBuf::from("/tmp/lib"), PathBuf::from("/tmp/docs")]
812 );
813 }
814
815 #[test]
816 fn add_dir_without_a_value_is_a_usage_error() {
817 assert!(parse_args(&args(&["-p", "x", "--add-dir"])).is_err());
818 }
819
820 #[test]
821 fn prompt_value_may_look_like_a_flag() {
822 // -p consumes the next argument verbatim.
823 let config = parse_args(&args(&["-p", "--quiet"])).unwrap().unwrap();
824 assert_eq!(config.prompt, "--quiet");
825 assert!(!config.quiet);
826 }
827
828 #[test]
829 fn unknown_flag_is_a_usage_error() {
830 let error = parse_args(&args(&["-p", "x", "--frobnicate"])).unwrap_err();
831 assert!(error.contains("--frobnicate"), "{error}");
832 }
833
834 #[test]
835 fn missing_values_are_usage_errors() {
836 assert!(parse_args(&args(&["-p"])).is_err());
837 assert!(parse_args(&args(&["-p", "x", "--cwd"])).is_err());
838 assert!(parse_args(&args(&["-p", "x", "--allow-tool"])).is_err());
839 assert!(parse_args(&args(&["-p", "x", "--deny-tool"])).is_err());
840 assert!(parse_args(&args(&["run", "x", "--output"])).is_err());
841 assert!(parse_args(&args(&["run", "x", "--resume"])).is_err());
842 }
843
844 #[test]
845 fn duplicate_prompt_is_a_usage_error() {
846 assert!(parse_args(&args(&["-p", "a", "--prompt", "b"])).is_err());
847 }
848
849 #[test]
850 fn run_rejects_a_second_prompt_from_either_source() {
851 let error = parse_args(&args(&["run", "a", "b"])).unwrap_err();
852 assert!(error.contains("unexpected extra argument: b"), "{error}");
853 let error = parse_args(&args(&["run", "a", "-p", "b"])).unwrap_err();
854 assert!(error.contains("more than once"), "{error}");
855 let error = parse_args(&args(&["run", "-p", "a", "b"])).unwrap_err();
856 assert!(error.contains("unexpected extra argument: b"), "{error}");
857 }
858
859 #[test]
860 fn run_does_not_take_a_mistyped_flag_as_the_prompt() {
861 let error = parse_args(&args(&["run", "--quite", "fix it"])).unwrap_err();
862 assert!(error.contains("unknown argument: --quite"), "{error}");
863 }
864
865 #[test]
866 fn bare_run_reports_a_missing_prompt() {
867 assert_eq!(
868 parse_args(&args(&["run"])),
869 Err("missing prompt".to_string())
870 );
871 }
872
873 #[test]
874 fn empty_prompt_is_a_usage_error() {
875 assert!(parse_args(&args(&["-p", " "])).is_err());
876 }
877 }