use std::io::{self, Write as _};
use std::path::Path;
use std::process::Command;
use crate::bootstrap::resolve_config_path;
use anyhow::{Context as _, bail};
use zeph_common::format_tokens;
use zeph_memory::store::agent_sessions::{AgentSessionRow, SessionStatus};
use zeph_subagent::error::SubAgentError;
use zeph_subagent::{SubAgentDef, ToolPolicy, is_valid_agent_name, resolve_agent_paths};
use crate::cli::{AgentsCommand, FleetStatus};
pub(crate) async fn handle_agents_command(
cmd: AgentsCommand,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
match cmd {
AgentsCommand::List => handle_list(config_path),
AgentsCommand::Show { name } => handle_show(&name, config_path),
AgentsCommand::Create {
name,
description,
dir,
model,
} => handle_create(&name, &description, dir.as_path(), model.as_deref()),
AgentsCommand::Edit { name } => handle_edit(&name, config_path),
AgentsCommand::Delete { name, yes } => handle_delete(&name, yes, config_path),
AgentsCommand::Fleet { status, limit } => handle_fleet(status, limit, config_path).await,
}
}
fn load_all_defs(config_path: Option<&Path>) -> anyhow::Result<Vec<SubAgentDef>> {
let config_file = resolve_config_path(config_path);
let config = zeph_core::config::Config::load(&config_file).unwrap_or_default();
let paths = resolve_agent_paths(
&[],
config.agents.user_agents_dir.as_ref(),
&config.agents.extra_dirs,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
SubAgentDef::load_all_with_sources(
&paths,
&[],
config.agents.user_agents_dir.as_ref(),
&config.agents.extra_dirs,
)
.map_err(|e| anyhow::anyhow!("{e}"))
}
fn handle_list(config_path: Option<&Path>) -> anyhow::Result<()> {
let defs = load_all_defs(config_path)?;
if defs.is_empty() {
println!("No sub-agent definitions found.");
return Ok(());
}
let name_w = defs.iter().map(|d| d.name.len()).max().unwrap_or(4).max(4);
let scope_w = defs
.iter()
.map(|d| d.source.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(5)
.max(5);
let desc_w = 40usize;
println!(
"{:<name_w$} {:<scope_w$} {:<desc_w$} MODEL",
"NAME", "SCOPE", "DESCRIPTION"
);
println!("{}", "-".repeat(name_w + scope_w + desc_w + 20usize));
for d in &defs {
let scope = d.source.as_deref().unwrap_or("-");
let desc = truncate(&d.description, desc_w);
let model = d.model.as_ref().map_or("-", |m| m.as_str());
println!(
"{:<name_w$} {:<scope_w$} {:<desc_w$} {}",
d.name, scope, desc, model
);
}
Ok(())
}
fn handle_show(name: &str, config_path: Option<&Path>) -> anyhow::Result<()> {
let defs = load_all_defs(config_path)?;
let def = defs
.iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow::anyhow!("agent not found: {name}"))?;
println!("Name: {}", def.name);
println!("Description: {}", def.description);
println!("Source: {}", def.source.as_deref().unwrap_or("-"));
println!(
"Model: {}",
def.model.as_ref().map_or("-", |m| m.as_str())
);
println!("Mode: {:?}", def.permissions.permission_mode);
println!("Max turns: {}", def.permissions.max_turns);
println!("Background: {}", def.permissions.background);
let tools_str = match &def.tools {
ToolPolicy::AllowList(v) => format!("allow {v:?}"),
ToolPolicy::DenyList(v) => format!("deny {v:?}"),
ToolPolicy::InheritAll => "inherit_all".to_owned(),
};
if def.disallowed_tools.is_empty() {
println!("Tools: {tools_str}");
} else {
println!("Tools: {tools_str} except {:?}", def.disallowed_tools);
}
if !def.skills.include.is_empty() || !def.skills.exclude.is_empty() {
println!(
"Skills: include {:?} exclude {:?}",
def.skills.include, def.skills.exclude
);
}
if !def.system_prompt.is_empty() {
println!("\nSystem prompt:\n{}", def.system_prompt);
}
Ok(())
}
fn handle_create(
name: &str,
description: &str,
dir: &Path,
model: Option<&str>,
) -> anyhow::Result<()> {
if !is_valid_agent_name(name) {
anyhow::bail!("invalid agent name '{name}': must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$");
}
let target_path = dir.join(format!("{name}.md"));
if target_path.exists() {
anyhow::bail!(
"agent '{name}' already exists at {}; use `zeph agents edit {name}` to modify it",
target_path.display()
);
}
let mut def = SubAgentDef::default_template(name, description);
if let Some(m) = model {
def.model = Some(zeph_subagent::ModelSpec::Named(m.to_owned()));
}
let target = def
.save_atomic(dir)
.map_err(|e: SubAgentError| anyhow::anyhow!("{e}"))?;
println!("Created {}", target.display());
Ok(())
}
fn handle_edit(name: &str, config_path: Option<&Path>) -> anyhow::Result<()> {
let defs = load_all_defs(config_path)?;
let def = defs
.iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow::anyhow!("agent not found: {name}"))?;
let path = def
.file_path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("cannot determine file path for agent '{name}'"))?;
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_owned());
let status = Command::new(&editor).arg(path).status().with_context(|| {
format!(
"failed to launch editor '{editor}'; \
set $EDITOR or $VISUAL environment variable"
)
})?;
if !status.success() {
bail!("editor exited with non-zero status");
}
let content =
std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
SubAgentDef::parse(&content)
.map_err(|e| anyhow::anyhow!("definition is invalid after editing: {e}"))?;
println!("Updated {}", path.display());
Ok(())
}
fn handle_delete(name: &str, yes: bool, config_path: Option<&Path>) -> anyhow::Result<()> {
let defs = load_all_defs(config_path)?;
let def = defs
.iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow::anyhow!("agent not found: {name}"))?;
let path = def
.file_path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("cannot determine file path for agent '{name}'"))?;
if !yes {
print!("Delete {}? [y/N] ", path.display());
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
SubAgentDef::delete_file(path).map_err(|e: SubAgentError| anyhow::anyhow!("{e}"))?;
println!("Deleted {name}");
Ok(())
}
async fn handle_fleet(
status_filter: Option<FleetStatus>,
limit: u32,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let config_file = resolve_config_path(config_path);
let config = zeph_core::config::Config::load(&config_file).unwrap_or_default();
let db_path = &config.memory.sqlite_path;
let store = zeph_memory::store::DbStore::new(db_path)
.await
.context("failed to open database")?;
let sf: Option<SessionStatus> = status_filter.map(SessionStatus::from);
let sessions = store
.list_agent_sessions(limit, sf)
.await
.context("failed to list agent sessions")?;
if sessions.is_empty() {
println!("No agent sessions found.");
return Ok(());
}
print_fleet_table(&sessions);
Ok(())
}
fn print_fleet_table(sessions: &[AgentSessionRow]) {
let w_id = 8;
let w_kind = 12;
let w_status = 11;
let w_channel = 5;
let w_model = 22;
let w_turns = 5;
let w_tokens = 15;
println!(
"{:<w_id$} {:<w_kind$}{:<w_status$}{:<w_channel$}{:<w_model$}{:>w_turns$} {:<w_tokens$}COST",
"ID", "KIND", "STATUS", "CH", "MODEL", "TURNS", "P/C TOKENS"
);
println!("{}", "-".repeat(100));
for s in sessions {
let id = if s.id.len() > w_id {
&s.id[..w_id]
} else {
&s.id
};
let model = truncate(&s.model, w_model);
let tokens = format!(
"{}/{}",
format_tokens(s.prompt_tokens),
format_tokens(s.completion_tokens)
);
let cost = if s.cost_cents > 0.0 {
format!("${:.4}", s.cost_cents / 100.0)
} else {
"—".to_owned()
};
println!(
"{:<w_id$} {:<w_kind$}{:<w_status$}{:<w_channel$}{:<w_model$}{:>w_turns$} {:<w_tokens$}{}",
id,
s.kind.to_string(),
s.status.to_string(),
s.channel.to_string(),
model,
s.turns,
tokens,
cost,
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{truncated}…")
}
}