Skip to main content

llman/x/codex/
agents.rs

1use crate::config::resolve_config_dir;
2use crate::fs_utils::atomic_write_with_mode;
3use crate::managed_block::{
4    LLMAN_PROMPTS_MARKER_END, LLMAN_PROMPTS_MARKER_START, has_llman_prompt_markers,
5    update_text_with_markers,
6};
7use crate::path_utils::validate_path_segment;
8use crate::prompts::store as prompt_store;
9use crate::skills::cli::interactive::is_interactive;
10use anyhow::{Context, Result, bail};
11use clap::{Args, Subcommand, ValueEnum};
12use inquire::{Confirm, MultiSelect, Select};
13use std::collections::HashSet;
14use std::env;
15use std::fs;
16use std::path::{Path, PathBuf};
17use time::OffsetDateTime;
18use toml::Value;
19
20#[derive(Args, Debug, Clone)]
21#[command(about = "Manage Codex custom agent configurations")]
22#[command(subcommand_required = false)]
23pub struct CodexAgentsArgs {
24    #[command(subcommand)]
25    pub command: Option<CodexAgentsCommand>,
26
27    /// Managed agents directory (default: $LLMAN_CONFIG_DIR/codex/agents)
28    #[arg(long = "managed-dir", global = true)]
29    pub managed_dir: Option<PathBuf>,
30
31    /// Override Codex home directory (uses `codex-home`/agents unless --agents-dir is set)
32    #[arg(long = "codex-home", global = true)]
33    pub codex_home: Option<PathBuf>,
34
35    /// Override Codex agents directory directly
36    #[arg(long = "agents-dir", global = true)]
37    pub agents_dir: Option<PathBuf>,
38
39    /// Only apply to selected agent names (repeatable; matches `name`.toml)
40    #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append, global = true)]
41    pub only: Vec<String>,
42
43    /// Show plan but do not write any files
44    #[arg(long, global = true)]
45    pub dry_run: bool,
46
47    /// Assume "yes" for confirmations (required for non-interactive write operations)
48    #[arg(long, global = true)]
49    pub yes: bool,
50
51    /// Force write operations without interactive confirmation (alias of --yes)
52    #[arg(long, global = true)]
53    pub force: bool,
54}
55
56#[derive(Subcommand, Debug, Clone)]
57pub enum CodexAgentsCommand {
58    /// Show managed/target status (read-only)
59    Status,
60    /// Import target agents into managed directory
61    Import,
62    /// Sync managed agents to Codex agents directory
63    Sync {
64        #[arg(long, value_enum, default_value_t = SyncMode::Link)]
65        mode: SyncMode,
66
67        /// Upsert `[agents.<name>] config_file = "agents/<file>.toml"` into Codex config.toml during sync
68        #[arg(long = "upsert-config")]
69        upsert_config: bool,
70
71        /// Agent name(s) to upsert into Codex config.toml (repeatable)
72        ///
73        /// Notes:
74        /// - `default` maps to `agents/defaults.toml`
75        /// - other names map to `agents/<name>.toml`
76        #[arg(long = "upsert-agent", value_delimiter = ',', action = clap::ArgAction::Append)]
77        upsert_agent: Vec<String>,
78    },
79    /// Inject prompt templates into developer_instructions in managed agent TOMLs
80    Inject {
81        /// Prompt template name(s) under $LLMAN_CONFIG_DIR/prompt/codex/*.md (repeatable)
82        #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append)]
83        template: Vec<String>,
84    },
85}
86
87#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SyncMode {
89    #[value(name = "link")]
90    Link,
91    #[value(name = "copy")]
92    Copy,
93}
94
95pub fn run(args: &CodexAgentsArgs) -> Result<()> {
96    let interactive = is_interactive();
97    match &args.command {
98        None => {
99            if interactive {
100                return run_wizard(args);
101            }
102            bail!("In non-interactive mode, a subcommand is required (status/import/sync/inject).");
103        }
104        Some(CodexAgentsCommand::Status) => run_status(args),
105        Some(CodexAgentsCommand::Import) => run_import(args, interactive),
106        Some(CodexAgentsCommand::Sync {
107            mode,
108            upsert_config,
109            upsert_agent,
110        }) => run_sync(args, *mode, interactive, *upsert_config, upsert_agent),
111        Some(CodexAgentsCommand::Inject { template }) => run_inject(args, template, interactive),
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116struct AgentConfigMapping {
117    agent_name: String,
118    file_stem: String,
119}
120
121impl std::fmt::Display for AgentConfigMapping {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        write!(f, "{} -> agents/{}.toml", self.agent_name, self.file_stem)
124    }
125}
126
127#[derive(Debug, Clone)]
128enum CodexConfigDoc {
129    Missing,
130    Invalid,
131    Loaded(Value),
132}
133
134fn run_status(args: &CodexAgentsArgs) -> Result<()> {
135    let managed_dir = resolve_managed_dir(args)?;
136    let target_dir = resolve_target_agents_dir(args)?;
137    let config_path = resolve_target_codex_config_path(args).ok();
138    let config_doc = match config_path.as_ref() {
139        Some(path) => match load_toml_value_if_exists(path) {
140            Ok(Some(doc)) => CodexConfigDoc::Loaded(doc),
141            Ok(None) => CodexConfigDoc::Missing,
142            Err(_) => CodexConfigDoc::Invalid,
143        },
144        None => CodexConfigDoc::Missing,
145    };
146
147    println!("Managed agents dir: {}", managed_dir.display());
148    println!("Target agents dir:  {}", target_dir.display());
149    if let Some(path) = config_path.as_ref() {
150        println!("Codex config file: {}", path.display());
151    }
152
153    let managed = list_toml_stems(&managed_dir)?;
154    if managed.is_empty() {
155        println!("No managed agent TOMLs found.");
156        return Ok(());
157    }
158
159    let selected = select_stems(&managed, &args.only, "agent name")?;
160    println!();
161
162    for stem in selected {
163        let managed_file = managed_dir.join(format!("{stem}.toml"));
164        let target_file = target_dir.join(format!("{stem}.toml"));
165
166        let schema_state = describe_agent_schema_state(&managed_file)?;
167        let target_state = describe_target_state(&managed_file, &target_file)?;
168        let inject_state = describe_inject_state(&managed_file)?;
169        let config_state = describe_config_state(&config_doc, &stem);
170
171        println!(
172            "- {stem}: schema={schema_state}; target={target_state}; inject={inject_state}; config={config_state}",
173            stem = stem,
174            schema_state = schema_state,
175            target_state = target_state,
176            inject_state = inject_state
177        );
178    }
179
180    Ok(())
181}
182
183fn run_import(args: &CodexAgentsArgs, interactive: bool) -> Result<()> {
184    let managed_dir = resolve_managed_dir(args)?;
185    let target_dir = resolve_target_agents_dir(args)?;
186    let available = list_toml_stems(&target_dir)?;
187    let selected = select_stems(&available, &args.only, "agent name")?;
188
189    let plan = plan_import(&managed_dir, &target_dir, &selected)?;
190    apply_plan(plan, args, interactive)
191}
192
193fn run_sync(
194    args: &CodexAgentsArgs,
195    mode: SyncMode,
196    interactive: bool,
197    upsert_config: bool,
198    upsert_agents: &[String],
199) -> Result<()> {
200    let managed_dir = resolve_managed_dir(args)?;
201    let target_dir = resolve_target_agents_dir(args)?;
202    let available = list_toml_stems(&managed_dir)?;
203    let selected = select_stems(&available, &args.only, "agent name")?;
204
205    let mut plan = plan_sync(&managed_dir, &target_dir, &selected, mode)?;
206
207    let should_upsert_config = upsert_config || !upsert_agents.is_empty();
208    if should_upsert_config {
209        let config_path = resolve_target_codex_config_path(args)?;
210        extend_plan_with_agent_config_upserts(
211            &mut plan,
212            &config_path,
213            &managed_dir,
214            &selected,
215            upsert_agents,
216        )?;
217    }
218    apply_plan(plan, args, interactive)
219}
220
221fn run_inject(args: &CodexAgentsArgs, templates: &[String], interactive: bool) -> Result<()> {
222    if templates.is_empty() {
223        bail!("--template is required");
224    }
225
226    let managed_dir = resolve_managed_dir(args)?;
227    let available = list_toml_stems(&managed_dir)?;
228    let selected = select_stems(&available, &args.only, "agent name")?;
229
230    let body = build_injection_body(templates)?;
231    let plan = plan_inject(&managed_dir, &selected, &body)?;
232    apply_plan(plan, args, interactive)
233}
234
235fn run_wizard(args: &CodexAgentsArgs) -> Result<()> {
236    let managed_dir = resolve_managed_dir(args)?;
237    let target_dir = resolve_target_agents_dir(args)?;
238    let confirm_all = args.yes || args.force;
239
240    println!("Managed agents dir: {}", managed_dir.display());
241    println!("Target agents dir:  {}", target_dir.display());
242    println!();
243
244    let action = Select::new(
245        "Select an action:",
246        vec!["status", "import", "inject", "sync"],
247    )
248    .prompt()
249    .context("select action")?;
250
251    match action {
252        "status" => run_status(args),
253        "import" => {
254            let available = list_toml_stems(&target_dir)?;
255            if available.is_empty() {
256                bail!(
257                    "No agent TOMLs found under target dir: {}",
258                    target_dir.display()
259                );
260            }
261            let selected = MultiSelect::new("Select agent files to import:", available)
262                .with_all_selected_by_default()
263                .prompt()
264                .context("select agents to import")?;
265            let plan = plan_import(&managed_dir, &target_dir, &selected)?;
266            apply_plan_with_override(plan, args, true, confirm_all)
267        }
268        "inject" => {
269            let available = list_toml_stems(&managed_dir)?;
270            if available.is_empty() {
271                bail!(
272                    "No managed agent TOMLs found under: {}",
273                    managed_dir.display()
274                );
275            }
276            let selected = MultiSelect::new("Select managed agents to inject:", available)
277                .with_all_selected_by_default()
278                .prompt()
279                .context("select agents to inject")?;
280
281            let templates = list_codex_prompt_templates()?;
282            if templates.is_empty() {
283                bail!("No codex prompt templates found under $LLMAN_CONFIG_DIR/prompt/codex");
284            }
285            let picked = MultiSelect::new("Select codex prompt templates to inject:", templates)
286                .prompt()
287                .context("select templates")?;
288            if picked.is_empty() {
289                bail!("At least one template is required for inject");
290            }
291
292            let body = build_injection_body(&picked)?;
293            let plan = plan_inject(&managed_dir, &selected, &body)?;
294            apply_plan_with_override(plan, args, true, confirm_all)
295        }
296        "sync" => {
297            let available = list_toml_stems(&managed_dir)?;
298            if available.is_empty() {
299                bail!(
300                    "No managed agent TOMLs found under: {}",
301                    managed_dir.display()
302                );
303            }
304            let selected = MultiSelect::new("Select managed agents to sync:", available)
305                .with_all_selected_by_default()
306                .prompt()
307                .context("select agents to sync")?;
308
309            let mode = Select::new("Select sync mode:", vec!["link", "copy"])
310                .prompt()
311                .context("select sync mode")?;
312            let mode = match mode {
313                "link" => SyncMode::Link,
314                "copy" => SyncMode::Copy,
315                _ => unreachable!("validated selection"),
316            };
317
318            let mut plan = plan_sync(&managed_dir, &target_dir, &selected, mode)?;
319
320            let config_path_display = resolve_target_codex_config_path(args)
321                .map(|p| p.display().to_string())
322                .unwrap_or_else(|_| "codex config.toml".to_string());
323            let upsert_prompt =
324                format!("Upsert agent config_file entries into {config_path_display}?");
325
326            let upsert = Confirm::new(&upsert_prompt)
327                .with_default(false)
328                .prompt()
329                .context("confirm upsert config")?;
330
331            if upsert {
332                let candidates = build_upsert_candidates(&selected);
333                if candidates.is_empty() {
334                    plan.ops.push(PlanOp::Note {
335                        message: "skip config upsert (no candidates)".to_string(),
336                    });
337                } else {
338                    let defaults = default_upsert_candidate_indices(&candidates);
339                    let picked_prompt = "Select agent mappings to upsert:".to_string();
340                    let picked = MultiSelect::new(&picked_prompt, candidates)
341                        .with_default(defaults.as_slice())
342                        .prompt()
343                        .context("select upsert agents")?;
344                    if !picked.is_empty() {
345                        let config_path = resolve_target_codex_config_path(args)?;
346                        extend_plan_with_agent_config_mappings(
347                            &mut plan,
348                            &config_path,
349                            &managed_dir,
350                            &picked,
351                        )?;
352                    }
353                }
354            }
355
356            apply_plan_with_override(plan, args, true, confirm_all)
357        }
358        _ => unreachable!("validated selection"),
359    }
360}
361
362fn build_upsert_candidates(stems: &[String]) -> Vec<AgentConfigMapping> {
363    let mut out = Vec::new();
364    let mut seen = HashSet::new();
365    for stem in stems {
366        let agent_name = agent_name_for_stem(stem);
367        if seen.contains(agent_name.as_str()) {
368            continue;
369        }
370        seen.insert(agent_name.clone());
371        out.push(AgentConfigMapping {
372            agent_name,
373            file_stem: stem.clone(),
374        });
375    }
376    out
377}
378
379fn default_upsert_candidate_indices(candidates: &[AgentConfigMapping]) -> Vec<usize> {
380    let official = ["default", "worker", "explorer"];
381    candidates
382        .iter()
383        .enumerate()
384        .filter_map(|(idx, m)| {
385            if official.contains(&m.agent_name.as_str()) {
386                Some(idx)
387            } else {
388                None
389            }
390        })
391        .collect()
392}
393
394fn extend_plan_with_agent_config_upserts(
395    plan: &mut Plan,
396    config_path: &Path,
397    managed_dir: &Path,
398    selected_stems: &[String],
399    upsert_agents: &[String],
400) -> Result<()> {
401    let mut mappings = Vec::new();
402    let selected_set: HashSet<&str> = selected_stems.iter().map(|s| s.as_str()).collect();
403
404    if !upsert_agents.is_empty() {
405        for raw in upsert_agents {
406            let agent_name = validate_path_segment(raw, "agent name")?;
407            let file_stem = file_stem_for_agent_name(&agent_name);
408            if !selected_set.contains(file_stem.as_str()) {
409                plan.ops.push(PlanOp::Note {
410                    message: format!(
411                        "skip config upsert for {} (agent file not selected for sync)",
412                        agent_name
413                    ),
414                });
415                continue;
416            }
417            mappings.push(AgentConfigMapping {
418                agent_name,
419                file_stem,
420            });
421        }
422    } else {
423        for agent_name in ["default", "worker", "explorer"] {
424            let file_stem = file_stem_for_agent_name(agent_name);
425            if !selected_set.contains(file_stem.as_str()) {
426                continue;
427            }
428            mappings.push(AgentConfigMapping {
429                agent_name: agent_name.to_string(),
430                file_stem,
431            });
432        }
433        if mappings.is_empty() {
434            plan.ops.push(PlanOp::Note {
435                message: "skip config upsert (no official agents selected for sync)".to_string(),
436            });
437            return Ok(());
438        }
439    }
440
441    let mappings = dedup_agent_mappings(mappings);
442    extend_plan_with_agent_config_mappings(plan, config_path, managed_dir, &mappings)
443}
444
445fn extend_plan_with_agent_config_mappings(
446    plan: &mut Plan,
447    config_path: &Path,
448    managed_dir: &Path,
449    mappings: &[AgentConfigMapping],
450) -> Result<()> {
451    let mut wanted = Vec::new();
452    for mapping in mappings {
453        let managed_file = managed_dir.join(format!("{}.toml", mapping.file_stem));
454        if !managed_file.exists() {
455            plan.ops.push(PlanOp::Note {
456                message: format!(
457                    "skip config upsert for {} (missing managed file: {})",
458                    mapping.agent_name,
459                    managed_file.display()
460                ),
461            });
462            continue;
463        }
464        wanted.push(mapping.clone());
465    }
466
467    if wanted.is_empty() {
468        return Ok(());
469    }
470
471    if let Some(op) = plan_upsert_agent_config_file_mappings(config_path, &wanted)? {
472        plan.ops.push(op);
473    }
474
475    Ok(())
476}
477
478fn dedup_agent_mappings(mappings: Vec<AgentConfigMapping>) -> Vec<AgentConfigMapping> {
479    let mut out = Vec::new();
480    let mut seen = HashSet::new();
481    for mapping in mappings {
482        if seen.insert(mapping.agent_name.clone()) {
483            out.push(mapping);
484        }
485    }
486    out
487}
488
489fn plan_upsert_agent_config_file_mappings(
490    config_path: &Path,
491    mappings: &[AgentConfigMapping],
492) -> Result<Option<PlanOp>> {
493    let mut doc: Value = if config_path.exists() {
494        let content = fs::read_to_string(config_path)
495            .with_context(|| format!("read codex config: {}", config_path.display()))?;
496        toml::from_str(&content)
497            .with_context(|| format!("parse codex config: {}", config_path.display()))?
498    } else {
499        Value::Table(toml::map::Map::new())
500    };
501
502    let before = doc.clone();
503    let root = doc.as_table_mut().ok_or_else(|| {
504        anyhow::anyhow!(
505            "codex config is not a TOML table: {}",
506            config_path.display()
507        )
508    })?;
509
510    let agents = root
511        .entry("agents")
512        .or_insert_with(|| Value::Table(toml::map::Map::new()));
513    let Some(agents_table) = agents.as_table_mut() else {
514        bail!(
515            "codex config `agents` is not a TOML table: {}",
516            config_path.display()
517        );
518    };
519
520    for mapping in mappings {
521        let entry = agents_table
522            .entry(mapping.agent_name.clone())
523            .or_insert_with(|| Value::Table(toml::map::Map::new()));
524        let Some(agent_table) = entry.as_table_mut() else {
525            bail!(
526                "codex config `agents.{}` is not a TOML table: {}",
527                mapping.agent_name,
528                config_path.display()
529            );
530        };
531        agent_table.insert(
532            "config_file".into(),
533            Value::String(format!("agents/{}.toml", mapping.file_stem)),
534        );
535    }
536
537    if doc == before {
538        return Ok(None);
539    }
540
541    let output = toml::to_string_pretty(&doc)
542        .with_context(|| format!("serialize codex config: {}", config_path.display()))?;
543
544    Ok(Some(PlanOp::WriteFile {
545        path: config_path.to_path_buf(),
546        content: output,
547    }))
548}
549
550fn load_toml_value_if_exists(path: &Path) -> Result<Option<Value>> {
551    if !path.exists() {
552        return Ok(None);
553    }
554    let content = fs::read_to_string(path).with_context(|| format!("read: {}", path.display()))?;
555    let doc: Value =
556        toml::from_str(&content).with_context(|| format!("parse: {}", path.display()))?;
557    Ok(Some(doc))
558}
559
560fn describe_config_state(config_doc: &CodexConfigDoc, stem: &str) -> String {
561    let doc = match config_doc {
562        CodexConfigDoc::Missing => return "n/a".to_string(),
563        CodexConfigDoc::Invalid => return "invalid-toml".to_string(),
564        CodexConfigDoc::Loaded(doc) => doc,
565    };
566
567    let Some(root) = doc.as_table() else {
568        return "invalid".to_string();
569    };
570
571    let agent_name = agent_name_for_stem(stem);
572    let file_stem = stem;
573    let expected = format!("agents/{file_stem}.toml");
574
575    let current = root
576        .get("agents")
577        .and_then(|v| v.as_table())
578        .and_then(|agents| agents.get(&agent_name))
579        .and_then(|v| v.as_table())
580        .and_then(|t| t.get("config_file"))
581        .and_then(|v| v.as_str());
582
583    match current {
584        Some(v) if v == expected => "ok".to_string(),
585        Some(v) => format!("mismatch({v})"),
586        None => "missing".to_string(),
587    }
588}
589
590fn agent_name_for_stem(stem: &str) -> String {
591    if stem == "defaults" {
592        "default".to_string()
593    } else {
594        stem.to_string()
595    }
596}
597
598fn file_stem_for_agent_name(agent_name: &str) -> String {
599    if agent_name == "default" {
600        "defaults".to_string()
601    } else {
602        agent_name.to_string()
603    }
604}
605
606#[derive(Debug, Clone)]
607enum PlanOp {
608    EnsureDir { path: PathBuf },
609    Backup { from: PathBuf, to: PathBuf },
610    Copy { from: PathBuf, to: PathBuf },
611    Link { from: PathBuf, to: PathBuf },
612    WriteFile { path: PathBuf, content: String },
613    Note { message: String },
614}
615
616#[derive(Debug, Clone, Default)]
617struct Plan {
618    ops: Vec<PlanOp>,
619}
620
621impl Plan {
622    fn has_writes(&self) -> bool {
623        self.ops.iter().any(|op| {
624            matches!(
625                op,
626                PlanOp::EnsureDir { .. }
627                    | PlanOp::Backup { .. }
628                    | PlanOp::Copy { .. }
629                    | PlanOp::Link { .. }
630                    | PlanOp::WriteFile { .. }
631            )
632        })
633    }
634
635    fn print(&self) {
636        for op in &self.ops {
637            match op {
638                PlanOp::EnsureDir { path } => println!("PLAN mkdir -p {}", path.display()),
639                PlanOp::Backup { from, to } => {
640                    println!("PLAN backup {} -> {}", from.display(), to.display())
641                }
642                PlanOp::Copy { from, to } => {
643                    println!("PLAN copy {} -> {}", from.display(), to.display())
644                }
645                PlanOp::Link { from, to } => {
646                    println!("PLAN link {} -> {}", to.display(), from.display())
647                }
648                PlanOp::WriteFile { path, .. } => println!("PLAN write {}", path.display()),
649                PlanOp::Note { message } => println!("PLAN note: {}", message),
650            }
651        }
652    }
653}
654
655fn apply_plan(plan: Plan, args: &CodexAgentsArgs, interactive: bool) -> Result<()> {
656    apply_plan_with_override(plan, args, interactive, args.yes || args.force)
657}
658
659fn apply_plan_with_override(
660    plan: Plan,
661    args: &CodexAgentsArgs,
662    interactive: bool,
663    confirm_all: bool,
664) -> Result<()> {
665    if plan.ops.is_empty() {
666        println!("No changes.");
667        return Ok(());
668    }
669
670    plan.print();
671
672    if args.dry_run {
673        return Ok(());
674    }
675
676    if plan.has_writes() && !confirm_all {
677        if !interactive {
678            bail!(
679                "This operation would write files. Re-run with --dry-run to preview or --yes/--force to proceed."
680            );
681        }
682
683        let proceed = Confirm::new("Proceed with these changes?")
684            .with_default(false)
685            .prompt()
686            .context("confirm apply")?;
687        if !proceed {
688            println!("Cancelled.");
689            return Ok(());
690        }
691    }
692
693    execute_plan(&plan)
694}
695
696fn execute_plan(plan: &Plan) -> Result<()> {
697    for op in &plan.ops {
698        match op {
699            PlanOp::EnsureDir { path } => {
700                fs::create_dir_all(path)
701                    .with_context(|| format!("create dir: {}", path.display()))?;
702            }
703            PlanOp::Backup { from, to } => {
704                fs::rename(from, to)
705                    .with_context(|| format!("backup {} -> {}", from.display(), to.display()))?;
706            }
707            PlanOp::Copy { from, to } => {
708                let content =
709                    fs::read(from).with_context(|| format!("read source: {}", from.display()))?;
710                if let Some(parent) = to.parent() {
711                    fs::create_dir_all(parent)
712                        .with_context(|| format!("create dir: {}", parent.display()))?;
713                }
714                atomic_write_with_mode(to, &content, None)
715                    .with_context(|| format!("write: {}", to.display()))?;
716            }
717            PlanOp::Link { from, to } => {
718                #[cfg(unix)]
719                {
720                    use std::os::unix::fs as unix_fs;
721                    if let Some(parent) = to.parent() {
722                        fs::create_dir_all(parent)
723                            .with_context(|| format!("create dir: {}", parent.display()))?;
724                    }
725                    unix_fs::symlink(from, to).with_context(|| {
726                        format!("symlink {} -> {}", to.display(), from.display())
727                    })?;
728                }
729                #[cfg(not(unix))]
730                {
731                    let _ = (from, to);
732                    bail!("symlink mode is not supported on this platform; use --mode copy");
733                }
734            }
735            PlanOp::WriteFile { path, content } => {
736                if let Some(parent) = path.parent() {
737                    fs::create_dir_all(parent)
738                        .with_context(|| format!("create dir: {}", parent.display()))?;
739                }
740                atomic_write_with_mode(path, content.as_bytes(), None)
741                    .with_context(|| format!("write: {}", path.display()))?;
742            }
743            PlanOp::Note { .. } => {}
744        }
745    }
746    Ok(())
747}
748
749fn plan_import(managed_dir: &Path, target_dir: &Path, stems: &[String]) -> Result<Plan> {
750    let mut plan = Plan::default();
751    if !managed_dir.exists() {
752        plan.ops.push(PlanOp::EnsureDir {
753            path: managed_dir.to_path_buf(),
754        });
755    }
756
757    for stem in stems {
758        let from = target_dir.join(format!("{stem}.toml"));
759        let to = managed_dir.join(format!("{stem}.toml"));
760
761        if !from.exists() {
762            plan.ops.push(PlanOp::Note {
763                message: format!("skip missing source: {}", from.display()),
764            });
765            continue;
766        }
767
768        let source_bytes = fs::read(&from).with_context(|| format!("read: {}", from.display()))?;
769        let needs_overwrite = if fs::symlink_metadata(&to).is_ok_and(|m| m.file_type().is_symlink())
770        {
771            true
772        } else if to.exists() {
773            fs::read(&to)
774                .map(|existing| existing != source_bytes)
775                .unwrap_or(true)
776        } else {
777            true
778        };
779
780        if !needs_overwrite {
781            continue;
782        }
783
784        if fs::symlink_metadata(&to).is_ok() {
785            let backup = backup_path_for(&to)?;
786            plan.ops.push(PlanOp::Backup {
787                from: to.clone(),
788                to: backup,
789            });
790        }
791
792        plan.ops.push(PlanOp::Copy { from, to });
793    }
794
795    Ok(plan)
796}
797
798fn plan_sync(
799    managed_dir: &Path,
800    target_dir: &Path,
801    stems: &[String],
802    mode: SyncMode,
803) -> Result<Plan> {
804    let mut plan = Plan::default();
805    if !target_dir.exists() {
806        plan.ops.push(PlanOp::EnsureDir {
807            path: target_dir.to_path_buf(),
808        });
809    }
810
811    for stem in stems {
812        let from = managed_dir.join(format!("{stem}.toml"));
813        let to = target_dir.join(format!("{stem}.toml"));
814
815        if !from.exists() {
816            plan.ops.push(PlanOp::Note {
817                message: format!("skip missing managed file: {}", from.display()),
818            });
819            continue;
820        }
821
822        if mode == SyncMode::Link {
823            if is_correct_symlink(&from, &to).unwrap_or(false) {
824                continue;
825            }
826        } else if mode == SyncMode::Copy && to.exists() {
827            let meta = fs::symlink_metadata(&to)?;
828            if !meta.file_type().is_symlink() {
829                let a = fs::read(&from).unwrap_or_default();
830                let b = fs::read(&to).unwrap_or_default();
831                if a == b {
832                    continue;
833                }
834            }
835        }
836
837        if fs::symlink_metadata(&to).is_ok() {
838            let backup = backup_path_for(&to)?;
839            plan.ops.push(PlanOp::Backup {
840                from: to.clone(),
841                to: backup,
842            });
843        }
844
845        match mode {
846            SyncMode::Link => plan.ops.push(PlanOp::Link { from, to }),
847            SyncMode::Copy => plan.ops.push(PlanOp::Copy { from, to }),
848        }
849    }
850
851    Ok(plan)
852}
853
854fn plan_inject(managed_dir: &Path, stems: &[String], body: &str) -> Result<Plan> {
855    let mut plan = Plan::default();
856
857    for stem in stems {
858        let path = managed_dir.join(format!("{stem}.toml"));
859        if !path.exists() {
860            plan.ops.push(PlanOp::Note {
861                message: format!("skip missing managed file: {}", path.display()),
862            });
863            continue;
864        }
865
866        let content =
867            fs::read_to_string(&path).with_context(|| format!("read: {}", path.display()))?;
868        let Some(updated) = inject_into_toml_developer_instructions(&content, body)? else {
869            plan.ops.push(PlanOp::Note {
870                message: format!("skip (no developer_instructions): {}", path.display()),
871            });
872            continue;
873        };
874
875        if updated == content {
876            continue;
877        }
878
879        plan.ops.push(PlanOp::WriteFile {
880            path,
881            content: updated,
882        });
883    }
884
885    Ok(plan)
886}
887
888fn resolve_managed_dir(args: &CodexAgentsArgs) -> Result<PathBuf> {
889    if let Some(path) = args.managed_dir.as_ref() {
890        return Ok(path.clone());
891    }
892    Ok(resolve_config_dir(None)?.join("codex").join("agents"))
893}
894
895fn resolve_target_agents_dir(args: &CodexAgentsArgs) -> Result<PathBuf> {
896    if let Some(path) = args.agents_dir.as_ref() {
897        return Ok(path.clone());
898    }
899
900    let home = if let Some(home) = args.codex_home.as_ref() {
901        home.clone()
902    } else if let Ok(env_home) = env::var("CODEX_HOME") {
903        let trimmed = env_home.trim();
904        if trimmed.is_empty() {
905            crate::config::home_dir()?.join(".codex")
906        } else {
907            PathBuf::from(trimmed)
908        }
909    } else {
910        crate::config::home_dir()?.join(".codex")
911    };
912
913    Ok(home.join("agents"))
914}
915
916fn resolve_target_codex_config_path(args: &CodexAgentsArgs) -> Result<PathBuf> {
917    Ok(resolve_target_codex_home_dir(args)?.join("config.toml"))
918}
919
920fn resolve_target_codex_home_dir(args: &CodexAgentsArgs) -> Result<PathBuf> {
921    if let Some(home) = args.codex_home.as_ref() {
922        return Ok(home.clone());
923    }
924
925    if let Some(agents_dir) = args.agents_dir.as_ref() {
926        if let Some(parent) = agents_dir.parent() {
927            return Ok(parent.to_path_buf());
928        }
929        bail!("--agents-dir has no parent; provide --codex-home to locate config.toml");
930    }
931
932    if let Ok(env_home) = env::var("CODEX_HOME") {
933        let trimmed = env_home.trim();
934        if trimmed.is_empty() {
935            return Ok(crate::config::home_dir()?.join(".codex"));
936        }
937        return Ok(PathBuf::from(trimmed));
938    }
939
940    Ok(crate::config::home_dir()?.join(".codex"))
941}
942
943fn list_toml_stems(dir: &Path) -> Result<Vec<String>> {
944    if !dir.exists() {
945        return Ok(Vec::new());
946    }
947
948    let mut stems = Vec::new();
949    for entry in fs::read_dir(dir).with_context(|| format!("read dir: {}", dir.display()))? {
950        let entry = entry?;
951        let file_name = entry.file_name();
952        let Some(name) = file_name.to_str() else {
953            continue;
954        };
955        if !name.ends_with(".toml") {
956            continue;
957        }
958        let stem = name.trim_end_matches(".toml").to_string();
959        if stem.is_empty() {
960            continue;
961        }
962        stems.push(stem);
963    }
964
965    stems.sort();
966    stems.dedup();
967    Ok(stems)
968}
969
970fn select_stems(available: &[String], only: &[String], what: &str) -> Result<Vec<String>> {
971    if only.is_empty() {
972        return Ok(available.to_vec());
973    }
974
975    let mut wanted = Vec::new();
976    let available_set: HashSet<&str> = available.iter().map(|s| s.as_str()).collect();
977    for raw in only {
978        let stem = validate_path_segment(raw, what)?;
979        if !available_set.contains(stem.as_str()) {
980            bail!("{what} not found: {stem}");
981        }
982        wanted.push(stem);
983    }
984    wanted.sort();
985    wanted.dedup();
986    Ok(wanted)
987}
988
989fn backup_path_for(path: &Path) -> Result<PathBuf> {
990    let ts = OffsetDateTime::now_utc()
991        .format(&time::macros::format_description!(
992            "[year][month][day][hour][minute][second]"
993        ))
994        .expect("valid timestamp");
995    let file_name = path
996        .file_name()
997        .and_then(|s| s.to_str())
998        .unwrap_or("backup");
999    Ok(path.with_file_name(format!("{file_name}.llman.bak.{ts}")))
1000}
1001
1002fn is_correct_symlink(from: &Path, link: &Path) -> Result<bool> {
1003    let meta =
1004        fs::symlink_metadata(link).with_context(|| format!("metadata: {}", link.display()))?;
1005    if !meta.file_type().is_symlink() {
1006        return Ok(false);
1007    }
1008
1009    let target = fs::read_link(link).with_context(|| format!("readlink: {}", link.display()))?;
1010    let resolved = if target.is_absolute() {
1011        target
1012    } else {
1013        link.parent().unwrap_or_else(|| Path::new(".")).join(target)
1014    };
1015
1016    Ok(resolved == from)
1017}
1018
1019fn describe_target_state(managed: &Path, target: &Path) -> Result<String> {
1020    if fs::symlink_metadata(target).is_err() {
1021        return Ok("missing".to_string());
1022    }
1023
1024    let meta =
1025        fs::symlink_metadata(target).with_context(|| format!("metadata: {}", target.display()))?;
1026    if meta.file_type().is_symlink() {
1027        let ok = is_correct_symlink(managed, target).unwrap_or(false);
1028        return Ok(if ok { "linked" } else { "wrong-link" }.to_string());
1029    }
1030
1031    let managed_bytes =
1032        fs::read(managed).with_context(|| format!("read: {}", managed.display()))?;
1033    let target_bytes = fs::read(target).with_context(|| format!("read: {}", target.display()))?;
1034    Ok(if managed_bytes == target_bytes {
1035        "copied"
1036    } else {
1037        "diff"
1038    }
1039    .to_string())
1040}
1041
1042fn describe_inject_state(managed: &Path) -> Result<String> {
1043    let content =
1044        fs::read_to_string(managed).with_context(|| format!("read: {}", managed.display()))?;
1045    let Some((open_end, close)) = dev_instructions_inner_range(&content) else {
1046        if content.contains("developer_instructions") {
1047            return Ok("unsupported-format".to_string());
1048        }
1049        return Ok("no-developer_instructions".to_string());
1050    };
1051    let inner = &content[open_end..close];
1052    let has_markers = has_llman_prompt_markers(inner);
1053    Ok(if has_markers { "managed" } else { "injectable" }.to_string())
1054}
1055
1056fn describe_agent_schema_state(managed: &Path) -> Result<String> {
1057    let content =
1058        fs::read_to_string(managed).with_context(|| format!("read: {}", managed.display()))?;
1059    let doc: Value = match toml::from_str(&content) {
1060        Ok(v) => v,
1061        Err(_) => return Ok("invalid-toml".to_string()),
1062    };
1063    let Some(table) = doc.as_table() else {
1064        return Ok("invalid-toml".to_string());
1065    };
1066
1067    let looks_like_agent = table.contains_key("name")
1068        || table.contains_key("description")
1069        || table.contains_key("developer_instructions");
1070    if !looks_like_agent {
1071        return Ok("overlay".to_string());
1072    }
1073
1074    let mut missing = Vec::new();
1075    if table
1076        .get("name")
1077        .and_then(|v| v.as_str())
1078        .is_none_or(|s| s.trim().is_empty())
1079    {
1080        missing.push("name");
1081    }
1082    if table
1083        .get("description")
1084        .and_then(|v| v.as_str())
1085        .is_none_or(|s| s.trim().is_empty())
1086    {
1087        missing.push("description");
1088    }
1089    if table
1090        .get("developer_instructions")
1091        .and_then(|v| v.as_str())
1092        .is_none_or(|s| s.trim().is_empty())
1093    {
1094        missing.push("developer_instructions");
1095    }
1096
1097    if missing.is_empty() {
1098        return Ok("agent-ok".to_string());
1099    }
1100
1101    Ok(format!("agent-missing({})", missing.join(",")))
1102}
1103
1104fn list_codex_prompt_templates() -> Result<Vec<String>> {
1105    let config = crate::config::Config::new()?;
1106    prompt_store::list_templates(&config, crate::config::CODEX_APP)
1107}
1108
1109fn build_injection_body(templates: &[String]) -> Result<String> {
1110    let config = crate::config::Config::new()?;
1111    prompt_store::build_llman_prompts_body(&config, crate::config::CODEX_APP, templates)
1112}
1113
1114fn inject_into_toml_developer_instructions(content: &str, body: &str) -> Result<Option<String>> {
1115    // Locate: developer_instructions = """ ... """
1116    // We intentionally support only the common triple-quote form to avoid rewriting TOML.
1117    let (open_start, open_end) = match find_dev_instructions_open(content) {
1118        Some(v) => v,
1119        None => return Ok(None),
1120    };
1121    let close = find_triple_quote_close(content, open_end).ok_or_else(|| {
1122        anyhow::anyhow!("Unterminated developer_instructions triple-quote string")
1123    })?;
1124
1125    let existing = &content[open_end..close];
1126    let updated_inner = update_text_with_markers(
1127        existing,
1128        body,
1129        true,
1130        LLMAN_PROMPTS_MARKER_START,
1131        LLMAN_PROMPTS_MARKER_END,
1132    );
1133    if updated_inner == existing {
1134        return Ok(Some(content.to_string()));
1135    }
1136
1137    let mut out = String::with_capacity(content.len() + body.len() + 64);
1138    out.push_str(&content[..open_start]);
1139    out.push_str(&content[open_start..open_end]);
1140    out.push_str(&updated_inner);
1141    out.push_str(&content[close..]);
1142    Ok(Some(out))
1143}
1144
1145fn find_dev_instructions_open(content: &str) -> Option<(usize, usize)> {
1146    // Line-wise scan to avoid matching inside strings/comments.
1147    let mut offset = 0usize;
1148    for line in content.split_inclusive('\n') {
1149        let raw = line.strip_suffix('\n').unwrap_or(line);
1150        let trimmed = raw.trim_start();
1151
1152        if trimmed.starts_with('#') {
1153            offset += line.len();
1154            continue;
1155        }
1156        if !trimmed.starts_with("developer_instructions") {
1157            offset += line.len();
1158            continue;
1159        }
1160
1161        // Remove trailing comment portion for the assignment line.
1162        let without_comment = trimmed.split('#').next().unwrap_or(trimmed);
1163        let Some(eq_idx) = without_comment.find('=') else {
1164            offset += line.len();
1165            continue;
1166        };
1167
1168        let rhs_in_without = &without_comment[eq_idx + 1..];
1169        let rhs = rhs_in_without.trim_start();
1170        let Some(pos) = rhs.find("\"\"\"") else {
1171            offset += line.len();
1172            continue;
1173        };
1174
1175        // Compute absolute indices: start delimiter position in full content, and end of opening delimiter.
1176        let before_trim = raw.len() - trimmed.len();
1177        let before_rhs = rhs_in_without.len() - rhs.len();
1178        let rhs_abs = offset + before_trim + (eq_idx + 1) + before_rhs;
1179        let open_start = rhs_abs + pos;
1180        return Some((open_start, open_start + 3));
1181    }
1182
1183    None
1184}
1185
1186fn find_triple_quote_close(content: &str, from: usize) -> Option<usize> {
1187    content[from..].find("\"\"\"").map(|i| from + i)
1188}
1189
1190fn dev_instructions_inner_range(content: &str) -> Option<(usize, usize)> {
1191    let (_open_start, open_end) = find_dev_instructions_open(content)?;
1192    let close = find_triple_quote_close(content, open_end)?;
1193    Some((open_end, close))
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use crate::config::override_runtime_config_dir;
1200    use crate::test_utils::TestProcess;
1201    use tempfile::TempDir;
1202
1203    #[test]
1204    fn inject_updates_existing_marker_block() {
1205        let input = r#"
1206name = "reviewer"
1207
1208developer_instructions = """
1209hello
1210<!-- LLMAN-PROMPTS:START -->
1211old
1212<!-- LLMAN-PROMPTS:END -->
1213bye
1214"""
1215"#;
1216        let out = inject_into_toml_developer_instructions(input, "new-body")
1217            .expect("inject")
1218            .expect("has dev instructions");
1219        assert!(out.contains("new-body"));
1220        assert!(!out.contains("\nold\n"));
1221    }
1222
1223    #[test]
1224    fn inject_appends_marker_when_missing() {
1225        let input = r#"
1226developer_instructions = """
1227hello
1228"""
1229"#;
1230        let out = inject_into_toml_developer_instructions(input, "body")
1231            .expect("inject")
1232            .expect("has dev instructions");
1233        assert!(out.contains(LLMAN_PROMPTS_MARKER_START));
1234        assert!(out.contains("body"));
1235        assert!(out.contains(LLMAN_PROMPTS_MARKER_END));
1236    }
1237
1238    #[test]
1239    fn inject_returns_none_when_no_developer_instructions() {
1240        let input = r#"model = "gpt-5.4-mini""#;
1241        let out = inject_into_toml_developer_instructions(input, "body").expect("inject");
1242        assert!(out.is_none());
1243    }
1244
1245    #[test]
1246    fn dry_run_plan_does_not_write_target_file() {
1247        let _proc = TestProcess::new();
1248        let temp = TempDir::new().expect("temp dir");
1249        let _guard = override_runtime_config_dir(temp.path().join("llman-config"));
1250
1251        let managed_dir = resolve_config_dir(None)
1252            .unwrap()
1253            .join("codex")
1254            .join("agents");
1255        let codex_home = temp.path().join("codex-home");
1256        let target_dir = codex_home.join("agents");
1257
1258        fs::create_dir_all(&managed_dir).unwrap();
1259        fs::write(managed_dir.join("a.toml"), "x").unwrap();
1260
1261        let args = CodexAgentsArgs {
1262            command: Some(CodexAgentsCommand::Sync {
1263                mode: SyncMode::Copy,
1264                upsert_config: false,
1265                upsert_agent: vec![],
1266            }),
1267            managed_dir: None,
1268            codex_home: Some(codex_home),
1269            agents_dir: None,
1270            only: vec![],
1271            dry_run: true,
1272            yes: true,
1273            force: false,
1274        };
1275
1276        run(&args).expect("run");
1277        assert!(!target_dir.join("a.toml").exists());
1278    }
1279
1280    #[test]
1281    fn non_interactive_write_requires_yes_or_force() {
1282        let _proc = TestProcess::new();
1283        let temp = TempDir::new().expect("temp dir");
1284        let _guard = override_runtime_config_dir(temp.path().join("llman-config"));
1285
1286        let managed_dir = resolve_config_dir(None)
1287            .unwrap()
1288            .join("codex")
1289            .join("agents");
1290        let codex_home = temp.path().join("codex-home");
1291        let target_dir = codex_home.join("agents");
1292
1293        fs::create_dir_all(&managed_dir).unwrap();
1294        fs::write(managed_dir.join("a.toml"), "x").unwrap();
1295
1296        let args = CodexAgentsArgs {
1297            command: Some(CodexAgentsCommand::Sync {
1298                mode: SyncMode::Copy,
1299                upsert_config: false,
1300                upsert_agent: vec![],
1301            }),
1302            managed_dir: None,
1303            codex_home: Some(codex_home),
1304            agents_dir: None,
1305            only: vec![],
1306            dry_run: false,
1307            yes: false,
1308            force: false,
1309        };
1310
1311        let err = run(&args).expect_err("should require --yes/--force in non-interactive mode");
1312        assert!(err.to_string().contains("--dry-run") || err.to_string().contains("--yes"));
1313        assert!(!target_dir.join("a.toml").exists());
1314    }
1315
1316    #[test]
1317    fn sync_can_upsert_agent_config_file_mappings_into_codex_config() {
1318        let _proc = TestProcess::new();
1319        let temp = TempDir::new().expect("temp dir");
1320        let _guard = override_runtime_config_dir(temp.path().join("llman-config"));
1321
1322        let managed_dir = resolve_config_dir(None)
1323            .unwrap()
1324            .join("codex")
1325            .join("agents");
1326        let codex_home = temp.path().join("codex-home");
1327
1328        fs::create_dir_all(&managed_dir).unwrap();
1329        fs::write(managed_dir.join("defaults.toml"), "x").unwrap();
1330        fs::write(managed_dir.join("explorer.toml"), "x").unwrap();
1331        fs::write(managed_dir.join("worker.toml"), "x").unwrap();
1332
1333        let args = CodexAgentsArgs {
1334            command: Some(CodexAgentsCommand::Sync {
1335                mode: SyncMode::Copy,
1336                upsert_config: true,
1337                upsert_agent: vec![],
1338            }),
1339            managed_dir: None,
1340            codex_home: Some(codex_home.clone()),
1341            agents_dir: None,
1342            only: vec![],
1343            dry_run: false,
1344            yes: true,
1345            force: false,
1346        };
1347
1348        run(&args).expect("run");
1349
1350        let codex_config_path = codex_home.join("config.toml");
1351        assert!(codex_config_path.exists());
1352
1353        let content = fs::read_to_string(&codex_config_path).unwrap();
1354        let doc: Value = toml::from_str(&content).unwrap();
1355        let root = doc.as_table().unwrap();
1356        let agents = root.get("agents").unwrap().as_table().unwrap();
1357
1358        let default_agent = agents.get("default").unwrap().as_table().unwrap();
1359        assert_eq!(
1360            default_agent.get("config_file").and_then(|v| v.as_str()),
1361            Some("agents/defaults.toml")
1362        );
1363
1364        let explorer_agent = agents.get("explorer").unwrap().as_table().unwrap();
1365        assert_eq!(
1366            explorer_agent.get("config_file").and_then(|v| v.as_str()),
1367            Some("agents/explorer.toml")
1368        );
1369
1370        let worker_agent = agents.get("worker").unwrap().as_table().unwrap();
1371        assert_eq!(
1372            worker_agent.get("config_file").and_then(|v| v.as_str()),
1373            Some("agents/worker.toml")
1374        );
1375    }
1376
1377    #[test]
1378    fn sync_upsert_config_default_does_not_include_custom_agents() {
1379        let _proc = TestProcess::new();
1380        let temp = TempDir::new().expect("temp dir");
1381        let _guard = override_runtime_config_dir(temp.path().join("llman-config"));
1382
1383        let managed_dir = resolve_config_dir(None)
1384            .unwrap()
1385            .join("codex")
1386            .join("agents");
1387        let codex_home = temp.path().join("codex-home");
1388
1389        fs::create_dir_all(&managed_dir).unwrap();
1390        fs::write(managed_dir.join("defaults.toml"), "x").unwrap();
1391        fs::write(managed_dir.join("explorer.toml"), "x").unwrap();
1392        fs::write(managed_dir.join("worker.toml"), "x").unwrap();
1393        fs::write(managed_dir.join("reviewer.toml"), "x").unwrap();
1394
1395        let args = CodexAgentsArgs {
1396            command: Some(CodexAgentsCommand::Sync {
1397                mode: SyncMode::Copy,
1398                upsert_config: true,
1399                upsert_agent: vec![],
1400            }),
1401            managed_dir: None,
1402            codex_home: Some(codex_home.clone()),
1403            agents_dir: None,
1404            only: vec![],
1405            dry_run: false,
1406            yes: true,
1407            force: false,
1408        };
1409
1410        run(&args).expect("run");
1411
1412        let codex_config_path = codex_home.join("config.toml");
1413        let content = fs::read_to_string(&codex_config_path).unwrap();
1414        let doc: Value = toml::from_str(&content).unwrap();
1415        let root = doc.as_table().unwrap();
1416        let agents = root.get("agents").unwrap().as_table().unwrap();
1417
1418        assert!(agents.contains_key("default"));
1419        assert!(agents.contains_key("explorer"));
1420        assert!(agents.contains_key("worker"));
1421        assert!(!agents.contains_key("reviewer"));
1422    }
1423
1424    #[test]
1425    fn sync_upsert_agent_implies_upsert_config() {
1426        let _proc = TestProcess::new();
1427        let temp = TempDir::new().expect("temp dir");
1428        let _guard = override_runtime_config_dir(temp.path().join("llman-config"));
1429
1430        let managed_dir = resolve_config_dir(None)
1431            .unwrap()
1432            .join("codex")
1433            .join("agents");
1434        let codex_home = temp.path().join("codex-home");
1435
1436        fs::create_dir_all(&managed_dir).unwrap();
1437        fs::write(managed_dir.join("reviewer.toml"), "x").unwrap();
1438
1439        let args = CodexAgentsArgs {
1440            command: Some(CodexAgentsCommand::Sync {
1441                mode: SyncMode::Copy,
1442                upsert_config: false,
1443                upsert_agent: vec!["reviewer".to_string()],
1444            }),
1445            managed_dir: None,
1446            codex_home: Some(codex_home.clone()),
1447            agents_dir: None,
1448            only: vec![],
1449            dry_run: false,
1450            yes: true,
1451            force: false,
1452        };
1453
1454        run(&args).expect("run");
1455
1456        let codex_config_path = codex_home.join("config.toml");
1457        let content = fs::read_to_string(&codex_config_path).unwrap();
1458        let doc: Value = toml::from_str(&content).unwrap();
1459        let root = doc.as_table().unwrap();
1460        let agents = root.get("agents").unwrap().as_table().unwrap();
1461
1462        let reviewer = agents.get("reviewer").unwrap().as_table().unwrap();
1463        assert_eq!(
1464            reviewer.get("config_file").and_then(|v| v.as_str()),
1465            Some("agents/reviewer.toml")
1466        );
1467    }
1468}