1use clap::{error::ErrorKind, ArgAction, CommandFactory, Parser, ValueEnum};
2use serde::Serialize;
3use serde_json::Value;
4use std::collections::{HashMap, HashSet};
5use std::ffi::OsString;
6use std::fs;
7use std::io::{self, IsTerminal, Read, Write};
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::sync::{mpsc, Arc, Mutex};
11use std::thread;
12use std::time::{Duration, Instant};
13
14mod linear_delivery;
15mod studio;
16
17const VERSION: &str = env!("CARGO_PKG_VERSION");
18const DEFAULT_TIMEOUT_MS: u64 = 600_000;
19const DEFAULT_MAX_MEMBER_CHARS: usize = 12_000;
20const DEFAULT_ITERATIONS: usize = 1;
21const DEFAULT_TEAM_SIZE: usize = 0;
22const TOKEN_ESTIMATE_CHARS_PER_TOKEN: usize = 4;
23
24const ENGINES: [&str; 3] = ["codex", "claude", "gemini"];
25const DEFAULT_SUMMARIZER_ORDER: [&str; 3] = ["codex", "claude", "gemini"];
26const DEFAULT_AUTH_MODE: &str = "auto";
27const CAPABILITY_INHERIT: &str = "inherit";
28const CAPABILITY_OVERRIDE: &str = "override";
29
30#[derive(Debug, Copy, Clone, Eq, PartialEq)]
31enum Engine {
32 Codex,
33 Claude,
34 Gemini,
35}
36
37impl Engine {
38 fn parse(name: &str) -> Option<Self> {
39 match name {
40 "codex" => Some(Self::Codex),
41 "claude" => Some(Self::Claude),
42 "gemini" => Some(Self::Gemini),
43 _ => None,
44 }
45 }
46
47 fn as_str(self) -> &'static str {
48 match self {
49 Self::Codex => "codex",
50 Self::Claude => "claude",
51 Self::Gemini => "gemini",
52 }
53 }
54
55 fn binary_env_var(self) -> &'static str {
56 match self {
57 Self::Codex => "AMON_HEN_CODEX_BIN",
58 Self::Claude => "AMON_HEN_CLAUDE_BIN",
59 Self::Gemini => "AMON_HEN_GEMINI_BIN",
60 }
61 }
62
63 fn allowed_efforts(self) -> &'static [&'static str] {
64 match self {
65 Self::Codex => &["low", "medium", "high", "xhigh", ""],
66 Self::Claude => &["low", "medium", "high", "xhigh", "max"],
67 Self::Gemini => &["low", "medium", "high", ""],
68 }
69 }
70}
71
72#[derive(Parser, Debug, Clone)]
73#[command(
74 name = "amon-hen",
75 version,
76 about = "Ask multiple AI CLIs the same question, then synthesize their answers.",
77 trailing_var_arg = true,
78 disable_version_flag = true
79)]
80pub struct CliArgs {
81 #[arg(short = 'v', long = "version", action = ArgAction::SetTrue)]
82 version: bool,
83
84 #[arg(long, action = ArgAction::SetTrue)]
85 json: bool,
86
87 #[arg(long = "json-stream", alias = "ndjson", action = ArgAction::SetTrue)]
88 json_stream: bool,
89
90 #[arg(long, action = ArgAction::SetTrue)]
91 headless: bool,
92
93 #[arg(long, action = ArgAction::SetTrue)]
94 studio: bool,
95
96 #[arg(long, action = ArgAction::SetTrue)]
97 plain: bool,
98
99 #[arg(long = "no-banner", action = ArgAction::SetTrue)]
100 no_banner: bool,
101 #[arg(long = "banner", hide = true, action = ArgAction::SetTrue)]
102 banner: bool,
103
104 #[arg(short = 'q', long = "quiet", alias = "summary-only", action = ArgAction::SetTrue)]
105 summary_only: bool,
106
107 #[arg(short = 'd', long, action = ArgAction::SetTrue)]
108 verbose: bool,
109
110 #[arg(long, action = ArgAction::SetTrue)]
111 all: bool,
112
113 #[arg(long, action = ArgAction::SetTrue)]
114 codex: bool,
115 #[arg(long = "no-codex", action = ArgAction::SetTrue)]
116 no_codex: bool,
117 #[arg(long, action = ArgAction::SetTrue)]
118 claude: bool,
119 #[arg(long = "no-claude", action = ArgAction::SetTrue)]
120 no_claude: bool,
121 #[arg(long, action = ArgAction::SetTrue)]
122 gemini: bool,
123 #[arg(long = "no-gemini", action = ArgAction::SetTrue)]
124 no_gemini: bool,
125
126 #[arg(long, value_delimiter = ',')]
127 members: Vec<String>,
128
129 #[arg(long, default_value = "auto")]
130 summarizer: String,
131
132 #[arg(long, value_enum)]
133 effort: Option<Effort>,
134
135 #[arg(long = "codex-model")]
136 codex_model: Option<String>,
137 #[arg(long = "claude-model")]
138 claude_model: Option<String>,
139 #[arg(long = "gemini-model")]
140 gemini_model: Option<String>,
141
142 #[arg(long = "codex-effort")]
143 codex_effort: Option<String>,
144 #[arg(long = "claude-effort")]
145 claude_effort: Option<String>,
146 #[arg(long = "gemini-effort")]
147 gemini_effort: Option<String>,
148
149 #[arg(long = "codex-sandbox", default_value = "read-only")]
150 codex_sandbox: String,
151 #[arg(long = "claude-permission-mode", default_value = "plan")]
152 claude_permission_mode: String,
153
154 #[arg(long = "codex-auth", default_value = "auto")]
155 codex_auth: String,
156 #[arg(long = "claude-auth", default_value = "auto")]
157 claude_auth: String,
158 #[arg(long = "gemini-auth", default_value = "auto")]
159 gemini_auth: String,
160
161 #[arg(long = "codex-capabilities", default_value = "inherit")]
162 codex_capabilities: String,
163 #[arg(long = "claude-capabilities", default_value = "inherit")]
164 claude_capabilities: String,
165 #[arg(long = "gemini-capabilities", default_value = "inherit")]
166 gemini_capabilities: String,
167
168 #[arg(long = "codex-config", action = ArgAction::Append)]
169 codex_config: Vec<String>,
170 #[arg(long = "codex-mcp-profile")]
171 codex_mcp_profile: Option<String>,
172 #[arg(long = "claude-mcp-config", action = ArgAction::Append)]
173 claude_mcp_config: Vec<String>,
174 #[arg(long = "claude-allowed-tools", value_delimiter = ',', action = ArgAction::Append)]
175 claude_allowed_tools: Vec<String>,
176 #[arg(long = "claude-disallowed-tools", value_delimiter = ',', action = ArgAction::Append)]
177 claude_disallowed_tools: Vec<String>,
178 #[arg(long = "claude-tools", value_delimiter = ',', action = ArgAction::Append)]
179 claude_tools: Vec<String>,
180 #[arg(long = "claude-agent")]
181 claude_agent: Option<String>,
182 #[arg(long = "claude-agents-json")]
183 claude_agents_json: Option<String>,
184 #[arg(long = "claude-plugin-dir", action = ArgAction::Append)]
185 claude_plugin_dir: Vec<String>,
186 #[arg(long = "claude-strict-mcp-config", action = ArgAction::SetTrue)]
187 claude_strict_mcp_config: bool,
188 #[arg(long = "claude-disable-slash-commands", action = ArgAction::SetTrue)]
189 claude_disable_slash_commands: bool,
190 #[arg(long = "gemini-settings")]
191 gemini_settings: Option<String>,
192 #[arg(long = "gemini-tools-profile", value_delimiter = ',', action = ArgAction::Append)]
193 gemini_tools_profile: Vec<String>,
194 #[arg(long = "gemini-allowed-mcp-servers", value_delimiter = ',', action = ArgAction::Append)]
195 gemini_allowed_mcp_servers: Vec<String>,
196 #[arg(long = "gemini-policy", value_delimiter = ',', action = ArgAction::Append)]
197 gemini_policy: Vec<String>,
198 #[arg(long = "gemini-admin-policy", value_delimiter = ',', action = ArgAction::Append)]
199 gemini_admin_policy: Vec<String>,
200 #[arg(long = "capabilities-status", action = ArgAction::SetTrue)]
201 capabilities_status: bool,
202
203 #[arg(long = "auth-login", action = ArgAction::SetTrue)]
204 auth_login: bool,
205 #[arg(long = "auth-status", action = ArgAction::SetTrue)]
206 auth_status: bool,
207 #[arg(long = "auth-login-providers", value_delimiter = ',')]
208 auth_login_providers: Vec<String>,
209 #[arg(long = "auth-device-code", action = ArgAction::SetTrue)]
210 auth_device_code: bool,
211 #[arg(long = "no-auth-open-browser", action = ArgAction::SetTrue)]
212 no_auth_open_browser: bool,
213 #[arg(long = "auth-open-browser", hide = true, action = ArgAction::SetTrue)]
214 auth_open_browser: bool,
215 #[arg(long = "auth-timeout", default_value_t = 300)]
216 auth_timeout: u64,
217 #[arg(long = "claude-login-mode", default_value = "claudeai")]
218 claude_login_mode: String,
219 #[arg(long = "claude-login-email")]
220 claude_login_email: Option<String>,
221
222 #[arg(long = "file", alias = "tag-file", action = ArgAction::Append)]
223 files: Vec<PathBuf>,
224 #[arg(long = "cmd", alias = "prompt-command", action = ArgAction::Append)]
225 commands: Vec<String>,
226
227 #[arg(long, action = ArgAction::SetTrue)]
228 handoff: bool,
229 #[arg(long)]
230 lead: Option<String>,
231 #[arg(long)]
232 planner: Option<String>,
233 #[arg(long, default_value_t = DEFAULT_ITERATIONS)]
234 iterations: usize,
235 #[arg(long = "team-work", alias = "teamwork", alias = "sub-agents", default_value_t = DEFAULT_TEAM_SIZE)]
236 team_work: usize,
237 #[arg(long = "codex-sub-agents")]
238 codex_sub_agents: Option<usize>,
239 #[arg(long = "claude-sub-agents")]
240 claude_sub_agents: Option<usize>,
241 #[arg(long = "gemini-sub-agents")]
242 gemini_sub_agents: Option<usize>,
243
244 #[arg(long = "deliver-linear", alias = "linear", action = ArgAction::SetTrue)]
245 deliver_linear: bool,
246 #[arg(long = "linear-setup", action = ArgAction::SetTrue)]
247 linear_setup: bool,
248 #[arg(long = "linear-status", action = ArgAction::SetTrue)]
249 linear_status: bool,
250 #[arg(long = "linear-watch", action = ArgAction::SetTrue)]
251 linear_watch: bool,
252 #[arg(long = "linear-until-complete", action = ArgAction::SetTrue)]
253 linear_until_complete: bool,
254 #[arg(long = "linear-issue", value_delimiter = ',', action = ArgAction::Append)]
255 linear_issue: Vec<String>,
256 #[arg(long = "linear-query")]
257 linear_query: Option<String>,
258 #[arg(long = "linear-project", value_delimiter = ',', action = ArgAction::Append)]
259 linear_project: Vec<String>,
260 #[arg(long = "linear-epic", value_delimiter = ',', action = ArgAction::Append)]
261 linear_epic: Vec<String>,
262 #[arg(long = "linear-team")]
263 linear_team: Option<String>,
264 #[arg(long = "linear-state")]
265 linear_state: Option<String>,
266 #[arg(long = "linear-assignee")]
267 linear_assignee: Option<String>,
268 #[arg(long = "linear-limit", default_value_t = 3)]
269 linear_limit: usize,
270 #[arg(long = "linear-endpoint")]
271 linear_endpoint: Option<String>,
272 #[arg(long = "linear-auth", default_value = "api-key")]
273 linear_auth: String,
274 #[arg(long = "linear-api-key-env", default_value = "LINEAR_API_KEY")]
275 linear_api_key_env: String,
276 #[arg(long = "linear-oauth-token-env", default_value = "LINEAR_OAUTH_TOKEN")]
277 linear_oauth_token_env: String,
278 #[arg(long = "linear-completion-gate", default_value = "delivered")]
279 linear_completion_gate: String,
280 #[arg(long = "linear-review-state")]
281 linear_review_state: Option<String>,
282 #[arg(long = "linear-ci-timeout", default_value_t = 900)]
283 linear_ci_timeout: u64,
284 #[arg(long = "linear-ci-poll-interval", default_value_t = 30)]
285 linear_ci_poll_interval: u64,
286 #[arg(long = "linear-poll-interval", default_value_t = 60)]
287 linear_poll_interval: u64,
288 #[arg(long = "linear-max-polls")]
289 linear_max_polls: Option<usize>,
290 #[arg(long = "linear-max-concurrency", default_value_t = 1)]
291 linear_max_concurrency: usize,
292 #[arg(long = "linear-max-attempts", default_value_t = 3)]
293 linear_max_attempts: usize,
294 #[arg(long = "linear-retry-base", default_value_t = 60)]
295 linear_retry_base: u64,
296 #[arg(long = "linear-workspace-strategy", default_value = "worktree")]
297 linear_workspace_strategy: String,
298 #[arg(long = "linear-state-file")]
299 linear_state_file: Option<PathBuf>,
300 #[arg(long = "linear-workspace-root")]
301 linear_workspace_root: Option<PathBuf>,
302 #[arg(long = "linear-observability-dir")]
303 linear_observability_dir: Option<PathBuf>,
304 #[arg(long = "linear-workflow-file")]
305 linear_workflow_file: Option<PathBuf>,
306 #[arg(long = "linear-attach-media", value_delimiter = ',', action = ArgAction::Append)]
307 linear_attach_media: Vec<String>,
308 #[arg(long = "linear-attachment-title")]
309 linear_attachment_title: Option<String>,
310 #[arg(long = "no-linear-comments", action = ArgAction::SetTrue)]
311 no_linear_comments: bool,
312 #[arg(long = "linear-update-review-state", action = ArgAction::SetTrue)]
313 linear_update_review_state: bool,
314 #[arg(long = "delivery-phases", value_delimiter = ',')]
315 delivery_phases: Vec<String>,
316
317 #[arg(long, default_value_t = DEFAULT_TIMEOUT_MS / 1000)]
318 timeout: u64,
319 #[arg(long = "max-member-chars", default_value_t = DEFAULT_MAX_MEMBER_CHARS)]
320 max_member_chars: usize,
321 #[arg(long, default_value = ".")]
322 cwd: PathBuf,
323 #[arg(long, default_value = "auto")]
324 color: String,
325 #[arg(long = "no-color", action = ArgAction::SetTrue)]
326 no_color: bool,
327
328 #[arg(value_name = "QUERY")]
329 prompt: Vec<String>,
330}
331
332#[derive(Copy, Clone, Debug, ValueEnum)]
333enum Effort {
334 Low,
335 Medium,
336 High,
337}
338
339impl Effort {
340 fn as_str(self) -> &'static str {
341 match self {
342 Effort::Low => "low",
343 Effort::Medium => "medium",
344 Effort::High => "high",
345 }
346 }
347}
348
349#[derive(Debug, Clone)]
350struct ResolvedArgs {
351 raw: CliArgs,
352 members: Vec<String>,
353 prompt: String,
354 cwd: PathBuf,
355}
356
357#[derive(Debug, Clone, Serialize)]
358pub struct AmonHenResult {
359 query: String,
360 cwd: String,
361 members_requested: Vec<String>,
362 summarizer_requested: String,
363 workflow: Workflow,
364 prompt_commands: Vec<CommandTelemetry>,
365 members: Vec<EngineResult>,
366 summary: EngineResult,
367}
368
369#[derive(Debug, Clone, Serialize)]
370struct Workflow {
371 handoff: bool,
372 lead: Option<String>,
373 planner: Option<String>,
374 iterations: usize,
375 team_work: usize,
376 teams: HashMap<String, usize>,
377}
378
379#[derive(Debug, Clone, Serialize)]
380pub struct EngineResult {
381 name: String,
382 bin: Option<String>,
383 status: String,
384 duration_ms: u128,
385 detail: String,
386 exit_code: Option<i32>,
387 stdout: String,
388 stderr: String,
389 output: String,
390 command: String,
391 token_usage: TokenUsage,
392 tool_calls: Vec<ToolUsage>,
393 sub_agents: Vec<EngineResult>,
394 role: String,
395 iteration: usize,
396 total_iterations: usize,
397 team_size: usize,
398}
399
400#[derive(Debug, Clone, Serialize)]
401struct TokenUsage {
402 input: usize,
403 output: usize,
404 total: usize,
405 estimated: bool,
406 source: String,
407}
408
409#[derive(Debug, Clone, Serialize)]
410struct ToolUsage {
411 name: String,
412 kind: String,
413 status: String,
414 detail: String,
415}
416
417#[derive(Debug, Clone, Serialize)]
418struct CommandTelemetry {
419 command: String,
420 status: String,
421 detail: String,
422 exit_code: Option<i32>,
423 duration_ms: u128,
424 stdout_chars: usize,
425 stderr_chars: usize,
426 timed_out: bool,
427}
428
429#[derive(Debug)]
430struct PromptContext {
431 prompt: String,
432 commands: Vec<CommandTelemetry>,
433}
434
435#[derive(Debug)]
436struct CommandResult {
437 command: String,
438 args: Vec<String>,
439 code: Option<i32>,
440 stdout: String,
441 stderr: String,
442 timed_out: bool,
443 error: Option<String>,
444 timeout_ms: u64,
445 duration_ms: u128,
446}
447
448#[derive(Debug, Clone)]
449struct EngineRunOptions {
450 prompt: String,
451 cwd: PathBuf,
452 timeout_ms: u64,
453 effort: Option<String>,
454 model: Option<String>,
455 permission: Option<String>,
456 auth: String,
457 capability: ProviderCapability,
458 role: String,
459 iteration: usize,
460 total_iterations: usize,
461 team_size: usize,
462 is_sub_agent: bool,
463}
464
465#[derive(Debug, Clone)]
466struct ProviderCapability {
467 mode: String,
468 config: Vec<String>,
469 mcp_profile: Option<String>,
470 mcp_config: Vec<String>,
471 allowed_tools: Vec<String>,
472 disallowed_tools: Vec<String>,
473 tools: Vec<String>,
474 agent: Option<String>,
475 agents_json: Option<String>,
476 plugin_dirs: Vec<String>,
477 strict_mcp_config: bool,
478 disable_slash_commands: bool,
479 settings: Option<String>,
480 tools_profile: Vec<String>,
481 allowed_mcp_servers: Vec<String>,
482 policy: Vec<String>,
483 admin_policy: Vec<String>,
484}
485
486#[derive(Debug, Clone, Serialize)]
487struct ProviderAuthStatus {
488 provider: String,
489 configured: bool,
490 status: String,
491 source: String,
492 detail: String,
493}
494
495#[derive(Debug, Clone, Serialize)]
496struct ProviderCapabilityStatus {
497 provider: String,
498 mcp: CommandTelemetry,
499 skills: Option<CommandTelemetry>,
500 tools: Option<CommandTelemetry>,
501 detail: String,
502}
503
504struct TempSettings {
505 _dir: tempfile::TempDir,
506 path: PathBuf,
507}
508
509pub fn run_from_env() -> i32 {
510 let args: Vec<OsString> = std::env::args_os().skip(1).collect();
511 run_with_args(args)
512}
513
514pub fn run_with_args<I, T>(args: I) -> i32
515where
516 I: IntoIterator<Item = T>,
517 T: Into<OsString>,
518{
519 let raw_args: Vec<OsString> = args.into_iter().map(Into::into).collect();
520 if raw_args.first().and_then(|value| value.to_str()) == Some("help") {
521 let mut command = CliArgs::command();
522 let _ = command.print_long_help();
523 println!();
524 return 0;
525 }
526
527 let mut parse_args = vec![OsString::from("amon-hen")];
528 parse_args.extend(raw_args);
529 let parsed = match CliArgs::try_parse_from(parse_args) {
530 Ok(parsed) => parsed,
531 Err(error) => {
532 let exit_code = parse_error_exit_code(error.kind());
533 let _ = error.print();
534 return exit_code;
535 }
536 };
537
538 if parsed.version {
539 println!("{VERSION}");
540 return 0;
541 }
542
543 let resolved = match resolve_args(parsed) {
544 Ok(resolved) => resolved,
545 Err(error) => {
546 eprintln!("{error}");
547 return 64;
548 }
549 };
550
551 if resolved.raw.studio {
552 return studio::run_studio(&resolved);
553 }
554
555 if resolved.raw.auth_status {
556 println!(
557 "{}",
558 render_auth_statuses(&collect_auth_statuses(&resolved))
559 );
560 if resolved.prompt.trim().is_empty()
561 && !resolved.raw.auth_login
562 && !resolved.raw.deliver_linear
563 && !resolved.raw.capabilities_status
564 {
565 return 0;
566 }
567 }
568
569 if resolved.raw.capabilities_status {
570 println!(
571 "{}",
572 render_provider_capability_statuses(&collect_provider_capability_statuses(&resolved))
573 );
574 if resolved.prompt.trim().is_empty()
575 && !resolved.raw.auth_login
576 && !resolved.raw.deliver_linear
577 {
578 return 0;
579 }
580 }
581
582 if resolved.raw.auth_login {
583 if let Err(error) = run_social_login(&resolved) {
584 eprintln!("{error}");
585 return 1;
586 }
587 if resolved.prompt.trim().is_empty() && !resolved.raw.deliver_linear {
588 return 0;
589 }
590 }
591
592 if resolved.raw.linear_setup || resolved.raw.linear_status {
593 match linear_delivery::get_linear_status(&resolved) {
594 Ok(status) => {
595 println!("{}", linear_delivery::render_linear_status(&status));
596 return 0;
597 }
598 Err(error) => {
599 eprintln!("{error}");
600 return 1;
601 }
602 }
603 }
604
605 if linear_delivery_requested(&resolved.raw) {
606 match linear_delivery::run_linear_delivery(&resolved) {
607 Ok(result) => {
608 println!(
609 "{}",
610 linear_delivery::render_linear_delivery_result(&result)
611 );
612 return if result.success { 0 } else { 1 };
613 }
614 Err(error) => {
615 eprintln!("{error}");
616 return 1;
617 }
618 }
619 }
620
621 if resolved.prompt.trim().is_empty() {
622 eprintln!(
623 "No query provided.\n\n{}",
624 CliArgs::command().render_long_help()
625 );
626 return 64;
627 }
628
629 let prompt_context = match build_prompt_context(&resolved) {
630 Ok(context) => context,
631 Err(error) => {
632 eprintln!("{error}");
633 return 1;
634 }
635 };
636
637 if should_show_banner(&resolved.raw) {
638 eprintln!("{}", render_banner());
639 }
640
641 let result = run_amon_hen(&resolved, prompt_context.prompt, prompt_context.commands);
642 if resolved.raw.json || resolved.raw.json_stream {
643 let serialized = if resolved.raw.json_stream {
644 serde_json::to_string(&result)
645 } else {
646 serde_json::to_string_pretty(&result)
647 };
648 println!("{}", serialized.unwrap_or_else(|_| "{}".to_string()));
649 } else {
650 println!("{}", render_human_result(&result, resolved.raw.verbose));
651 }
652
653 if is_success(&result) {
654 0
655 } else {
656 1
657 }
658}
659
660fn parse_error_exit_code(kind: ErrorKind) -> i32 {
661 match kind {
662 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
663 _ => 64,
664 }
665}
666
667fn resolve_args(raw: CliArgs) -> Result<ResolvedArgs, String> {
668 let members = resolve_members(&raw)?;
669 validate_engine_name(&raw.summarizer, true, "--summarizer")?;
670 if let Some(lead) = &raw.lead {
671 validate_engine_name(lead, false, "--lead")?;
672 if !members.contains(lead) {
673 return Err(format!(
674 "--lead must be one of the enabled members: {}",
675 members.join(", ")
676 ));
677 }
678 }
679 if let Some(planner) = &raw.planner {
680 validate_engine_name(planner, false, "--planner")?;
681 if !members.contains(planner) {
682 return Err(format!(
683 "--planner must be one of the enabled members: {}",
684 members.join(", ")
685 ));
686 }
687 }
688 validate_provider_effort("codex", raw.codex_effort.as_deref())?;
689 validate_provider_effort("claude", raw.claude_effort.as_deref())?;
690 validate_provider_effort("gemini", raw.gemini_effort.as_deref())?;
691 validate_choice(
692 "--claude-login-mode",
693 &raw.claude_login_mode,
694 &["claudeai", "console", "sso"],
695 )?;
696 validate_choice(
697 "--codex-capabilities",
698 &raw.codex_capabilities,
699 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
700 )?;
701 validate_choice(
702 "--claude-capabilities",
703 &raw.claude_capabilities,
704 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
705 )?;
706 validate_choice(
707 "--gemini-capabilities",
708 &raw.gemini_capabilities,
709 &[CAPABILITY_INHERIT, CAPABILITY_OVERRIDE],
710 )?;
711 validate_choice("--linear-auth", &raw.linear_auth, &["api-key", "oauth"])?;
712 validate_choice(
713 "--linear-completion-gate",
714 &raw.linear_completion_gate,
715 &["delivered", "human-review", "ci-success", "review-or-ci"],
716 )?;
717 validate_choice(
718 "--linear-workspace-strategy",
719 &raw.linear_workspace_strategy,
720 &["worktree", "copy", "none"],
721 )?;
722 if raw.linear_limit == 0 {
723 return Err("--linear-limit requires a positive integer.".to_string());
724 }
725 if raw.linear_max_concurrency == 0 {
726 return Err("--linear-max-concurrency requires a positive integer.".to_string());
727 }
728 if raw.linear_max_polls == Some(0) {
729 return Err("--linear-max-polls requires a positive integer.".to_string());
730 }
731 if raw
732 .linear_endpoint
733 .as_ref()
734 .is_some_and(|value| value.trim().is_empty())
735 {
736 return Err("--linear-endpoint requires a non-empty value.".to_string());
737 }
738 for phase in raw
739 .delivery_phases
740 .iter()
741 .map(|phase| phase.trim())
742 .filter(|phase| !phase.is_empty())
743 {
744 validate_choice(
745 "--delivery-phases",
746 phase,
747 &["plan", "implement", "verify", "ship"],
748 )?;
749 }
750
751 let mut prompt = raw.prompt.join(" ");
752 if prompt.trim().is_empty() && !io::stdin().is_terminal() {
753 let mut stdin = String::new();
754 io::stdin()
755 .read_to_string(&mut stdin)
756 .map_err(|error| format!("Failed to read stdin: {error}"))?;
757 prompt = stdin;
758 }
759 let cwd = raw.cwd.canonicalize().unwrap_or_else(|_| raw.cwd.clone());
760
761 Ok(ResolvedArgs {
762 raw,
763 members,
764 prompt,
765 cwd,
766 })
767}
768
769fn resolve_members(raw: &CliArgs) -> Result<Vec<String>, String> {
770 let mut members = if raw.members.is_empty() {
771 ENGINES
772 .iter()
773 .map(|name| (*name).to_string())
774 .collect::<Vec<_>>()
775 } else {
776 raw.members
777 .iter()
778 .map(|name| name.trim().to_string())
779 .filter(|name| !name.is_empty())
780 .collect::<Vec<_>>()
781 };
782
783 if raw.all {
784 members = ENGINES.iter().map(|name| (*name).to_string()).collect();
785 }
786 if raw.no_codex {
787 members.retain(|name| name != "codex");
788 }
789 if raw.no_claude {
790 members.retain(|name| name != "claude");
791 }
792 if raw.no_gemini {
793 members.retain(|name| name != "gemini");
794 }
795 for enabled in [
796 (raw.codex, "codex"),
797 (raw.claude, "claude"),
798 (raw.gemini, "gemini"),
799 ] {
800 if enabled.0 && !members.iter().any(|name| name == enabled.1) {
801 members.push(enabled.1.to_string());
802 }
803 }
804
805 members.dedup();
806 for member in &members {
807 validate_engine_name(member, false, "--members")?;
808 }
809 if members.is_empty() {
810 return Err("At least one member must be enabled.".to_string());
811 }
812 Ok(members)
813}
814
815fn validate_engine_name(name: &str, allow_auto: bool, flag: &str) -> Result<(), String> {
816 if allow_auto && name == "auto" {
817 return Ok(());
818 }
819 if Engine::parse(name).is_some() {
820 Ok(())
821 } else {
822 Err(format!(
823 "{flag} must be one of: {}",
824 if allow_auto {
825 "auto, codex, claude, gemini"
826 } else {
827 "codex, claude, gemini"
828 }
829 ))
830 }
831}
832
833fn validate_provider_effort(provider: &str, value: Option<&str>) -> Result<(), String> {
834 let Some(value) = value else {
835 return Ok(());
836 };
837 let engine = Engine::parse(provider).expect("provider effort validation uses known engines");
838 if engine.allowed_efforts().contains(&value) {
839 Ok(())
840 } else {
841 Err(format!("Unsupported --{provider}-effort value: {value}"))
842 }
843}
844
845fn validate_choice(flag: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
846 if allowed.contains(&value) {
847 Ok(())
848 } else {
849 Err(format!("{flag} must be one of: {}", allowed.join(", ")))
850 }
851}
852
853fn collect_auth_statuses(resolved: &ResolvedArgs) -> Vec<ProviderAuthStatus> {
854 resolved
855 .members
856 .iter()
857 .map(|provider| provider_auth_status(provider, resolved))
858 .collect()
859}
860
861fn provider_auth_status(provider: &str, resolved: &ResolvedArgs) -> ProviderAuthStatus {
862 let auth = provider_auth(resolved, provider);
863 match provider {
864 "codex" => {
865 let bin = resolve_binary("codex");
866 let args = vec!["login".to_string(), "status".to_string()];
867 status_from_command(provider, &auth, &bin, &args, &resolved.cwd)
868 }
869 "claude" => {
870 let bin = resolve_binary("claude");
871 let args = vec![
872 "auth".to_string(),
873 "status".to_string(),
874 "--text".to_string(),
875 ];
876 status_from_command(provider, &auth, &bin, &args, &resolved.cwd)
877 }
878 "gemini" => {
879 let has_api_key = std::env::var("GEMINI_API_KEY")
880 .ok()
881 .is_some_and(|value| !value.trim().is_empty());
882 let home = std::env::var("HOME").unwrap_or_default();
883 let has_oauth_file = !home.is_empty()
884 && [
885 ".gemini/oauth_creds.json",
886 ".gemini/oauth_tokens.json",
887 ".gemini/settings.json",
888 ]
889 .iter()
890 .any(|path| Path::new(&home).join(path).exists());
891 let configured = has_api_key || has_oauth_file;
892 ProviderAuthStatus {
893 provider: provider.to_string(),
894 configured,
895 status: if configured { "configured" } else { "unknown" }.to_string(),
896 source: auth,
897 detail: if has_api_key {
898 "GEMINI_API_KEY is present.".to_string()
899 } else if has_oauth_file {
900 "Gemini local auth/config files are present.".to_string()
901 } else {
902 "Gemini CLI does not currently expose a stable headless auth status command; use Social login from Studio or run gemini interactively.".to_string()
903 },
904 }
905 }
906 _ => ProviderAuthStatus {
907 provider: provider.to_string(),
908 configured: false,
909 status: "unknown".to_string(),
910 source: auth,
911 detail: "Unknown provider.".to_string(),
912 },
913 }
914}
915
916fn status_from_command(
917 provider: &str,
918 auth: &str,
919 bin: &str,
920 args: &[String],
921 cwd: &Path,
922) -> ProviderAuthStatus {
923 let result = run_command(bin, args, cwd, None, 15_000, HashMap::new());
924 let configured = result.code == Some(0);
925 ProviderAuthStatus {
926 provider: provider.to_string(),
927 configured,
928 status: if configured {
929 "configured"
930 } else if result.error.is_some() {
931 "missing-cli"
932 } else {
933 "not-configured"
934 }
935 .to_string(),
936 source: auth.to_string(),
937 detail: sanitize_status_detail(&compact_failure(&result)),
938 }
939}
940
941fn sanitize_status_detail(detail: &str) -> String {
942 let detail = detail.trim();
943 if detail.is_empty() {
944 return "No status detail returned.".to_string();
945 }
946 let detail = detail
947 .lines()
948 .take(6)
949 .map(str::trim)
950 .filter(|line| !line.is_empty())
951 .map(redact_possible_secret)
952 .collect::<Vec<_>>()
953 .join(" ");
954 redact_local_paths(&detail)
955}
956
957fn redact_possible_secret(value: &str) -> String {
958 value
959 .split_whitespace()
960 .map(|token| {
961 if looks_sensitive_token(token) {
962 "[redacted]"
963 } else if looks_like_email(token) {
964 "[email]"
965 } else {
966 token
967 }
968 })
969 .collect::<Vec<_>>()
970 .join(" ")
971}
972
973fn looks_sensitive_token(token: &str) -> bool {
974 let trimmed =
975 token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_');
976 trimmed.len() >= 28
977 && (trimmed.starts_with("sk-")
978 || trimmed.starts_with("sk_")
979 || trimmed.starts_with("gho_")
980 || trimmed.starts_with("ghp_")
981 || trimmed.starts_with("ghs_")
982 || trimmed.starts_with("ghu_")
983 || trimmed.starts_with("AIza")
984 || trimmed.starts_with("AQ."))
985}
986
987fn looks_like_email(token: &str) -> bool {
988 let trimmed = token.trim_matches(|ch: char| {
989 !ch.is_ascii_alphanumeric() && !matches!(ch, '@' | '.' | '_' | '-' | '+')
990 });
991 trimmed.contains('@') && trimmed.rsplit_once('.').is_some()
992}
993
994fn redact_local_paths(value: &str) -> String {
995 let mut redacted = value.to_string();
996 if let Ok(home) = std::env::var("HOME") {
997 if !home.trim().is_empty() {
998 redacted = redacted.replace(&home, "~");
999 }
1000 }
1001 redacted
1002}
1003
1004fn render_auth_statuses(statuses: &[ProviderAuthStatus]) -> String {
1005 let mut lines = vec!["Provider auth status".to_string()];
1006 for status in statuses {
1007 lines.push(format!(
1008 "- {}: {} via {} ({})",
1009 status.provider, status.status, status.source, status.detail
1010 ));
1011 }
1012 lines.join("\n")
1013}
1014
1015fn collect_provider_capability_statuses(resolved: &ResolvedArgs) -> Vec<ProviderCapabilityStatus> {
1016 resolved
1017 .members
1018 .iter()
1019 .map(|provider| provider_capability_status(provider, resolved))
1020 .collect()
1021}
1022
1023fn provider_capability_status(provider: &str, resolved: &ResolvedArgs) -> ProviderCapabilityStatus {
1024 match provider {
1025 "codex" => {
1026 let bin = resolve_binary("codex");
1027 let mcp = run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd);
1028 ProviderCapabilityStatus {
1029 provider: provider.to_string(),
1030 mcp,
1031 skills: None,
1032 tools: Some(run_capability_probe(
1033 &bin,
1034 &["plugin", "marketplace", "--help"],
1035 &resolved.cwd,
1036 )),
1037 detail:
1038 "Codex inherits ~/.codex config unless --codex-capabilities override is set."
1039 .to_string(),
1040 }
1041 }
1042 "claude" => {
1043 let bin = resolve_binary("claude");
1044 ProviderCapabilityStatus {
1045 provider: provider.to_string(),
1046 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd),
1047 skills: Some(run_capability_probe(&bin, &["plugin", "list"], &resolved.cwd)),
1048 tools: Some(run_capability_probe(&bin, &["agents"], &resolved.cwd)),
1049 detail: "Claude override can manage MCP config, tools, agents, plugin dirs, and slash-command skills.".to_string(),
1050 }
1051 }
1052 "gemini" => {
1053 let bin = resolve_binary("gemini");
1054 ProviderCapabilityStatus {
1055 provider: provider.to_string(),
1056 mcp: run_capability_probe(&bin, &["mcp", "list"], &resolved.cwd),
1057 skills: Some(run_capability_probe(&bin, &["skills", "list"], &resolved.cwd)),
1058 tools: Some(run_capability_probe(&bin, &["extensions", "list"], &resolved.cwd)),
1059 detail: "Gemini override can manage settings, extensions, MCP server allowlists, and policy files.".to_string(),
1060 }
1061 }
1062 _ => ProviderCapabilityStatus {
1063 provider: provider.to_string(),
1064 mcp: CommandTelemetry {
1065 command: provider.to_string(),
1066 status: "unknown".to_string(),
1067 detail: "Unknown provider.".to_string(),
1068 exit_code: None,
1069 duration_ms: 0,
1070 stdout_chars: 0,
1071 stderr_chars: 0,
1072 timed_out: false,
1073 },
1074 skills: None,
1075 tools: None,
1076 detail: "Unknown provider.".to_string(),
1077 },
1078 }
1079}
1080
1081fn run_capability_probe(bin: &str, args: &[&str], cwd: &Path) -> CommandTelemetry {
1082 let args = args
1083 .iter()
1084 .map(|arg| (*arg).to_string())
1085 .collect::<Vec<_>>();
1086 command_telemetry(&run_command(bin, &args, cwd, None, 20_000, HashMap::new()))
1087}
1088
1089fn render_provider_capability_statuses(statuses: &[ProviderCapabilityStatus]) -> String {
1090 let mut lines = vec!["Provider capabilities".to_string()];
1091 for status in statuses {
1092 lines.push(format!(
1093 "- {} MCP: {} ({})",
1094 status.provider, status.mcp.status, status.mcp.command
1095 ));
1096 if !status.mcp.detail.trim().is_empty() {
1097 lines.push(format!(" {}", status.mcp.detail));
1098 }
1099 if let Some(skills) = &status.skills {
1100 lines.push(format!(
1101 " skills/plugins: {} ({})",
1102 skills.status, skills.command
1103 ));
1104 if !skills.detail.trim().is_empty() {
1105 lines.push(format!(" {}", skills.detail));
1106 }
1107 }
1108 if let Some(tools) = &status.tools {
1109 lines.push(format!(
1110 " tools/extensions: {} ({})",
1111 tools.status, tools.command
1112 ));
1113 if !tools.detail.trim().is_empty() {
1114 lines.push(format!(" {}", tools.detail));
1115 }
1116 }
1117 lines.push(format!(" {}", status.detail));
1118 }
1119 lines.join("\n")
1120}
1121
1122fn run_social_login(resolved: &ResolvedArgs) -> Result<(), String> {
1123 let providers = if resolved.raw.auth_login_providers.is_empty() {
1124 resolved.members.clone()
1125 } else {
1126 resolved.raw.auth_login_providers.clone()
1127 };
1128
1129 for provider in providers {
1130 validate_engine_name(&provider, false, "--auth-login-providers")?;
1131 let (bin, args, instruction): (String, Vec<String>, &str) =
1132 match provider.as_str() {
1133 "codex" => {
1134 let mut args = vec!["login".to_string()];
1135 if resolved.raw.auth_device_code {
1136 args.push("--device-auth".to_string());
1137 }
1138 (
1139 resolve_binary("codex"),
1140 args,
1141 "Complete the Codex browser login. Deeplinks and pasted codes are supported by the provider CLI when prompted.",
1142 )
1143 }
1144 "claude" => {
1145 let mut args = vec!["auth".to_string(), "login".to_string()];
1146 match resolved.raw.claude_login_mode.as_str() {
1147 "console" => args.push("--console".to_string()),
1148 "sso" => args.push("--sso".to_string()),
1149 _ => args.push("--claudeai".to_string()),
1150 }
1151 if let Some(email) = &resolved.raw.claude_login_email {
1152 push_arg(&mut args, "--email", email.clone());
1153 }
1154 (
1155 resolve_binary("claude"),
1156 args,
1157 "Complete the Claude browser login. Paste any shown login code into this terminal when prompted; deeplinks are opened when the CLI emits them.",
1158 )
1159 }
1160 "gemini" => (
1161 resolve_binary("gemini"),
1162 vec![],
1163 "Use the Gemini CLI auth selector, choose browser/social login, and complete local callback or code paste when prompted.",
1164 ),
1165 _ => unreachable!(),
1166 };
1167 eprintln!(
1168 "[auth] launching {provider}: {}",
1169 format_command(&bin, &args)
1170 );
1171 eprintln!("[auth] {provider}: {instruction}");
1172 let result = run_interactive_auth_command(
1173 &bin,
1174 &args,
1175 &resolved.cwd,
1176 resolved.raw.auth_timeout * 1000,
1177 !resolved.raw.no_auth_open_browser,
1178 &provider,
1179 );
1180 if result.code.unwrap_or(1) != 0 {
1181 return Err(format!(
1182 "{provider} social login failed: {}",
1183 compact_failure(&result)
1184 ));
1185 }
1186 let status = provider_auth_status(&provider, resolved);
1187 eprintln!("[auth] {provider}: {} ({})", status.status, status.detail);
1188 }
1189 Ok(())
1190}
1191
1192fn run_interactive_auth_command(
1193 command: &str,
1194 args: &[String],
1195 cwd: &Path,
1196 timeout_ms: u64,
1197 open_browser: bool,
1198 provider: &str,
1199) -> CommandResult {
1200 let started = Instant::now();
1201 let mut child = match Command::new(command)
1202 .args(args)
1203 .current_dir(cwd)
1204 .stdin(Stdio::inherit())
1205 .stdout(Stdio::piped())
1206 .stderr(Stdio::piped())
1207 .spawn()
1208 {
1209 Ok(child) => child,
1210 Err(error) => {
1211 return CommandResult {
1212 command: command.to_string(),
1213 args: args.to_vec(),
1214 code: None,
1215 stdout: String::new(),
1216 stderr: String::new(),
1217 timed_out: false,
1218 error: Some(error.to_string()),
1219 timeout_ms,
1220 duration_ms: started.elapsed().as_millis(),
1221 }
1222 }
1223 };
1224
1225 let seen_urls = Arc::new(Mutex::new(HashSet::new()));
1226 let stdout = child.stdout.take().map(|pipe| {
1227 read_auth_pipe(
1228 pipe,
1229 true,
1230 provider.to_string(),
1231 open_browser,
1232 Arc::clone(&seen_urls),
1233 )
1234 });
1235 let stderr = child.stderr.take().map(|pipe| {
1236 read_auth_pipe(
1237 pipe,
1238 false,
1239 provider.to_string(),
1240 open_browser,
1241 Arc::clone(&seen_urls),
1242 )
1243 });
1244 let timeout = Duration::from_millis(timeout_ms);
1245 let mut timed_out = false;
1246 let code;
1247 loop {
1248 match child.try_wait() {
1249 Ok(Some(status)) => {
1250 code = status.code();
1251 break;
1252 }
1253 Ok(None) => {
1254 if timeout_ms > 0 && started.elapsed() >= timeout {
1255 timed_out = true;
1256 let _ = child.kill();
1257 let status = child.wait().ok();
1258 code = status.and_then(|status| status.code());
1259 break;
1260 }
1261 thread::sleep(Duration::from_millis(50));
1262 }
1263 Err(error) => {
1264 return CommandResult {
1265 command: command.to_string(),
1266 args: args.to_vec(),
1267 code: None,
1268 stdout: String::new(),
1269 stderr: String::new(),
1270 timed_out,
1271 error: Some(error.to_string()),
1272 timeout_ms,
1273 duration_ms: started.elapsed().as_millis(),
1274 }
1275 }
1276 }
1277 }
1278
1279 CommandResult {
1280 command: command.to_string(),
1281 args: args.to_vec(),
1282 code,
1283 stdout: stdout
1284 .and_then(|handle| handle.join().ok())
1285 .unwrap_or_default(),
1286 stderr: stderr
1287 .and_then(|handle| handle.join().ok())
1288 .unwrap_or_default(),
1289 timed_out,
1290 error: None,
1291 timeout_ms,
1292 duration_ms: started.elapsed().as_millis(),
1293 }
1294}
1295
1296fn read_auth_pipe<R>(
1297 mut pipe: R,
1298 stdout: bool,
1299 provider: String,
1300 open_browser: bool,
1301 seen_urls: Arc<Mutex<HashSet<String>>>,
1302) -> thread::JoinHandle<String>
1303where
1304 R: Read + Send + 'static,
1305{
1306 thread::spawn(move || {
1307 let mut text = String::new();
1308 let mut buffer = [0u8; 4096];
1309 loop {
1310 let read = match pipe.read(&mut buffer) {
1311 Ok(0) => break,
1312 Ok(read) => read,
1313 Err(_) => break,
1314 };
1315 let chunk = String::from_utf8_lossy(&buffer[..read]).to_string();
1316 text.push_str(&chunk);
1317 if stdout {
1318 print!("{chunk}");
1319 let _ = io::stdout().flush();
1320 } else {
1321 eprint!("{chunk}");
1322 let _ = io::stderr().flush();
1323 }
1324 if open_browser {
1325 for url in extract_auth_urls(&chunk) {
1326 let mut seen = seen_urls.lock().ok();
1327 if seen.as_mut().is_some_and(|seen| !seen.insert(url.clone())) {
1328 continue;
1329 }
1330 eprintln!("[auth] {provider}: opening {url}");
1331 if let Err(error) = open_browser_url(&url) {
1332 eprintln!("[auth] {provider}: failed to open {url}: {error}");
1333 }
1334 }
1335 }
1336 }
1337 text
1338 })
1339}
1340
1341fn extract_auth_urls(text: &str) -> Vec<String> {
1342 text.split_whitespace()
1343 .map(|token| {
1344 token.trim_matches(|ch: char| {
1345 matches!(ch, '<' | '>' | '"' | '\'' | ')' | '(' | ',' | ';')
1346 })
1347 })
1348 .map(|token| token.trim_end_matches(['.', ':', ',', ';']))
1349 .filter(|token| {
1350 token.starts_with("http://")
1351 || token.starts_with("https://")
1352 || token.starts_with("codex://")
1353 || token.starts_with("openai://")
1354 || token.starts_with("claude://")
1355 || token.starts_with("anthropic://")
1356 || token.starts_with("gemini://")
1357 || token.starts_with("google://")
1358 })
1359 .map(ToString::to_string)
1360 .collect()
1361}
1362
1363fn open_browser_url(url: &str) -> Result<(), String> {
1364 let (command, args): (&str, Vec<String>) = if cfg!(target_os = "macos") {
1365 ("open", vec![url.to_string()])
1366 } else if cfg!(target_os = "windows") {
1367 (
1368 "cmd",
1369 vec![
1370 "/c".to_string(),
1371 "start".to_string(),
1372 "".to_string(),
1373 url.to_string(),
1374 ],
1375 )
1376 } else {
1377 ("xdg-open", vec![url.to_string()])
1378 };
1379 Command::new(command)
1380 .args(args)
1381 .stdin(Stdio::null())
1382 .stdout(Stdio::null())
1383 .stderr(Stdio::null())
1384 .spawn()
1385 .map(|mut child| {
1386 let _ = child.try_wait();
1387 })
1388 .map_err(|error| error.to_string())
1389}
1390
1391fn build_prompt_context(resolved: &ResolvedArgs) -> Result<PromptContext, String> {
1392 let mut prompt = resolved.prompt.trim().to_string();
1393 let mut sections = Vec::new();
1394 let mut commands = Vec::new();
1395
1396 for file in &resolved.raw.files {
1397 let path = if file.is_absolute() {
1398 file.clone()
1399 } else {
1400 resolved.cwd.join(file)
1401 };
1402 let content = fs::read_to_string(&path)
1403 .map_err(|error| format!("Failed to read tagged file {}: {error}", path.display()))?;
1404 sections.push(format!("--- file: {} ---\n{}", file.display(), content));
1405 }
1406
1407 for command in &resolved.raw.commands {
1408 let shell = if cfg!(windows) { "cmd" } else { "sh" };
1409 let args = if cfg!(windows) {
1410 vec!["/C".to_string(), command.clone()]
1411 } else {
1412 vec!["-lc".to_string(), command.clone()]
1413 };
1414 let result = run_command(
1415 shell,
1416 &args,
1417 &resolved.cwd,
1418 None,
1419 resolved.raw.timeout * 1000,
1420 HashMap::new(),
1421 );
1422 commands.push(command_telemetry(&result));
1423 sections.push(format!(
1424 "--- command: {} (exit {}) ---\nstdout:\n{}\nstderr:\n{}",
1425 command,
1426 result
1427 .code
1428 .map(|code| code.to_string())
1429 .unwrap_or_else(|| "unknown".to_string()),
1430 result.stdout.trim(),
1431 result.stderr.trim()
1432 ));
1433 }
1434
1435 if !sections.is_empty() {
1436 prompt.push_str("\n\nPrompt context:\n");
1437 prompt.push_str(§ions.join("\n\n"));
1438 }
1439 Ok(PromptContext { prompt, commands })
1440}
1441
1442fn run_amon_hen(
1443 resolved: &ResolvedArgs,
1444 query: String,
1445 prompt_commands: Vec<CommandTelemetry>,
1446) -> AmonHenResult {
1447 let workflow = build_workflow(resolved);
1448 let mut previous_iteration = Vec::new();
1449 let mut final_members = Vec::new();
1450
1451 for iteration in 1..=workflow.iterations {
1452 final_members = run_iteration(resolved, &query, &workflow, iteration, &previous_iteration);
1453 previous_iteration = final_members.clone();
1454 }
1455
1456 let successes = final_members
1457 .iter()
1458 .filter(|result| result.status == "ok")
1459 .cloned()
1460 .collect::<Vec<_>>();
1461 let summary = if successes.is_empty() {
1462 EngineResult {
1463 name: resolved.raw.summarizer.clone(),
1464 bin: None,
1465 status: "error".to_string(),
1466 duration_ms: 0,
1467 detail: "No Amon Hen member produced a response.".to_string(),
1468 exit_code: None,
1469 stdout: String::new(),
1470 stderr: String::new(),
1471 output: String::new(),
1472 command: String::new(),
1473 token_usage: token_usage("", ""),
1474 tool_calls: vec![],
1475 sub_agents: vec![],
1476 role: "summary".to_string(),
1477 iteration: workflow.iterations,
1478 total_iterations: workflow.iterations,
1479 team_size: 0,
1480 }
1481 } else {
1482 let summary_prompt =
1483 build_summary_prompt(&query, &successes, &workflow, resolved.raw.max_member_chars);
1484 let summarizer = pick_summarizer(resolved, &successes);
1485 let mut options = engine_options(
1486 resolved,
1487 &summarizer,
1488 summary_prompt,
1489 "summary",
1490 workflow.iterations,
1491 &workflow,
1492 );
1493 options.role = "summary".to_string();
1494 options.team_size = 0;
1495 run_engine(&summarizer, options)
1496 };
1497
1498 AmonHenResult {
1499 query,
1500 cwd: resolved.cwd.display().to_string(),
1501 members_requested: resolved.members.clone(),
1502 summarizer_requested: resolved.raw.summarizer.clone(),
1503 workflow,
1504 prompt_commands,
1505 members: final_members,
1506 summary,
1507 }
1508}
1509
1510fn build_workflow(resolved: &ResolvedArgs) -> Workflow {
1511 let mut teams = HashMap::new();
1512 for member in ENGINES {
1513 teams.insert(member.to_string(), resolved.raw.team_work);
1514 }
1515 if let Some(value) = resolved.raw.codex_sub_agents {
1516 teams.insert("codex".to_string(), value);
1517 }
1518 if let Some(value) = resolved.raw.claude_sub_agents {
1519 teams.insert("claude".to_string(), value);
1520 }
1521 if let Some(value) = resolved.raw.gemini_sub_agents {
1522 teams.insert("gemini".to_string(), value);
1523 }
1524 Workflow {
1525 handoff: resolved.raw.handoff,
1526 lead: resolved.raw.lead.clone(),
1527 planner: resolved.raw.planner.clone(),
1528 iterations: resolved.raw.iterations.max(1),
1529 team_work: resolved.raw.team_work,
1530 teams,
1531 }
1532}
1533
1534fn run_iteration(
1535 resolved: &ResolvedArgs,
1536 query: &str,
1537 workflow: &Workflow,
1538 iteration: usize,
1539 previous_iteration: &[EngineResult],
1540) -> Vec<EngineResult> {
1541 let ordered = execution_order(&resolved.members, workflow.planner.as_deref());
1542 if workflow.handoff {
1543 let mut results = Vec::new();
1544 for member in ordered {
1545 let role = role_for(&member, workflow);
1546 let prompt = build_member_prompt(
1547 query,
1548 &role,
1549 workflow,
1550 iteration,
1551 previous_iteration,
1552 &results,
1553 "",
1554 );
1555 let options = engine_options(resolved, &member, prompt, &role, iteration, workflow);
1556 results.push(run_engine(&member, options));
1557 }
1558 return results;
1559 }
1560
1561 let planner = workflow
1562 .planner
1563 .as_ref()
1564 .filter(|planner| ordered.contains(planner))
1565 .cloned();
1566 let mut results = Vec::new();
1567 let mut plan_output = String::new();
1568 if let Some(planner_name) = planner.as_ref() {
1569 let role = role_for(planner_name, workflow);
1570 let prompt = build_member_prompt(
1571 query,
1572 &role,
1573 workflow,
1574 iteration,
1575 previous_iteration,
1576 &[],
1577 "",
1578 );
1579 let options = engine_options(resolved, planner_name, prompt, &role, iteration, workflow);
1580 let result = run_engine(planner_name, options);
1581 if result.status == "ok" {
1582 plan_output = result.output.clone();
1583 }
1584 results.push(result);
1585 }
1586
1587 let (tx, rx) = mpsc::channel();
1588 let executors = ordered
1589 .into_iter()
1590 .filter(|member| Some(member) != planner.as_ref())
1591 .collect::<Vec<_>>();
1592 let resolved = Arc::new(resolved.clone());
1593 let workflow = Arc::new(workflow.clone());
1594 let previous_iteration = Arc::new(previous_iteration.to_vec());
1595 let plan_output = Arc::new(plan_output);
1596 for member in executors.clone() {
1597 let tx = tx.clone();
1598 let resolved = Arc::clone(&resolved);
1599 let workflow = Arc::clone(&workflow);
1600 let query = query.to_string();
1601 let previous_iteration = Arc::clone(&previous_iteration);
1602 let plan_output = Arc::clone(&plan_output);
1603 thread::spawn(move || {
1604 let role = role_for(&member, &workflow);
1605 let prompt = build_member_prompt(
1606 &query,
1607 &role,
1608 &workflow,
1609 iteration,
1610 previous_iteration.as_slice(),
1611 &[],
1612 &plan_output,
1613 );
1614 let options = engine_options(&resolved, &member, prompt, &role, iteration, &workflow);
1615 let _ = tx.send(run_engine(&member, options));
1616 });
1617 }
1618 drop(tx);
1619 let mut executor_results = rx.into_iter().collect::<Vec<_>>();
1620 executor_results.sort_by_key(|result| {
1621 executors
1622 .iter()
1623 .position(|member| member == &result.name)
1624 .unwrap_or(usize::MAX)
1625 });
1626 results.extend(executor_results);
1627 results
1628}
1629
1630fn engine_options(
1631 resolved: &ResolvedArgs,
1632 member: &str,
1633 prompt: String,
1634 role: &str,
1635 iteration: usize,
1636 workflow: &Workflow,
1637) -> EngineRunOptions {
1638 EngineRunOptions {
1639 prompt,
1640 cwd: resolved.cwd.clone(),
1641 timeout_ms: resolved.raw.timeout * 1000,
1642 effort: provider_effort(resolved, member),
1643 model: provider_model(resolved, member),
1644 permission: provider_permission(resolved, member),
1645 auth: provider_auth(resolved, member),
1646 capability: provider_capability(resolved, member),
1647 role: role.to_string(),
1648 iteration,
1649 total_iterations: workflow.iterations,
1650 team_size: *workflow.teams.get(member).unwrap_or(&DEFAULT_TEAM_SIZE),
1651 is_sub_agent: false,
1652 }
1653}
1654
1655fn provider_effort(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1656 provider_option(
1657 member,
1658 &resolved.raw.codex_effort,
1659 &resolved.raw.claude_effort,
1660 &resolved.raw.gemini_effort,
1661 )
1662 .or_else(|| {
1663 resolved
1664 .raw
1665 .effort
1666 .map(|effort| effort.as_str().to_string())
1667 })
1668}
1669
1670fn provider_model(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1671 provider_option(
1672 member,
1673 &resolved.raw.codex_model,
1674 &resolved.raw.claude_model,
1675 &resolved.raw.gemini_model,
1676 )
1677}
1678
1679fn provider_permission(resolved: &ResolvedArgs, member: &str) -> Option<String> {
1680 match Engine::parse(member) {
1681 Some(Engine::Codex) => Some(resolved.raw.codex_sandbox.clone()),
1682 Some(Engine::Claude) => Some(resolved.raw.claude_permission_mode.clone()),
1683 Some(Engine::Gemini) | None => None,
1684 }
1685}
1686
1687fn provider_auth(resolved: &ResolvedArgs, member: &str) -> String {
1688 match Engine::parse(member) {
1689 Some(Engine::Codex) => resolved.raw.codex_auth.clone(),
1690 Some(Engine::Claude) => resolved.raw.claude_auth.clone(),
1691 Some(Engine::Gemini) => resolved.raw.gemini_auth.clone(),
1692 None => DEFAULT_AUTH_MODE.to_string(),
1693 }
1694}
1695
1696fn provider_capability(resolved: &ResolvedArgs, member: &str) -> ProviderCapability {
1697 match Engine::parse(member).expect("provider capabilities use validated engines") {
1698 Engine::Codex => ProviderCapability {
1699 mode: inferred_capability_mode(
1700 &resolved.raw.codex_capabilities,
1701 !resolved.raw.codex_config.is_empty() || resolved.raw.codex_mcp_profile.is_some(),
1702 ),
1703 config: resolved.raw.codex_config.clone(),
1704 mcp_profile: resolved.raw.codex_mcp_profile.clone(),
1705 mcp_config: vec![],
1706 allowed_tools: vec![],
1707 disallowed_tools: vec![],
1708 tools: vec![],
1709 agent: None,
1710 agents_json: None,
1711 plugin_dirs: vec![],
1712 strict_mcp_config: false,
1713 disable_slash_commands: false,
1714 settings: None,
1715 tools_profile: vec![],
1716 allowed_mcp_servers: vec![],
1717 policy: vec![],
1718 admin_policy: vec![],
1719 },
1720 Engine::Claude => ProviderCapability {
1721 mode: inferred_capability_mode(
1722 &resolved.raw.claude_capabilities,
1723 !resolved.raw.claude_mcp_config.is_empty()
1724 || !resolved.raw.claude_allowed_tools.is_empty()
1725 || !resolved.raw.claude_disallowed_tools.is_empty()
1726 || !resolved.raw.claude_tools.is_empty()
1727 || resolved.raw.claude_agent.is_some()
1728 || resolved.raw.claude_agents_json.is_some()
1729 || !resolved.raw.claude_plugin_dir.is_empty()
1730 || resolved.raw.claude_strict_mcp_config
1731 || resolved.raw.claude_disable_slash_commands,
1732 ),
1733 config: vec![],
1734 mcp_profile: None,
1735 mcp_config: resolved.raw.claude_mcp_config.clone(),
1736 allowed_tools: resolved.raw.claude_allowed_tools.clone(),
1737 disallowed_tools: resolved.raw.claude_disallowed_tools.clone(),
1738 tools: resolved.raw.claude_tools.clone(),
1739 agent: resolved.raw.claude_agent.clone(),
1740 agents_json: resolved.raw.claude_agents_json.clone(),
1741 plugin_dirs: resolved.raw.claude_plugin_dir.clone(),
1742 strict_mcp_config: resolved.raw.claude_strict_mcp_config,
1743 disable_slash_commands: resolved.raw.claude_disable_slash_commands,
1744 settings: None,
1745 tools_profile: vec![],
1746 allowed_mcp_servers: vec![],
1747 policy: vec![],
1748 admin_policy: vec![],
1749 },
1750 Engine::Gemini => ProviderCapability {
1751 mode: inferred_capability_mode(
1752 &resolved.raw.gemini_capabilities,
1753 resolved.raw.gemini_settings.is_some()
1754 || !resolved.raw.gemini_tools_profile.is_empty()
1755 || !resolved.raw.gemini_allowed_mcp_servers.is_empty()
1756 || !resolved.raw.gemini_policy.is_empty()
1757 || !resolved.raw.gemini_admin_policy.is_empty(),
1758 ),
1759 config: vec![],
1760 mcp_profile: None,
1761 mcp_config: vec![],
1762 allowed_tools: vec![],
1763 disallowed_tools: vec![],
1764 tools: vec![],
1765 agent: None,
1766 agents_json: None,
1767 plugin_dirs: vec![],
1768 strict_mcp_config: false,
1769 disable_slash_commands: false,
1770 settings: resolved.raw.gemini_settings.clone(),
1771 tools_profile: resolved.raw.gemini_tools_profile.clone(),
1772 allowed_mcp_servers: resolved.raw.gemini_allowed_mcp_servers.clone(),
1773 policy: resolved.raw.gemini_policy.clone(),
1774 admin_policy: resolved.raw.gemini_admin_policy.clone(),
1775 },
1776 }
1777}
1778
1779fn inferred_capability_mode(configured: &str, has_override_flags: bool) -> String {
1780 if configured == CAPABILITY_INHERIT && has_override_flags {
1781 CAPABILITY_OVERRIDE.to_string()
1782 } else {
1783 configured.to_string()
1784 }
1785}
1786
1787fn provider_option(
1788 member: &str,
1789 codex: &Option<String>,
1790 claude: &Option<String>,
1791 gemini: &Option<String>,
1792) -> Option<String> {
1793 match Engine::parse(member) {
1794 Some(Engine::Codex) => codex.clone(),
1795 Some(Engine::Claude) => claude.clone(),
1796 Some(Engine::Gemini) => gemini.clone(),
1797 None => None,
1798 }
1799}
1800
1801fn build_sub_agent_prompt(original: &str, role: &str, index: usize, total: usize) -> String {
1802 format!(
1803 "You are sub-agent {index} of {total} for an Amon Hen provider assigned role `{role}`.\n\
1804 Work independently on a useful slice of the task. Inspect, reason, or verify as needed, \
1805 then return concise findings, risks, and concrete recommendations for the provider lead.\n\n\
1806 Original provider prompt:\n{original}"
1807 )
1808}
1809
1810fn build_team_lead_prompt(original: &str, sub_agents: &[EngineResult]) -> String {
1811 let handoff = sub_agents
1812 .iter()
1813 .map(|agent| {
1814 format!(
1815 "### {} [{}]\n{}",
1816 agent.role,
1817 agent.status,
1818 if agent.output.trim().is_empty() {
1819 agent.detail.trim()
1820 } else {
1821 agent.output.trim()
1822 }
1823 )
1824 })
1825 .collect::<Vec<_>>()
1826 .join("\n\n");
1827 format!(
1828 "You are the provider lead. Use the sub-agent handoffs below, resolve disagreements, \
1829 and produce the final provider response for the original Amon Hen role.\n\n\
1830 Sub-agent handoffs:\n{handoff}\n\nOriginal provider prompt:\n{original}"
1831 )
1832}
1833
1834fn run_engine(name: &str, options: EngineRunOptions) -> EngineResult {
1835 if options.team_size > 0 && options.role != "summary" && !options.is_sub_agent {
1836 return run_engine_team(name, options);
1837 }
1838 run_engine_single(name, options, vec![])
1839}
1840
1841fn run_engine_team(name: &str, options: EngineRunOptions) -> EngineResult {
1842 let team_size = options.team_size;
1843 let (tx, rx) = mpsc::channel();
1844 for index in 1..=team_size {
1845 let tx = tx.clone();
1846 let name = name.to_string();
1847 let mut sub_options = options.clone();
1848 sub_options.team_size = 0;
1849 sub_options.is_sub_agent = true;
1850 sub_options.role = format!("{}:sub-agent-{index}", options.role);
1851 sub_options.prompt =
1852 build_sub_agent_prompt(&options.prompt, &options.role, index, team_size);
1853 thread::spawn(move || {
1854 let _ = tx.send((index, run_engine_single(&name, sub_options, vec![])));
1855 });
1856 }
1857 drop(tx);
1858
1859 let mut indexed = rx.into_iter().collect::<Vec<_>>();
1860 indexed.sort_by_key(|(index, _)| *index);
1861 let sub_agents = indexed
1862 .into_iter()
1863 .map(|(_, result)| result)
1864 .collect::<Vec<_>>();
1865 let mut lead_options = options.clone();
1866 lead_options.prompt = build_team_lead_prompt(&options.prompt, &sub_agents);
1867 lead_options.is_sub_agent = true;
1868 run_engine_single(name, lead_options, sub_agents)
1869}
1870
1871fn run_engine_single(
1872 name: &str,
1873 options: EngineRunOptions,
1874 sub_agents: Vec<EngineResult>,
1875) -> EngineResult {
1876 let started = Instant::now();
1877 let bin = resolve_binary(name);
1878 let result = match Engine::parse(name) {
1879 Some(Engine::Codex) => run_codex(&bin, &options),
1880 Some(Engine::Claude) => run_claude(&bin, &options),
1881 Some(Engine::Gemini) => run_gemini(&bin, &options),
1882 None => CommandResult {
1883 command: name.to_string(),
1884 args: vec![],
1885 code: None,
1886 stdout: String::new(),
1887 stderr: String::new(),
1888 timed_out: false,
1889 error: Some(format!("Unknown engine: {name}")),
1890 timeout_ms: options.timeout_ms,
1891 duration_ms: started.elapsed().as_millis(),
1892 },
1893 };
1894 finalize_engine(
1895 name,
1896 &bin,
1897 started.elapsed().as_millis(),
1898 result,
1899 options,
1900 sub_agents,
1901 )
1902}
1903
1904fn push_arg(args: &mut Vec<String>, flag: &str, value: impl Into<String>) {
1905 args.push(flag.to_string());
1906 args.push(value.into());
1907}
1908
1909fn push_optional_arg(args: &mut Vec<String>, flag: &str, value: &Option<String>) {
1910 if let Some(value) = value {
1911 push_arg(args, flag, value.clone());
1912 }
1913}
1914
1915fn push_repeated_flag(args: &mut Vec<String>, flag: &str, values: &[String]) {
1916 if !values.is_empty() {
1917 args.push(flag.to_string());
1918 args.extend(values.iter().cloned());
1919 }
1920}
1921
1922fn push_each_arg(args: &mut Vec<String>, flag: &str, values: &[String]) {
1923 for value in values {
1924 push_arg(args, flag, value.clone());
1925 }
1926}
1927
1928fn run_codex(bin: &str, options: &EngineRunOptions) -> CommandResult {
1929 let temp = match tempfile::tempdir() {
1930 Ok(temp) => temp,
1931 Err(error) => {
1932 return CommandResult {
1933 command: bin.to_string(),
1934 args: vec![],
1935 code: None,
1936 stdout: String::new(),
1937 stderr: String::new(),
1938 timed_out: false,
1939 error: Some(error.to_string()),
1940 timeout_ms: options.timeout_ms,
1941 duration_ms: 0,
1942 }
1943 }
1944 };
1945 let output_path = temp.path().join("last-message.txt");
1946 let mut args = vec!["exec".to_string()];
1947 push_optional_arg(&mut args, "--model", &options.model);
1948 if let Some(effort) = &options.effort {
1949 push_arg(&mut args, "-c", format!("model_reasoning_effort={effort}"));
1950 }
1951 if options.capability.mode == CAPABILITY_OVERRIDE {
1952 for config in &options.capability.config {
1953 push_arg(&mut args, "-c", config.clone());
1954 }
1955 push_optional_arg(&mut args, "--profile", &options.capability.mcp_profile);
1956 }
1957 args.extend([
1958 "--skip-git-repo-check".to_string(),
1959 "--sandbox".to_string(),
1960 options
1961 .permission
1962 .clone()
1963 .unwrap_or_else(|| "read-only".to_string()),
1964 "--ephemeral".to_string(),
1965 "--json".to_string(),
1966 "-o".to_string(),
1967 output_path.display().to_string(),
1968 "-".to_string(),
1969 ]);
1970 let mut result = run_command(
1971 bin,
1972 &args,
1973 &options.cwd,
1974 Some(&options.prompt),
1975 options.timeout_ms,
1976 HashMap::new(),
1977 );
1978 if let Ok(output) = fs::read_to_string(output_path) {
1979 if !output.trim().is_empty() {
1980 result.stdout = output;
1981 }
1982 }
1983 result
1984}
1985
1986fn run_claude(bin: &str, options: &EngineRunOptions) -> CommandResult {
1987 let mut args = Vec::new();
1988 if should_use_claude_bare_mode(&options.auth) {
1989 args.push("--bare".to_string());
1990 }
1991 args.extend([
1992 "-p".to_string(),
1993 "--permission-mode".to_string(),
1994 options
1995 .permission
1996 .clone()
1997 .unwrap_or_else(|| "plan".to_string()),
1998 "--verbose".to_string(),
1999 "--output-format".to_string(),
2000 "stream-json".to_string(),
2001 "--include-partial-messages".to_string(),
2002 "--no-session-persistence".to_string(),
2003 ]);
2004 push_optional_arg(&mut args, "--model", &options.model);
2005 if let Some(effort) = options
2006 .effort
2007 .clone()
2008 .or_else(|| std::env::var("CLAUDE_CODE_EFFORT_LEVEL").ok())
2009 {
2010 push_arg(&mut args, "--effort", effort);
2011 }
2012 if options.capability.mode == CAPABILITY_OVERRIDE {
2013 push_repeated_flag(&mut args, "--mcp-config", &options.capability.mcp_config);
2014 push_repeated_flag(
2015 &mut args,
2016 "--allowedTools",
2017 &options.capability.allowed_tools,
2018 );
2019 push_repeated_flag(
2020 &mut args,
2021 "--disallowedTools",
2022 &options.capability.disallowed_tools,
2023 );
2024 push_repeated_flag(&mut args, "--tools", &options.capability.tools);
2025 push_optional_arg(&mut args, "--agent", &options.capability.agent);
2026 push_optional_arg(&mut args, "--agents", &options.capability.agents_json);
2027 push_each_arg(&mut args, "--plugin-dir", &options.capability.plugin_dirs);
2028 if options.capability.strict_mcp_config {
2029 args.push("--strict-mcp-config".to_string());
2030 }
2031 if options.capability.disable_slash_commands {
2032 args.push("--disable-slash-commands".to_string());
2033 }
2034 }
2035 run_command(
2036 bin,
2037 &args,
2038 &options.cwd,
2039 Some(&options.prompt),
2040 options.timeout_ms,
2041 HashMap::new(),
2042 )
2043}
2044
2045fn run_gemini(bin: &str, options: &EngineRunOptions) -> CommandResult {
2046 let mut args = Vec::new();
2047 push_optional_arg(&mut args, "--model", &options.model);
2048 if options.capability.mode == CAPABILITY_OVERRIDE {
2049 push_repeated_flag(&mut args, "--extensions", &options.capability.tools_profile);
2050 push_repeated_flag(
2051 &mut args,
2052 "--allowed-mcp-server-names",
2053 &options.capability.allowed_mcp_servers,
2054 );
2055 push_repeated_flag(&mut args, "--policy", &options.capability.policy);
2056 push_repeated_flag(
2057 &mut args,
2058 "--admin-policy",
2059 &options.capability.admin_policy,
2060 );
2061 }
2062 args.extend([
2063 "-p".to_string(),
2064 options.prompt.clone(),
2065 "--skip-trust".to_string(),
2066 "--approval-mode".to_string(),
2067 "plan".to_string(),
2068 "--output-format".to_string(),
2069 "json".to_string(),
2070 ]);
2071 let mut envs = HashMap::new();
2072 let effort_settings = prepare_gemini_settings(options);
2073 if let Some(path) = effort_settings.as_ref() {
2074 envs.insert(
2075 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2076 path.path.display().to_string(),
2077 );
2078 } else if options.capability.mode == CAPABILITY_OVERRIDE {
2079 if let Some(settings) = &options.capability.settings {
2080 envs.insert(
2081 "GEMINI_CLI_SYSTEM_SETTINGS_PATH".to_string(),
2082 settings.clone(),
2083 );
2084 }
2085 }
2086 run_command(bin, &args, &options.cwd, None, options.timeout_ms, envs)
2087}
2088
2089fn prepare_gemini_settings(options: &EngineRunOptions) -> Option<TempSettings> {
2090 let effort = options.effort.as_deref()?;
2091 let budget = match effort {
2092 "low" => 1024,
2093 "medium" => 8192,
2094 "high" => 24576,
2095 _ => return None,
2096 };
2097 let dir = tempfile::tempdir().ok()?;
2098 let path = dir.path().join("settings.json");
2099 let mut settings = if options.capability.mode == CAPABILITY_OVERRIDE {
2100 options
2101 .capability
2102 .settings
2103 .as_ref()
2104 .and_then(|path| fs::read_to_string(path).ok())
2105 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
2106 .unwrap_or_else(|| Value::Object(Default::default()))
2107 } else {
2108 Value::Object(Default::default())
2109 };
2110 if let Value::Object(object) = &mut settings {
2111 object.insert("thinkingBudget".to_string(), Value::from(budget));
2112 }
2113 let _ = fs::write(&path, serde_json::to_vec(&settings).ok()?);
2114 Some(TempSettings { _dir: dir, path })
2115}
2116
2117fn should_use_claude_bare_mode(auth: &str) -> bool {
2118 if auth == "api-key" {
2119 return true;
2120 }
2121 if matches!(auth, "social-login" | "oauth" | "keychain") {
2122 return false;
2123 }
2124 if std::env::var("CLAUDE_CODE_OAUTH_TOKEN")
2125 .ok()
2126 .filter(|value| !value.trim().is_empty())
2127 .is_some()
2128 {
2129 return false;
2130 }
2131 std::env::var("ANTHROPIC_API_KEY")
2132 .ok()
2133 .filter(|value| !value.trim().is_empty())
2134 .is_some()
2135}
2136
2137fn run_command(
2138 command: &str,
2139 args: &[String],
2140 cwd: &Path,
2141 stdin_text: Option<&str>,
2142 timeout_ms: u64,
2143 envs: HashMap<String, String>,
2144) -> CommandResult {
2145 let started = Instant::now();
2146 let mut child = match Command::new(command)
2147 .args(args)
2148 .current_dir(cwd)
2149 .envs(envs)
2150 .stdin(Stdio::piped())
2151 .stdout(Stdio::piped())
2152 .stderr(Stdio::piped())
2153 .spawn()
2154 {
2155 Ok(child) => child,
2156 Err(error) => {
2157 return CommandResult {
2158 command: command.to_string(),
2159 args: args.to_vec(),
2160 code: None,
2161 stdout: String::new(),
2162 stderr: String::new(),
2163 timed_out: false,
2164 error: Some(error.to_string()),
2165 timeout_ms,
2166 duration_ms: started.elapsed().as_millis(),
2167 }
2168 }
2169 };
2170
2171 if let Some(mut stdin) = child.stdin.take() {
2172 if let Some(text) = stdin_text {
2173 let _ = stdin.write_all(text.as_bytes());
2174 }
2175 }
2176
2177 let stdout = child.stdout.take().map(read_pipe);
2178 let stderr = child.stderr.take().map(read_pipe);
2179 let timeout = Duration::from_millis(timeout_ms);
2180 let mut timed_out = false;
2181 let code;
2182 loop {
2183 match child.try_wait() {
2184 Ok(Some(status)) => {
2185 code = status.code();
2186 break;
2187 }
2188 Ok(None) => {
2189 if timeout_ms > 0 && started.elapsed() >= timeout {
2190 timed_out = true;
2191 let _ = child.kill();
2192 let status = child.wait().ok();
2193 code = status.and_then(|status| status.code());
2194 break;
2195 }
2196 thread::sleep(Duration::from_millis(25));
2197 }
2198 Err(error) => {
2199 return CommandResult {
2200 command: command.to_string(),
2201 args: args.to_vec(),
2202 code: None,
2203 stdout: String::new(),
2204 stderr: String::new(),
2205 timed_out,
2206 error: Some(error.to_string()),
2207 timeout_ms,
2208 duration_ms: started.elapsed().as_millis(),
2209 }
2210 }
2211 }
2212 }
2213
2214 CommandResult {
2215 command: command.to_string(),
2216 args: args.to_vec(),
2217 code,
2218 stdout: stdout
2219 .and_then(|handle| handle.join().ok())
2220 .unwrap_or_default(),
2221 stderr: stderr
2222 .and_then(|handle| handle.join().ok())
2223 .unwrap_or_default(),
2224 timed_out,
2225 error: None,
2226 timeout_ms,
2227 duration_ms: started.elapsed().as_millis(),
2228 }
2229}
2230
2231fn read_pipe<R>(mut pipe: R) -> thread::JoinHandle<String>
2232where
2233 R: Read + Send + 'static,
2234{
2235 thread::spawn(move || {
2236 let mut text = String::new();
2237 let _ = pipe.read_to_string(&mut text);
2238 text
2239 })
2240}
2241
2242fn finalize_engine(
2243 name: &str,
2244 bin: &str,
2245 duration_ms: u128,
2246 command_result: CommandResult,
2247 options: EngineRunOptions,
2248 sub_agents: Vec<EngineResult>,
2249) -> EngineResult {
2250 let output = match Engine::parse(name) {
2251 Some(Engine::Claude) => parse_claude_output(&command_result.stdout),
2252 Some(Engine::Gemini) => parse_gemini_output(&command_result.stdout),
2253 Some(Engine::Codex) => parse_codex_output(&command_result.stdout),
2254 None => command_result.stdout.trim().to_string(),
2255 };
2256 let status = if command_result.error.is_some() {
2257 "missing"
2258 } else if command_result.timed_out {
2259 "timeout"
2260 } else if command_result.code != Some(0) || output.trim().is_empty() {
2261 "error"
2262 } else {
2263 "ok"
2264 };
2265 let detail = if status == "ok" {
2266 String::new()
2267 } else if command_result.timed_out {
2268 format!("Timed out after {}s.", command_result.timeout_ms / 1000)
2269 } else {
2270 compact_failure(&command_result)
2271 };
2272 let usage = aggregate_token_usage(
2273 extract_token_usage(&command_result.stdout, &options.prompt, &output)
2274 .unwrap_or_else(|| token_usage(&options.prompt, &output)),
2275 &sub_agents,
2276 );
2277 let tool_calls = extract_tool_usage(name, &command_result.stdout, &command_result.stderr);
2278 EngineResult {
2279 name: name.to_string(),
2280 bin: Some(bin.to_string()),
2281 status: status.to_string(),
2282 duration_ms,
2283 detail,
2284 exit_code: command_result.code,
2285 stdout: command_result.stdout,
2286 stderr: command_result.stderr,
2287 output,
2288 command: format_command(&command_result.command, &command_result.args),
2289 token_usage: usage,
2290 tool_calls,
2291 sub_agents,
2292 role: options.role,
2293 iteration: options.iteration,
2294 total_iterations: options.total_iterations,
2295 team_size: options.team_size,
2296 }
2297}
2298
2299fn compact_failure(result: &CommandResult) -> String {
2300 if let Some(error) = &result.error {
2301 return error.clone();
2302 }
2303 if !result.stderr.trim().is_empty() {
2304 return result.stderr.trim().to_string();
2305 }
2306 if !result.stdout.trim().is_empty() {
2307 return result.stdout.trim().to_string();
2308 }
2309 result
2310 .code
2311 .map(|code| format!("Exited with code {code}."))
2312 .unwrap_or_else(|| "Command failed.".to_string())
2313}
2314
2315fn command_telemetry(result: &CommandResult) -> CommandTelemetry {
2316 let status = if result.error.is_some() {
2317 "missing"
2318 } else if result.timed_out {
2319 "timeout"
2320 } else if result.code == Some(0) {
2321 "ok"
2322 } else {
2323 "error"
2324 };
2325 CommandTelemetry {
2326 command: format_command(&result.command, &result.args),
2327 status: status.to_string(),
2328 detail: truncate(&sanitize_status_detail(&compact_failure(result)), 600),
2329 exit_code: result.code,
2330 duration_ms: result.duration_ms,
2331 stdout_chars: result.stdout.len(),
2332 stderr_chars: result.stderr.len(),
2333 timed_out: result.timed_out,
2334 }
2335}
2336
2337fn parse_codex_output(stdout: &str) -> String {
2338 stdout.trim().to_string()
2339}
2340
2341fn parse_claude_output(stdout: &str) -> String {
2342 let trimmed = stdout.trim();
2343 if trimmed.is_empty() {
2344 return String::new();
2345 }
2346 if let Some(value) = extract_json(trimmed) {
2347 if let Some(result) = value.get("result").and_then(Value::as_str) {
2348 return result.trim().to_string();
2349 }
2350 }
2351 let mut latest = String::new();
2352 for line in trimmed.lines() {
2353 let Ok(value) = serde_json::from_str::<Value>(line) else {
2354 continue;
2355 };
2356 if value.get("type").and_then(Value::as_str) == Some("result") {
2357 if let Some(result) = value.get("result").and_then(Value::as_str) {
2358 latest = result.trim().to_string();
2359 }
2360 }
2361 if value.get("type").and_then(Value::as_str) == Some("assistant") {
2362 if let Some(content) = value.pointer("/message/content").and_then(Value::as_array) {
2363 let text = content
2364 .iter()
2365 .filter_map(|block| {
2366 (block.get("type").and_then(Value::as_str) == Some("text"))
2367 .then(|| block.get("text").and_then(Value::as_str))
2368 .flatten()
2369 })
2370 .collect::<String>();
2371 if !text.trim().is_empty() {
2372 latest = text.trim().to_string();
2373 }
2374 }
2375 }
2376 }
2377 if latest.is_empty() {
2378 trimmed.to_string()
2379 } else {
2380 latest
2381 }
2382}
2383
2384fn parse_gemini_output(stdout: &str) -> String {
2385 let trimmed = stdout.trim();
2386 if trimmed.is_empty() {
2387 return String::new();
2388 }
2389 if let Some(value) = extract_json(trimmed) {
2390 if let Some(response) = value.get("response").and_then(Value::as_str) {
2391 return response.trim().to_string();
2392 }
2393 }
2394 trimmed.to_string()
2395}
2396
2397fn extract_json(text: &str) -> Option<Value> {
2398 serde_json::from_str::<Value>(text).ok().or_else(|| {
2399 let start = text.find('{')?;
2400 let end = text.rfind('}')?;
2401 serde_json::from_str::<Value>(&text[start..=end]).ok()
2402 })
2403}
2404
2405fn build_member_prompt(
2406 query: &str,
2407 role: &str,
2408 workflow: &Workflow,
2409 iteration: usize,
2410 previous_iteration: &[EngineResult],
2411 handoff_results: &[EngineResult],
2412 plan_output: &str,
2413) -> String {
2414 let mut sections = vec![
2415 "You are one member of a multi-model Amon Hen run.".to_string(),
2416 format!(
2417 "Amon Hen workflow: iteration {iteration} of {}.",
2418 workflow.iterations
2419 ),
2420 workflow
2421 .lead
2422 .as_ref()
2423 .map(|lead| format!("Lead model: {lead}."))
2424 .unwrap_or_else(|| "Lead model: auto.".to_string()),
2425 workflow
2426 .planner
2427 .as_ref()
2428 .map(|planner| format!("Planner model: {planner}."))
2429 .unwrap_or_else(|| "Planner model: none.".to_string()),
2430 format!("Your assigned role: {role}."),
2431 role_instruction(role).to_string(),
2432 "Answer the user query directly.".to_string(),
2433 "Do not introduce yourself.".to_string(),
2434 "Do not describe your tools, environment, or capabilities unless the user explicitly asks."
2435 .to_string(),
2436 "Be concise unless the user asks for depth.".to_string(),
2437 ];
2438 if workflow.team_work > 0 {
2439 sections.push(format!(
2440 "Team work: you may coordinate up to {} internal sub-agents or subtasks inside your own CLI if that helps.",
2441 workflow.team_work
2442 ));
2443 }
2444 if workflow.handoff {
2445 sections.push(
2446 "Handoff mode is enabled. Treat earlier Amon Hen outputs as working context."
2447 .to_string(),
2448 );
2449 }
2450 if !plan_output.trim().is_empty() {
2451 sections.push(format!("Planner handoff:\n{}", plan_output.trim()));
2452 }
2453 let context = previous_iteration
2454 .iter()
2455 .chain(handoff_results.iter())
2456 .filter(|result| result.status == "ok" && !result.output.trim().is_empty())
2457 .map(|result| format!("### {}\n{}", result.name, result.output.trim()))
2458 .collect::<Vec<_>>()
2459 .join("\n\n");
2460 if !context.is_empty() {
2461 sections.push(format!("Earlier Amon Hen handoffs:\n{context}"));
2462 }
2463 sections.push(format!("Current user query:\n{}", query.trim()));
2464 sections.join("\n\n")
2465}
2466
2467fn build_summary_prompt(
2468 query: &str,
2469 responses: &[EngineResult],
2470 workflow: &Workflow,
2471 max_member_chars: usize,
2472) -> String {
2473 let blocks = responses
2474 .iter()
2475 .map(|response| {
2476 let output = truncate(&response.output, max_member_chars);
2477 format!("### {}\n{}", response.name, output.trim())
2478 })
2479 .collect::<Vec<_>>()
2480 .join("\n\n");
2481 format!(
2482 "You are synthesizing answers from multiple AI CLI tools.\n\nAmon Hen workflow: {} iteration{}, {}.\nLead model: {}.\nPlanner model: {}.\nProduce one final answer to the original user query.\nAnswer directly. Call out meaningful disagreement or uncertainty when it exists.\n\nCurrent user query:\n{}\n\nAmon Hen member responses:\n{}",
2483 workflow.iterations,
2484 if workflow.iterations == 1 { "" } else { "s" },
2485 if workflow.handoff { "handoff enabled" } else { "parallel consultation" },
2486 workflow.lead.as_deref().unwrap_or("auto"),
2487 workflow.planner.as_deref().unwrap_or("none"),
2488 query.trim(),
2489 blocks
2490 )
2491}
2492
2493fn role_instruction(role: &str) -> &'static str {
2494 match role {
2495 "planner" => "Plan the work: identify the approach, risks, checkpoints, and useful handoffs for the executors.",
2496 "lead" => "Lead the work: make the strongest direct attempt while watching for conflicts you may need to resolve in synthesis.",
2497 "lead+planner" => "Plan and lead the work: produce a practical plan, then make the strongest direct attempt from that plan.",
2498 _ => "Execute the work: use any plan or handoff context, then produce your independent best answer.",
2499 }
2500}
2501
2502fn execution_order(members: &[String], planner: Option<&str>) -> Vec<String> {
2503 let mut ordered = Vec::new();
2504 if let Some(planner) = planner {
2505 if members.iter().any(|member| member == planner) {
2506 ordered.push(planner.to_string());
2507 }
2508 }
2509 ordered.extend(
2510 members
2511 .iter()
2512 .filter(|member| Some(member.as_str()) != planner)
2513 .cloned(),
2514 );
2515 ordered
2516}
2517
2518fn role_for(member: &str, workflow: &Workflow) -> String {
2519 let is_lead = workflow.lead.as_deref() == Some(member);
2520 let is_planner = workflow.planner.as_deref() == Some(member);
2521 match (is_lead, is_planner) {
2522 (true, true) => "lead+planner",
2523 (true, false) => "lead",
2524 (false, true) => "planner",
2525 (false, false) => "executor",
2526 }
2527 .to_string()
2528}
2529
2530fn pick_summarizer(resolved: &ResolvedArgs, successes: &[EngineResult]) -> String {
2531 if resolved.raw.summarizer != "auto" {
2532 return resolved.raw.summarizer.clone();
2533 }
2534 if let Some(lead) = &resolved.raw.lead {
2535 if successes.iter().any(|result| &result.name == lead) {
2536 return lead.clone();
2537 }
2538 }
2539 DEFAULT_SUMMARIZER_ORDER
2540 .iter()
2541 .find(|name| successes.iter().any(|result| result.name == **name))
2542 .unwrap_or(&successes[0].name.as_str())
2543 .to_string()
2544}
2545
2546fn resolve_binary(name: &str) -> String {
2547 let Some(engine) = Engine::parse(name) else {
2548 return name.to_string();
2549 };
2550 std::env::var(engine.binary_env_var())
2551 .ok()
2552 .filter(|value| !value.trim().is_empty())
2553 .unwrap_or_else(|| engine.as_str().to_string())
2554}
2555
2556fn token_usage(prompt: &str, output: &str) -> TokenUsage {
2557 let input = estimate_tokens(prompt);
2558 let output = estimate_tokens(output);
2559 TokenUsage {
2560 input,
2561 output,
2562 total: input + output,
2563 estimated: true,
2564 source: "estimate".to_string(),
2565 }
2566}
2567
2568fn aggregate_token_usage(mut usage: TokenUsage, sub_agents: &[EngineResult]) -> TokenUsage {
2569 if sub_agents.is_empty() {
2570 return usage;
2571 }
2572 for agent in sub_agents {
2573 usage.input += agent.token_usage.input;
2574 usage.output += agent.token_usage.output;
2575 usage.total += agent.token_usage.total;
2576 usage.estimated |= agent.token_usage.estimated;
2577 }
2578 usage.source = format!("{}+sub-agents", usage.source);
2579 usage
2580}
2581
2582fn extract_token_usage(stdout: &str, prompt: &str, output: &str) -> Option<TokenUsage> {
2583 let mut usage = TokenAccumulator::default();
2584 for value in json_values(stdout) {
2585 collect_token_counts(&value, &mut usage);
2586 }
2587 if usage.input.is_none() && usage.output.is_none() && usage.total.is_none() {
2588 return None;
2589 }
2590 let input = usage.input.unwrap_or_else(|| estimate_tokens(prompt));
2591 let output = usage.output.unwrap_or_else(|| estimate_tokens(output));
2592 let total = usage.total.unwrap_or(input + output);
2593 Some(TokenUsage {
2594 input,
2595 output,
2596 total,
2597 estimated: usage.input.is_none() || usage.output.is_none(),
2598 source: "provider".to_string(),
2599 })
2600}
2601
2602#[derive(Default)]
2603struct TokenAccumulator {
2604 input: Option<usize>,
2605 output: Option<usize>,
2606 total: Option<usize>,
2607}
2608
2609fn collect_token_counts(value: &Value, usage: &mut TokenAccumulator) {
2610 match value {
2611 Value::Object(map) => {
2612 for (key, value) in map {
2613 let normalized = key.replace(['_', '-'], "").to_ascii_lowercase();
2614 if let Some(count) = value.as_u64().map(|value| value as usize) {
2615 match normalized.as_str() {
2616 "inputtokens" | "inputtokencount" | "prompttokens" | "prompttokencount" => {
2617 usage.input = Some(count)
2618 }
2619 "outputtokens"
2620 | "outputtokencount"
2621 | "completiontokens"
2622 | "candidatestokencount" => usage.output = Some(count),
2623 "totaltokens" | "totaltokencount" => usage.total = Some(count),
2624 _ => {}
2625 }
2626 }
2627 collect_token_counts(value, usage);
2628 }
2629 }
2630 Value::Array(values) => {
2631 for value in values {
2632 collect_token_counts(value, usage);
2633 }
2634 }
2635 _ => {}
2636 }
2637}
2638
2639fn extract_tool_usage(provider: &str, stdout: &str, stderr: &str) -> Vec<ToolUsage> {
2640 let mut tools = Vec::new();
2641 for value in json_values(stdout) {
2642 collect_tool_usage(&value, &mut tools);
2643 }
2644 for line in stdout.lines().chain(stderr.lines()) {
2645 if let Some(tool) = line_tool_usage(provider, line) {
2646 tools.push(tool);
2647 }
2648 }
2649 dedupe_tools(tools)
2650}
2651
2652fn collect_tool_usage(value: &Value, tools: &mut Vec<ToolUsage>) {
2653 match value {
2654 Value::Object(map) => {
2655 let type_name = map.get("type").and_then(Value::as_str).unwrap_or_default();
2656 let name = map
2657 .get("name")
2658 .or_else(|| map.get("tool"))
2659 .or_else(|| map.get("tool_name"))
2660 .or_else(|| map.get("toolName"))
2661 .and_then(Value::as_str);
2662 if let Some(name) = name.filter(|_| {
2663 type_name.contains("tool")
2664 || map.contains_key("input")
2665 || map.contains_key("arguments")
2666 || map.contains_key("toolCall")
2667 }) {
2668 tools.push(ToolUsage {
2669 name: name.to_string(),
2670 kind: if type_name.is_empty() {
2671 "tool"
2672 } else {
2673 type_name
2674 }
2675 .to_string(),
2676 status: "observed".to_string(),
2677 detail: short_json_detail(value),
2678 });
2679 }
2680 for value in map.values() {
2681 collect_tool_usage(value, tools);
2682 }
2683 }
2684 Value::Array(values) => {
2685 for value in values {
2686 collect_tool_usage(value, tools);
2687 }
2688 }
2689 _ => {}
2690 }
2691}
2692
2693fn line_tool_usage(provider: &str, line: &str) -> Option<ToolUsage> {
2694 let trimmed = line.trim();
2695 let lowered = trimmed.to_ascii_lowercase();
2696 let marker = ["running shell:", "tool:", "tool_use", "mcp:", "command:"]
2697 .iter()
2698 .find(|marker| lowered.contains(**marker))?;
2699 Some(ToolUsage {
2700 name: marker.trim_end_matches(':').to_string(),
2701 kind: provider.to_string(),
2702 status: "observed".to_string(),
2703 detail: truncate(trimmed, 240),
2704 })
2705}
2706
2707fn short_json_detail(value: &Value) -> String {
2708 serde_json::to_string(value)
2709 .map(|text| truncate(&text, 240))
2710 .unwrap_or_default()
2711}
2712
2713fn dedupe_tools(tools: Vec<ToolUsage>) -> Vec<ToolUsage> {
2714 let mut seen = HashSet::new();
2715 tools
2716 .into_iter()
2717 .filter(|tool| seen.insert(format!("{}:{}:{}", tool.kind, tool.name, tool.detail)))
2718 .collect()
2719}
2720
2721fn json_values(text: &str) -> Vec<Value> {
2722 let trimmed = text.trim();
2723 if trimmed.is_empty() {
2724 return vec![];
2725 }
2726 let mut values = Vec::new();
2727 if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
2728 values.push(value);
2729 return values;
2730 }
2731 for line in trimmed.lines() {
2732 if let Ok(value) = serde_json::from_str::<Value>(line.trim()) {
2733 values.push(value);
2734 }
2735 }
2736 values
2737}
2738
2739fn estimate_tokens(text: &str) -> usize {
2740 if text.trim().is_empty() {
2741 0
2742 } else {
2743 text.len().div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN)
2744 }
2745}
2746
2747fn format_command(command: &str, args: &[String]) -> String {
2748 std::iter::once(command.to_string())
2749 .chain(args.iter().map(|arg| shell_quote(arg)))
2750 .collect::<Vec<_>>()
2751 .join(" ")
2752}
2753
2754fn shell_quote(value: &str) -> String {
2755 if value
2756 .chars()
2757 .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
2758 {
2759 value.to_string()
2760 } else {
2761 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
2762 }
2763}
2764
2765fn truncate(text: &str, max_chars: usize) -> String {
2766 if text.chars().count() <= max_chars {
2767 text.to_string()
2768 } else {
2769 let mut truncated = text.chars().take(max_chars).collect::<String>();
2770 truncated.push_str("\n...[truncated]");
2771 truncated
2772 }
2773}
2774
2775fn should_show_banner(raw: &CliArgs) -> bool {
2776 !raw.no_banner && !raw.headless && !raw.json && !raw.json_stream && !raw.plain
2777}
2778
2779fn linear_delivery_requested(raw: &CliArgs) -> bool {
2780 raw.deliver_linear
2781 || raw.linear_until_complete
2782 || raw.linear_watch
2783 || !raw.linear_issue.is_empty()
2784 || raw
2785 .linear_query
2786 .as_ref()
2787 .is_some_and(|value| !value.trim().is_empty())
2788 || !raw.linear_project.is_empty()
2789 || !raw.linear_epic.is_empty()
2790}
2791
2792fn render_banner() -> &'static str {
2793 " _ _ _ \n / \\ _ __ ___ ___ _ __ | | | | ___ _ __ \n / _ \\ | '_ ` _ \\ / _ \\| '_ \\| |_| |/ _ \\ '_ \\ \n / ___ \\| | | | | | (_) | | | | _ | __/ | | |\n/_/ \\_\\_| |_| |_|\\___/|_| |_|_| |_|\\___|_| |_|"
2794}
2795
2796fn render_human_result(result: &AmonHenResult, verbose: bool) -> String {
2797 let mut lines = Vec::new();
2798 if verbose {
2799 lines.push(format!(
2800 "Amon Hen consulted: {}",
2801 result.members_requested.join(", ")
2802 ));
2803 for command in &result.prompt_commands {
2804 lines.push(format!(
2805 "cmd [{}] {} ({:.1}s)",
2806 command.status,
2807 command.command,
2808 command.duration_ms as f64 / 1000.0
2809 ));
2810 }
2811 for (index, member) in result.members.iter().enumerate() {
2812 lines.push(format!(
2813 "{}. [{}] {} ({:.1}s, tokens:{}, tools:{}, sub-agents:{}){}",
2814 index + 1,
2815 member.status,
2816 member.name,
2817 member.duration_ms as f64 / 1000.0,
2818 member.token_usage.total,
2819 member.tool_calls.len(),
2820 member.sub_agents.len(),
2821 if member.detail.is_empty() { "" } else { ": " }
2822 ));
2823 if !member.detail.is_empty() {
2824 lines.push(format!(" {}", member.detail));
2825 }
2826 for sub_agent in &member.sub_agents {
2827 lines.push(format!(
2828 " - {} [{}] tokens:{} tools:{}",
2829 sub_agent.role,
2830 sub_agent.status,
2831 sub_agent.token_usage.total,
2832 sub_agent.tool_calls.len()
2833 ));
2834 }
2835 if member.status == "ok" {
2836 lines.push(indent(&member.output, " "));
2837 }
2838 }
2839 lines.push("----------- synthesis -----------".to_string());
2840 }
2841 if result.summary.status == "ok" {
2842 lines.push(result.summary.output.trim().to_string());
2843 } else {
2844 lines.push(format!("Synthesis failed: {}", result.summary.detail));
2845 }
2846 lines.join("\n")
2847}
2848
2849fn indent(text: &str, prefix: &str) -> String {
2850 text.lines()
2851 .map(|line| format!("{prefix}{line}"))
2852 .collect::<Vec<_>>()
2853 .join("\n")
2854}
2855
2856fn is_success(result: &AmonHenResult) -> bool {
2857 result.members.iter().any(|member| member.status == "ok") && result.summary.status == "ok"
2858}
2859
2860#[cfg(test)]
2861mod tests {
2862 use super::*;
2863
2864 #[test]
2865 fn help_flags_use_success_exit_code() {
2866 let long_help = CliArgs::try_parse_from(["amon-hen", "--help"]).unwrap_err();
2867 let short_help = CliArgs::try_parse_from(["amon-hen", "-h"]).unwrap_err();
2868
2869 assert_eq!(long_help.kind(), ErrorKind::DisplayHelp);
2870 assert_eq!(short_help.kind(), ErrorKind::DisplayHelp);
2871 assert_eq!(parse_error_exit_code(long_help.kind()), 0);
2872 assert_eq!(parse_error_exit_code(short_help.kind()), 0);
2873 }
2874
2875 #[test]
2876 fn parses_members_and_provider_flags() {
2877 let args = CliArgs::try_parse_from([
2878 "amon-hen",
2879 "--members",
2880 "codex,claude",
2881 "--planner",
2882 "codex",
2883 "--lead",
2884 "claude",
2885 "--codex-effort",
2886 "xhigh",
2887 "--claude-capabilities",
2888 "override",
2889 "--claude-allowed-tools",
2890 "Read,Bash(git:*)",
2891 "--claude-tools",
2892 "Read,Edit",
2893 "--claude-agent",
2894 "reviewer",
2895 "--gemini-allowed-mcp-servers",
2896 "linear,github",
2897 "ship it",
2898 ])
2899 .unwrap();
2900 let resolved = resolve_args(args).unwrap();
2901 assert_eq!(resolved.members, vec!["codex", "claude"]);
2902 assert_eq!(resolved.raw.codex_effort.as_deref(), Some("xhigh"));
2903 assert_eq!(
2904 resolved.raw.claude_allowed_tools,
2905 vec!["Read", "Bash(git:*)"]
2906 );
2907 assert_eq!(resolved.raw.claude_tools, vec!["Read", "Edit"]);
2908 assert_eq!(resolved.raw.claude_agent.as_deref(), Some("reviewer"));
2909 assert_eq!(
2910 resolved.raw.gemini_allowed_mcp_servers,
2911 vec!["linear", "github"]
2912 );
2913 }
2914
2915 #[test]
2916 fn parses_historical_linear_flags_in_rust() {
2917 let args = CliArgs::try_parse_from([
2918 "amon-hen",
2919 "--banner",
2920 "--auth-open-browser",
2921 "--members",
2922 "codex,claude,gemini",
2923 "--linear-project",
2924 "ENG",
2925 "--linear-endpoint",
2926 "https://linear.example/graphql",
2927 "--linear-auth",
2928 "oauth",
2929 "--linear-max-polls",
2930 "2",
2931 "--linear-max-concurrency",
2932 "3",
2933 "--linear-workflow-file",
2934 "docs/linear-workflow.md",
2935 "--linear-workspace-strategy",
2936 "copy",
2937 "--linear-completion-gate",
2938 "review-or-ci",
2939 "--delivery-phases",
2940 "plan,implement,verify,ship",
2941 "deliver it",
2942 ])
2943 .unwrap();
2944 let resolved = resolve_args(args).unwrap();
2945
2946 assert!(linear_delivery_requested(&resolved.raw));
2947 assert_eq!(
2948 resolved.raw.linear_endpoint.as_deref(),
2949 Some("https://linear.example/graphql")
2950 );
2951 assert_eq!(resolved.raw.linear_auth, "oauth");
2952 assert_eq!(resolved.raw.linear_max_polls, Some(2));
2953 assert_eq!(resolved.raw.linear_max_concurrency, 3);
2954 assert_eq!(
2955 resolved.raw.linear_workflow_file.as_deref(),
2956 Some(Path::new("docs/linear-workflow.md"))
2957 );
2958 assert_eq!(resolved.raw.linear_workspace_strategy, "copy");
2959 assert_eq!(resolved.raw.linear_completion_gate, "review-or-ci");
2960 assert_eq!(
2961 resolved.raw.delivery_phases,
2962 vec!["plan", "implement", "verify", "ship"]
2963 );
2964 }
2965
2966 #[test]
2967 fn builds_member_prompt_with_roles_and_handoff() {
2968 let workflow = Workflow {
2969 handoff: true,
2970 lead: Some("claude".to_string()),
2971 planner: Some("codex".to_string()),
2972 iterations: 2,
2973 team_work: 1,
2974 teams: HashMap::new(),
2975 };
2976 let prompt = build_member_prompt("Fix the bug", "planner", &workflow, 1, &[], &[], "");
2977 assert!(prompt.contains("Amon Hen workflow: iteration 1 of 2."));
2978 assert!(prompt.contains("Lead model: claude."));
2979 assert!(prompt.contains("Planner model: codex."));
2980 assert!(prompt.contains("Your assigned role: planner."));
2981 assert!(prompt.contains("Current user query:"));
2982 }
2983
2984 #[test]
2985 fn parses_claude_stream_json_result() {
2986 let text = r#"{"type":"system","subtype":"status","status":"requesting"}
2987{"type":"result","result":"done"}"#;
2988 assert_eq!(parse_claude_output(text), "done");
2989 }
2990
2991 #[test]
2992 fn parses_gemini_json_response() {
2993 assert_eq!(parse_gemini_output(r#"{"response":"hello"}"#), "hello");
2994 }
2995
2996 #[test]
2997 fn estimates_tokens_with_ceiling_chunks() {
2998 assert_eq!(estimate_tokens(""), 0);
2999 assert_eq!(estimate_tokens("abc"), 1);
3000 assert_eq!(estimate_tokens("abcd"), 1);
3001 assert_eq!(estimate_tokens("abcde"), 2);
3002 }
3003
3004 #[test]
3005 fn extracts_social_login_urls() {
3006 assert_eq!(
3007 extract_auth_urls("Open https://example.com/callback?code=123, then continue"),
3008 vec!["https://example.com/callback?code=123"]
3009 );
3010 assert_eq!(
3011 extract_auth_urls("deeplink: claude://login/complete."),
3012 vec!["claude://login/complete"]
3013 );
3014 }
3015
3016 #[test]
3017 fn parses_provider_token_usage() {
3018 let stdout = r#"{"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}"#;
3019 let usage = extract_token_usage(stdout, "hello", "world").unwrap();
3020 assert_eq!(usage.input, 12);
3021 assert_eq!(usage.output, 8);
3022 assert_eq!(usage.total, 20);
3023 assert!(!usage.estimated);
3024 }
3025
3026 #[test]
3027 fn extracts_tool_usage_from_provider_streams() {
3028 let stdout = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"git status"}}]}}"#;
3029 let tools = extract_tool_usage("claude", stdout, "");
3030 assert!(tools.iter().any(|tool| tool.name == "Bash"));
3031 }
3032
3033 #[test]
3034 fn builds_real_sub_agent_handoff_prompt() {
3035 let agent = EngineResult {
3036 name: "codex".to_string(),
3037 bin: Some("codex".to_string()),
3038 status: "ok".to_string(),
3039 duration_ms: 1,
3040 detail: String::new(),
3041 exit_code: Some(0),
3042 stdout: String::new(),
3043 stderr: String::new(),
3044 output: "inspect parser".to_string(),
3045 command: "codex exec".to_string(),
3046 token_usage: token_usage("a", "b"),
3047 tool_calls: vec![],
3048 sub_agents: vec![],
3049 role: "executor:sub-agent-1".to_string(),
3050 iteration: 1,
3051 total_iterations: 1,
3052 team_size: 0,
3053 };
3054 let prompt = build_team_lead_prompt("ship it", &[agent]);
3055 assert!(prompt.contains("Sub-agent handoffs"));
3056 assert!(prompt.contains("inspect parser"));
3057 assert!(prompt.contains("Original provider prompt"));
3058 }
3059
3060 #[test]
3061 fn system_time_is_available_for_test_environment() {
3062 assert!(std::time::SystemTime::now()
3063 .duration_since(std::time::UNIX_EPOCH)
3064 .is_ok());
3065 }
3066}