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 #[arg(long = "managed-dir", global = true)]
29 pub managed_dir: Option<PathBuf>,
30
31 #[arg(long = "codex-home", global = true)]
33 pub codex_home: Option<PathBuf>,
34
35 #[arg(long = "agents-dir", global = true)]
37 pub agents_dir: Option<PathBuf>,
38
39 #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append, global = true)]
41 pub only: Vec<String>,
42
43 #[arg(long, global = true)]
45 pub dry_run: bool,
46
47 #[arg(long, global = true)]
49 pub yes: bool,
50
51 #[arg(long, global = true)]
53 pub force: bool,
54}
55
56#[derive(Subcommand, Debug, Clone)]
57pub enum CodexAgentsCommand {
58 Status,
60 Import,
62 Sync {
64 #[arg(long, value_enum, default_value_t = SyncMode::Link)]
65 mode: SyncMode,
66
67 #[arg(long = "upsert-config")]
69 upsert_config: bool,
70
71 #[arg(long = "upsert-agent", value_delimiter = ',', action = clap::ArgAction::Append)]
77 upsert_agent: Vec<String>,
78 },
79 Inject {
81 #[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 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 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 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 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}