use anyhow::{Result, anyhow};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneMode {
NewPane,
ExistingPane,
CurrentPane,
}
impl PaneMode {
pub fn as_str(self) -> &'static str {
match self {
Self::NewPane => "new-pane",
Self::ExistingPane => "existing-pane",
Self::CurrentPane => "current-pane",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentFrontmatter {
pub color: Option<String>,
pub model: Option<String>,
}
pub fn read_agent_frontmatter(home_dir: &Path, agent_name: &str) -> AgentFrontmatter {
let path = home_dir
.join(".claude")
.join("agents")
.join(format!("{agent_name}.md"));
let Ok(text) = std::fs::read_to_string(&path) else {
return AgentFrontmatter::default();
};
parse_agent_frontmatter(&text)
}
fn parse_agent_frontmatter(text: &str) -> AgentFrontmatter {
let mut lines = text.lines();
if lines.next().map(str::trim) != Some("---") {
return AgentFrontmatter::default();
}
let mut color = None;
let mut model = None;
for line in lines {
if line.trim() == "---" {
break;
}
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().to_string();
if val.is_empty() {
continue;
}
match key {
"color" => color = Some(val),
"model" => model = Some(val),
_ => {}
}
}
}
AgentFrontmatter { color, model }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnDraft {
pub team: String,
pub member: String,
pub model: String,
pub agent_type: String,
pub pane_mode: PaneMode,
pub worktree: Option<String>,
pub color: Option<String>,
}
pub fn parse_pane_mode(raw: &str) -> Result<PaneMode> {
match raw.trim().to_ascii_lowercase().as_str() {
"new-pane" => Ok(PaneMode::NewPane),
"existing-pane" => Ok(PaneMode::ExistingPane),
"current-pane" => Ok(PaneMode::CurrentPane),
_ => Err(anyhow!(
"invalid pane mode '{raw}'. valid: new-pane, existing-pane, current-pane"
)),
}
}
pub fn apply_edits(draft: &mut SpawnDraft, edits: &str) -> Result<()> {
for pair in edits.split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let Some((idx_raw, value_raw)) = pair.split_once('=') else {
return Err(anyhow!(
"invalid edit '{pair}'. expected n=value (for example: 1=atm-dev)"
));
};
let idx: u8 = idx_raw
.trim()
.parse()
.map_err(|_| anyhow!("invalid field index '{idx_raw}'"))?;
let value = value_raw.trim();
match idx {
1 => draft.team = value.to_string(),
2 => draft.member = value.to_string(),
3 => draft.model = value.to_string(),
4 => draft.agent_type = value.to_string(),
5 => draft.pane_mode = parse_pane_mode(value)?,
6 => {
if value.is_empty() || value.eq_ignore_ascii_case("(none)") {
draft.worktree = None;
} else {
draft.worktree = Some(value.to_string());
}
}
7 => {
if value.is_empty() || value.eq_ignore_ascii_case("(none)") {
draft.color = None;
} else {
draft.color = Some(value.to_string());
}
}
_ => return Err(anyhow!("unknown field index '{idx}'. valid fields: 1..7")),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn base_draft() -> SpawnDraft {
SpawnDraft {
team: "atm-dev".to_string(),
member: "arch-ctm".to_string(),
model: "unknown".to_string(),
agent_type: "general-purpose".to_string(),
pane_mode: PaneMode::NewPane,
worktree: None,
color: None,
}
}
#[test]
fn test_parse_agent_frontmatter_extracts_color_and_model() {
let text = "---\nname: atm-monitor\nmodel: haiku\ncolor: orange\n---\nbody";
let fm = parse_agent_frontmatter(text);
assert_eq!(fm.color.as_deref(), Some("orange"));
assert_eq!(fm.model.as_deref(), Some("haiku"));
}
#[test]
fn test_parse_agent_frontmatter_no_frontmatter_returns_default() {
let fm = parse_agent_frontmatter("just body text");
assert_eq!(fm, AgentFrontmatter::default());
}
#[test]
fn test_parse_agent_frontmatter_missing_fields_returns_none() {
let text = "---\nname: arch-ctm\ndescription: something\n---\nbody";
let fm = parse_agent_frontmatter(text);
assert!(fm.color.is_none());
assert!(fm.model.is_none());
}
#[test]
fn test_parse_pane_mode_validation() {
assert_eq!(parse_pane_mode("new-pane").unwrap(), PaneMode::NewPane);
assert_eq!(
parse_pane_mode("existing-pane").unwrap(),
PaneMode::ExistingPane
);
assert_eq!(
parse_pane_mode("current-pane").unwrap(),
PaneMode::CurrentPane
);
assert!(parse_pane_mode("invalid").is_err());
}
#[test]
fn test_apply_edits_parses_comma_separated() {
let mut draft = base_draft();
apply_edits(
&mut draft,
"1=atm-qa,2=quality-mgr,3=claude-haiku-4-5,5=existing-pane",
)
.unwrap();
assert_eq!(draft.team, "atm-qa");
assert_eq!(draft.member, "quality-mgr");
assert_eq!(draft.model, "claude-haiku-4-5");
assert_eq!(draft.pane_mode, PaneMode::ExistingPane);
}
}