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