use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{anyhow, Context, Result};
use klasp_agents_codex::{CodexSurface, HookKind, HookWarning};
use klasp_core::{
AgentSurface, ConfigV1, InstallContext, InstallReport, KlaspError, GATE_SCHEMA_VERSION,
};
use crate::cli::InstallArgs;
use crate::registry::SurfaceRegistry;
pub const AGENT_ALL: &str = "all";
pub fn run(args: &InstallArgs) -> ExitCode {
match try_run(args) {
Ok(exit) => exit,
Err(e) => {
eprintln!("klasp install: {e:#}");
ExitCode::from(1)
}
}
}
fn try_run(args: &InstallArgs) -> Result<ExitCode> {
let repo_root = resolve_repo_root(args.repo_root.as_deref())?;
let registry = SurfaceRegistry::default();
let selection = resolve_selection(args.agent.as_deref(), ®istry, &repo_root)?;
let surfaces = match selection {
Selection::Empty { reason } => {
eprintln!("warning: {reason}; nothing to install");
return Ok(ExitCode::SUCCESS);
}
Selection::Surfaces(s) => s,
};
if let Some(agent_name) = args.agent.as_deref() {
if agent_name != AGENT_ALL {
warn_if_narrower_than_config(agent_name, &repo_root, ®istry);
}
}
let surfaces = if args.agent.is_some() {
surfaces
} else {
filter_by_detect(surfaces, &repo_root, args.force)
};
if surfaces.is_empty() {
return Err(anyhow!(
"no agent surfaces auto-detected at {}; pass --force or --agent <name>",
repo_root.display(),
));
}
let ctx = InstallContext {
repo_root: repo_root.clone(),
dry_run: args.dry_run,
force: args.force,
schema_version: GATE_SCHEMA_VERSION,
};
let mut reports = Vec::with_capacity(surfaces.len());
for s in &surfaces {
let (report, warnings) = install_one_surface(*s, &ctx)?;
for warning in &warnings {
print_hook_warning(warning);
}
reports.push(report);
}
print_reports(&reports, args.dry_run);
Ok(ExitCode::SUCCESS)
}
pub(crate) enum Selection<'a> {
Surfaces(Vec<&'a dyn AgentSurface>),
Empty { reason: String },
}
pub(crate) fn resolve_selection<'a>(
requested: Option<&str>,
registry: &'a SurfaceRegistry,
repo_root: &Path,
) -> Result<Selection<'a>> {
match requested {
None => Ok(Selection::Surfaces(registry.iter().collect())),
Some(name) if name == AGENT_ALL => resolve_all(registry, repo_root),
Some(name) => match registry.get(name) {
Some(s) => Ok(Selection::Surfaces(vec![s])),
None => Err(unknown_agent(name, registry)),
},
}
}
fn resolve_all<'a>(registry: &'a SurfaceRegistry, repo_root: &Path) -> Result<Selection<'a>> {
let config = ConfigV1::load(repo_root).map_err(map_config_err)?;
if config.gate.agents.is_empty() {
return Ok(Selection::Empty {
reason: "`[gate].agents = []` in klasp.toml".to_string(),
});
}
let mut surfaces = Vec::with_capacity(config.gate.agents.len());
for name in &config.gate.agents {
match registry.get(name) {
Some(s) => surfaces.push(s),
None => return Err(unknown_agent(name, registry)),
}
}
Ok(Selection::Surfaces(surfaces))
}
fn map_config_err(e: KlaspError) -> anyhow::Error {
match e {
KlaspError::ConfigNotFound { searched } => {
let paths: Vec<String> = searched.iter().map(|p| p.display().to_string()).collect();
anyhow!(
"--agent all requires klasp.toml; not found (searched: {})",
paths.join(", ")
)
}
other => anyhow!(other),
}
}
fn unknown_agent(name: &str, registry: &SurfaceRegistry) -> anyhow::Error {
let supported = registry.agent_ids().join(", ");
anyhow!("unknown agent \"{name}\"; supported: {supported} (or \"all\" to fan out across [gate].agents)")
}
fn filter_by_detect<'a>(
surfaces: Vec<&'a dyn AgentSurface>,
repo_root: &Path,
force: bool,
) -> Vec<&'a dyn AgentSurface> {
if force {
return surfaces;
}
surfaces
.into_iter()
.filter(|s| s.detect(repo_root))
.collect()
}
fn warn_if_narrower_than_config(installing: &str, repo_root: &Path, registry: &SurfaceRegistry) {
let config = match ConfigV1::load(repo_root) {
Ok(c) => c,
Err(_) => return,
};
let uncovered: Vec<&str> = config
.gate
.agents
.iter()
.filter(|a| a.as_str() != installing)
.filter(|a| registry.get(a.as_str()).is_some()) .map(String::as_str)
.collect();
if !uncovered.is_empty() {
eprintln!(
"warning: klasp.toml lists agents {} that this install will NOT cover; \
doctor will report them as missing. \
Run `klasp install --agent all` to cover all declared agents.",
uncovered.join(", ")
);
}
}
fn print_reports(reports: &[InstallReport], dry_run: bool) {
for r in reports {
if r.already_installed {
println!("{}: already installed (no changes)", r.agent_id);
continue;
}
if dry_run {
println!(
"{}: would write {} and update {}",
r.agent_id,
r.hook_path.display(),
r.settings_path.display(),
);
if let Some(preview) = &r.preview {
println!("--- {} ---", r.hook_path.display());
print!("{preview}");
}
continue;
}
println!("{}: installed", r.agent_id);
for path in &r.paths_written {
println!(" wrote {}", path.display());
}
}
}
pub(crate) fn install_one_surface(
surface: &dyn AgentSurface,
ctx: &InstallContext,
) -> Result<(InstallReport, Vec<HookWarning>)> {
if surface.agent_id() == CodexSurface::AGENT_ID {
let detailed = CodexSurface
.install_detailed(ctx)
.with_context(|| format!("installing {}", surface.agent_id()))?;
Ok((detailed.report, detailed.warnings))
} else {
let report = surface
.install(ctx)
.with_context(|| format!("installing {}", surface.agent_id()))?;
Ok((report, vec![]))
}
}
pub(crate) fn print_hook_warning(warning: &HookWarning) {
match warning {
HookWarning::Skipped {
path,
kind,
conflict,
} => {
let hook_label = match kind {
HookKind::Commit => "pre-commit",
HookKind::Push => "pre-push",
};
let trigger = kind.trigger_arg();
let tool = conflict.tool();
eprintln!(
"warning: skipping {hook_label} hook ({}) — file is managed by {tool}.",
path.display()
);
eprintln!(
" Install klasp's gate manually by adding `klasp gate \
--agent codex --trigger {trigger} \"$@\"`"
);
eprintln!(
" to your existing hook, or remove the foreign tool and \
re-run `klasp install --agent codex`."
);
}
}
}
pub(crate) fn resolve_repo_root(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
return Ok(p.to_path_buf());
}
let cwd = std::env::current_dir().context("getting current directory")?;
let mut probe = cwd.as_path();
loop {
if probe.join(".git").exists() {
return Ok(probe.to_path_buf());
}
match probe.parent() {
Some(parent) => probe = parent,
None => {
return Err(anyhow!(
"not a git repository (run from inside a repo, or pass --repo-root)"
));
}
}
}
}