use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use super::{InitError, InstallOutcome, InstallPath, InstallStatus};
pub const DEFAULT_AGENT_NAME: &str = "ptuf-guarded";
pub const FALLBACK_AGENT_NAME: &str = "default";
pub const DEFAULT_MATCHER: &str = "*";
pub const DEFAULT_TIMEOUT_MS: u64 = 10_000;
pub const DEFAULT_CACHE_TTL_SECONDS: u64 = 0;
pub(crate) const COMMAND_TAIL: &[&str] = &["hook", "kiro"];
const CHAT_DEFAULT_AGENT_KEY: &str = "chat.defaultAgent";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Scope {
Workspace,
Home,
}
impl Scope {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Workspace => "workspace-agent",
Self::Home => "home-agent",
}
}
pub(crate) fn short(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Home => "home",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedAgent {
pub scope: Scope,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KiroDefaultAgentReport {
pub scope: Scope,
pub agent_name: String,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct KiroInstallExtras {
pub already_present_count: usize,
pub default_agents: Vec<KiroDefaultAgentReport>,
pub skipped_non_json_agents: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetPaths {
pub(crate) agent_config_paths: Vec<ResolvedAgent>,
pub(crate) skipped_non_json: Vec<PathBuf>,
pub(crate) default_agent_names: Vec<KiroDefaultAgentReport>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum KiroMode {
#[default]
PatchExisting,
NewAgent,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ScopeFilter {
#[default]
Both,
WorkspaceOnly,
GlobalOnly,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KiroInitOptions {
pub mode: KiroMode,
pub scope: ScopeFilter,
}
pub fn detect_binary() -> String {
super::detect_binary_impl()
}
pub fn resolve_paths(
start: Option<&Path>,
opts: &KiroInitOptions,
) -> Result<TargetPaths, InitError> {
let home = std::env::var_os("HOME").map(PathBuf::from);
resolve_paths_with(start, home.as_deref(), opts)
}
pub(crate) fn resolve_paths_with(
start: Option<&Path>,
home: Option<&Path>,
opts: &KiroInitOptions,
) -> Result<TargetPaths, InitError> {
let workspace_root = start.and_then(crate::config::repo::discover);
let home_root: Option<PathBuf> = home.map(Path::to_path_buf);
let (use_workspace, use_home) = match opts.scope {
ScopeFilter::WorkspaceOnly => {
if workspace_root.is_none() {
return Err(InitError::RepoRootNotFound);
}
(true, false)
},
ScopeFilter::GlobalOnly => {
if home_root.is_none() {
return Err(InitError::HomeNotSet);
}
(false, true)
},
ScopeFilter::Both => {
if workspace_root.is_none() && home_root.is_none() {
return Err(InitError::RepoRootNotFound);
}
(workspace_root.is_some(), home_root.is_some())
},
};
if matches!(opts.mode, KiroMode::NewAgent) {
return resolve_new_agent_path(
workspace_root.as_deref(),
home_root.as_deref(),
use_workspace,
use_home,
);
}
let mut agents: Vec<ResolvedAgent> = Vec::new();
let mut skipped: Vec<PathBuf> = Vec::new();
let mut defaults: Vec<KiroDefaultAgentReport> = Vec::new();
let mut scopes: Vec<(Scope, PathBuf)> = Vec::new();
if use_workspace && let Some(root) = workspace_root.as_deref() {
scopes.push((Scope::Workspace, root.join(".kiro")));
}
if use_home && let Some(root) = home_root.as_deref() {
scopes.push((Scope::Home, root.join(".kiro")));
}
for (scope, kiro_dir) in &scopes {
let agents_dir = kiro_dir.join("agents");
let (mut json_files, mut md_files) = enumerate_agents(&agents_dir)?;
json_files.sort();
md_files.sort();
skipped.append(&mut md_files);
let settings_dir = kiro_dir.join("settings");
let default_name = read_default_agent(&settings_dir)?;
if let Some(name) = default_name.as_deref() {
let referenced = agents_dir.join(format!("{name}.json"));
let present_in_scope = json_files.iter().any(|p| p == &referenced);
if !present_in_scope {
return Err(InitError::Schema {
path: settings_dir.join("cli.json"),
message: format!(
"`{CHAT_DEFAULT_AGENT_KEY}`=\"{name}\" referenced but {} not found",
referenced.display()
),
});
}
defaults.push(KiroDefaultAgentReport {
scope: *scope,
agent_name: name.to_string(),
});
}
for path in json_files {
agents.push(ResolvedAgent {
scope: *scope,
path,
});
}
}
if agents.is_empty() {
let Some((scope, kiro_dir)) = scopes.first() else {
return Err(InitError::RepoRootNotFound);
};
let fallback = kiro_dir
.join("agents")
.join(format!("{FALLBACK_AGENT_NAME}.json"));
agents.push(ResolvedAgent {
scope: *scope,
path: fallback,
});
}
Ok(TargetPaths {
agent_config_paths: agents,
skipped_non_json: skipped,
default_agent_names: defaults,
})
}
fn resolve_new_agent_path(
workspace_root: Option<&Path>,
home_root: Option<&Path>,
use_workspace: bool,
use_home: bool,
) -> Result<TargetPaths, InitError> {
let file_name = format!("{DEFAULT_AGENT_NAME}.json");
let (scope, base) = if use_workspace && let Some(root) = workspace_root {
(Scope::Workspace, root.to_path_buf())
} else if use_home && let Some(root) = home_root {
(Scope::Home, root.to_path_buf())
} else {
return Err(InitError::RepoRootNotFound);
};
Ok(TargetPaths {
agent_config_paths: vec![ResolvedAgent {
scope,
path: base.join(".kiro/agents").join(file_name),
}],
skipped_non_json: Vec::new(),
default_agent_names: Vec::new(),
})
}
fn enumerate_agents(agents_dir: &Path) -> Result<(Vec<PathBuf>, Vec<PathBuf>), InitError> {
let entries = match fs::read_dir(agents_dir) {
Ok(it) => it,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok((Vec::new(), Vec::new())),
Err(e) => {
return Err(InitError::Io {
path: agents_dir.to_path_buf(),
source: e,
});
},
};
let mut json_files = Vec::new();
let mut other_files = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| InitError::Io {
path: agents_dir.to_path_buf(),
source: e,
})?;
let path = entry.path();
let ft = entry.file_type().map_err(|e| InitError::Io {
path: path.clone(),
source: e,
})?;
if !ft.is_file() {
continue;
}
match path.extension().and_then(|e| e.to_str()) {
Some("json") => json_files.push(path),
_ => other_files.push(path),
}
}
Ok((json_files, other_files))
}
fn read_default_agent(settings_dir: &Path) -> Result<Option<String>, InitError> {
let path = settings_dir.join("cli.json");
let body = match fs::read_to_string(&path) {
Ok(s) if s.trim().is_empty() => return Ok(None),
Ok(s) => s,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(InitError::Io {
path: path.clone(),
source: e,
});
},
};
let value: Value = serde_json::from_str(&body).map_err(|e| InitError::Json {
path: path.clone(),
message: e.to_string(),
})?;
let Some(obj) = value.as_object() else {
return Ok(None);
};
Ok(obj
.get(CHAT_DEFAULT_AGENT_KEY)
.and_then(Value::as_str)
.map(str::to_string))
}
pub fn install(
targets: &TargetPaths,
ptuf_binary: &str,
dry_run: bool,
) -> Result<InstallOutcome, InitError> {
let (outcome, _) = install_with_report(targets, ptuf_binary, dry_run)?;
Ok(outcome)
}
pub(crate) fn install_with_report(
targets: &TargetPaths,
ptuf_binary: &str,
dry_run: bool,
) -> Result<(InstallOutcome, KiroInstallExtras), InitError> {
let command = format!("{ptuf_binary} hook kiro");
let mut paths: Vec<InstallPath> = Vec::with_capacity(targets.agent_config_paths.len());
let mut already_present_count = 0_usize;
for agent in &targets.agent_config_paths {
let per_file = install_one_file(&agent.path, &command, dry_run)?;
if per_file.already_present {
already_present_count += 1;
}
paths.push(InstallPath {
label: agent.scope.label(),
path: agent.path.clone(),
});
}
let installed_count = paths.len() - already_present_count;
let status = if installed_count == 0 {
InstallStatus::AlreadyPresent
} else if dry_run {
InstallStatus::WouldInstall
} else {
InstallStatus::Installed
};
let extras = KiroInstallExtras {
already_present_count,
default_agents: targets.default_agent_names.clone(),
skipped_non_json_agents: targets.skipped_non_json.clone(),
};
Ok((
InstallOutcome {
status,
agent: "kiro",
paths,
matcher: DEFAULT_MATCHER.to_string(),
command,
},
extras,
))
}
struct PerFileResult {
already_present: bool,
}
fn install_one_file(path: &Path, command: &str, dry_run: bool) -> Result<PerFileResult, InitError> {
let mut root = read_agent_config(path)?;
if has_existing_hook(&root) {
return Ok(PerFileResult {
already_present: true,
});
}
append_hook(&mut root, path, command)?;
if !dry_run {
write_json_atomically(path, &root)?;
}
Ok(PerFileResult {
already_present: false,
})
}
fn read_agent_config(path: &Path) -> Result<Value, InitError> {
match fs::read_to_string(path) {
Ok(s) if s.trim().is_empty() => {
Ok(default_agent_skeleton(stem_or(path, DEFAULT_AGENT_NAME)))
},
Ok(s) => serde_json::from_str(&s).map_err(|e| InitError::Json {
path: path.to_path_buf(),
message: e.to_string(),
}),
Err(e) if e.kind() == ErrorKind::NotFound => {
Ok(default_agent_skeleton(stem_or(path, DEFAULT_AGENT_NAME)))
},
Err(e) => Err(InitError::Io {
path: path.to_path_buf(),
source: e,
}),
}
}
fn stem_or<'a>(path: &'a Path, fallback: &'a str) -> &'a str {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(fallback)
}
fn default_agent_skeleton(name: &str) -> Value {
json!({
"name": name,
"description": "Kiro CLI agent guarded by ptuf PreToolUse policy.",
"tools": ["*"],
"includeMcpJson": true,
})
}
pub(crate) fn pre_tool_use_commands(root: &Value) -> Vec<String> {
let Some(arr) = root.pointer("/hooks/preToolUse").and_then(Value::as_array) else {
return Vec::new();
};
let mut commands = Vec::new();
for entry in arr {
commands.extend(entry_commands(entry));
}
commands
}
pub(crate) fn command_invokes_ptuf_hook(cmd: &str) -> bool {
super::command_invokes_ptuf_hook(cmd, COMMAND_TAIL)
}
pub(crate) fn entry_commands(entry: &Value) -> Vec<String> {
entry
.get("command")
.and_then(Value::as_str)
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
fn has_existing_hook(root: &Value) -> bool {
root.pointer("/hooks/preToolUse")
.and_then(Value::as_array)
.into_iter()
.flatten()
.flat_map(entry_commands)
.any(|cmd| command_invokes_ptuf_hook(&cmd))
}
fn append_hook(root: &mut Value, agent_path: &Path, command: &str) -> Result<(), InitError> {
let Some(map) = root.as_object_mut() else {
return Err(InitError::Schema {
path: agent_path.to_path_buf(),
message: "top-level value must be a JSON object".into(),
});
};
let hooks = ensure_object(map, "hooks").ok_or_else(|| InitError::Schema {
path: agent_path.to_path_buf(),
message: "`hooks` must be an object".into(),
})?;
let pre_tool_use = ensure_array(hooks, "preToolUse").ok_or_else(|| InitError::Schema {
path: agent_path.to_path_buf(),
message: "`hooks.preToolUse` must be an array".into(),
})?;
pre_tool_use.push(json!({
"matcher": DEFAULT_MATCHER,
"command": command,
"timeout_ms": DEFAULT_TIMEOUT_MS,
"cache_ttl_seconds": DEFAULT_CACHE_TTL_SECONDS,
}));
Ok(())
}
fn ensure_object<'a>(
map: &'a mut Map<String, Value>,
key: &str,
) -> Option<&'a mut Map<String, Value>> {
map.entry(key.to_string())
.or_insert_with(|| json!({}))
.as_object_mut()
}
fn ensure_array<'a>(map: &'a mut Map<String, Value>, key: &str) -> Option<&'a mut Vec<Value>> {
map.entry(key.to_string())
.or_insert_with(|| json!([]))
.as_array_mut()
}
fn write_json_atomically(path: &Path, value: &Value) -> Result<(), InitError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|e| InitError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let mut body = serde_json::to_string_pretty(value).map_err(|e| InitError::Schema {
path: path.to_path_buf(),
message: e.to_string(),
})?;
body.push('\n');
let tmp = sibling_temp_path(path);
crate::init::write_secure(&tmp, body.as_bytes()).map_err(|e| InitError::Io {
path: tmp.clone(),
source: e,
})?;
fs::rename(&tmp, path).map_err(|e| InitError::Io {
path: path.to_path_buf(),
source: e,
})
}
fn sibling_temp_path(path: &Path) -> PathBuf {
super::sibling_install_tmp_path(path, "agent.json")
}
#[cfg(test)]
mod tests {
use super::*;
fn workdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ptuf-init-kiro-{}-{}-{}",
tag,
std::process::id(),
line!()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
fn single_target(path: PathBuf) -> TargetPaths {
TargetPaths {
agent_config_paths: vec![ResolvedAgent {
scope: Scope::Workspace,
path,
}],
skipped_non_json: Vec::new(),
default_agent_names: Vec::new(),
}
}
fn install_and_extras(
targets: &TargetPaths,
bin: &str,
dry_run: bool,
) -> (InstallOutcome, KiroInstallExtras) {
install_with_report(targets, bin, dry_run).unwrap()
}
#[test]
fn detect_binary_delegates_to_shared_impl() {
assert!(!detect_binary().is_empty());
}
#[test]
fn install_creates_new_local_config_with_default_template() {
let dir = workdir("install-local");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
let targets = single_target(path.clone());
let outcome = install(&targets, "/usr/local/bin/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
let body = read(&path);
assert!(body.contains("\"name\": \"ptuf-guarded\""));
assert!(body.contains("\"includeMcpJson\": true"));
assert!(body.contains("/usr/local/bin/ptuf hook kiro"));
assert!(body.contains("\"timeout_ms\": 10000"));
assert!(body.contains("\"cache_ttl_seconds\": 0"));
assert!(body.contains("\"matcher\": \"*\""));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_is_idempotent_when_ptuf_hook_already_present() {
let dir = workdir("idempotent");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let preset = json!({
"name": "ptuf-guarded",
"tools": ["*"],
"hooks": {
"preToolUse": [{
"matcher": "*",
"command": "/x/ptuf hook kiro",
"timeout_ms": 10_000,
"cache_ttl_seconds": 0,
}],
},
});
fs::write(&path, serde_json::to_string_pretty(&preset).unwrap()).unwrap();
let before = read(&path);
let targets = single_target(path.clone());
let (outcome, extras) = install_and_extras(&targets, "/y/ptuf", false);
assert_eq!(outcome.status, InstallStatus::AlreadyPresent);
assert_eq!(before, read(&path));
assert_eq!(extras.already_present_count, 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_appends_when_unrelated_entry_exists() {
let dir = workdir("append");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let preset = json!({
"name": "ptuf-guarded",
"hooks": {
"preToolUse": [{
"matcher": "*",
"command": "/usr/bin/something-else",
"timeout_ms": 5000,
}],
},
});
fs::write(&path, serde_json::to_string_pretty(&preset).unwrap()).unwrap();
let targets = single_target(path.clone());
let outcome = install(&targets, "/x/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
let after: Value = serde_json::from_str(&read(&path)).unwrap();
let arr = after
.pointer("/hooks/preToolUse")
.and_then(Value::as_array)
.unwrap();
assert_eq!(arr.len(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_preserves_unknown_top_level_keys() {
let dir = workdir("preserve-keys");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let preset = json!({
"name": "ptuf-guarded",
"model": "claude-sonnet-4-6",
"temperature": 0.2,
"extras": { "deep": { "value": 42 } },
});
fs::write(&path, serde_json::to_string_pretty(&preset).unwrap()).unwrap();
let targets = single_target(path.clone());
install(&targets, "/x/ptuf", false).unwrap();
let after: Value = serde_json::from_str(&read(&path)).unwrap();
assert_eq!(
after.get("model").and_then(Value::as_str),
Some("claude-sonnet-4-6")
);
assert_eq!(
after.pointer("/extras/deep/value").and_then(Value::as_i64),
Some(42)
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_dry_run_returns_would_install_without_creating_file() {
let dir = workdir("dry-run");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
let targets = single_target(path.clone());
let outcome = install(&targets, "/usr/local/bin/ptuf", true).unwrap();
assert_eq!(outcome.status, InstallStatus::WouldInstall);
assert!(!path.exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_returns_init_error_json_for_invalid_file() {
let dir = workdir("bad-json");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let before = "{not json";
fs::write(&path, before).unwrap();
let targets = single_target(path.clone());
let err = install(&targets, "/x/ptuf", false).unwrap_err();
assert!(matches!(err, InitError::Json { .. }));
let after = fs::read_to_string(&path).unwrap();
assert_eq!(after, before, "agent config was modified despite Err");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_rejects_when_top_level_is_not_object() {
let dir = workdir("non-object");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "[]").unwrap();
let targets = single_target(path.clone());
let err = install(&targets, "/x/ptuf", false).unwrap_err();
assert!(matches!(err, InitError::Schema { .. }));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_rejects_when_hooks_is_wrong_type() {
let dir = workdir("hooks-wrong-type");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, r#"{"name": "x", "hooks": 42}"#).unwrap();
let targets = single_target(path.clone());
let err = install(&targets, "/x/ptuf", false).unwrap_err();
match err {
InitError::Schema { message, .. } => {
assert!(message.contains("hooks"), "got: {message}");
},
other => panic!("unexpected: {other:?}"),
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_rejects_when_pre_tool_use_is_wrong_type() {
let dir = workdir("pretool-wrong-type");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, r#"{"name": "x", "hooks": {"preToolUse": "nope"}}"#).unwrap();
let targets = single_target(path.clone());
let err = install(&targets, "/x/ptuf", false).unwrap_err();
match err {
InitError::Schema { message, .. } => {
assert!(message.contains("preToolUse"), "got: {message}");
},
other => panic!("unexpected: {other:?}"),
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn empty_file_is_treated_as_default_skeleton() {
let dir = workdir("empty");
let path = dir.join(".kiro/agents/ptuf-guarded.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "").unwrap();
let targets = single_target(path.clone());
let outcome = install(&targets, "/x/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
let after: Value = serde_json::from_str(&read(&path)).unwrap();
assert_eq!(
after.get("name").and_then(Value::as_str),
Some("ptuf-guarded")
);
assert!(after.pointer("/hooks/preToolUse").is_some());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_reports_io_error_when_path_is_a_directory() {
let dir = workdir("path-is-dir");
let path = dir.join("agent-as-dir");
fs::create_dir_all(&path).unwrap();
let targets = single_target(path);
let err = install(&targets, "/x/ptuf", false).unwrap_err();
assert!(matches!(err, InitError::Io { .. }));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn command_invokes_ptuf_hook_matches_trailing_tokens() {
assert!(command_invokes_ptuf_hook("/x/ptuf hook kiro"));
assert!(command_invokes_ptuf_hook("ptuf hook kiro "));
assert!(!command_invokes_ptuf_hook("ptuf hook codex"));
assert!(!command_invokes_ptuf_hook("ptuf"));
}
#[test]
fn sibling_temp_path_uses_default_filename_when_input_has_none() {
let p = Path::new("/");
let tmp = sibling_temp_path(p);
assert!(
tmp.to_string_lossy().contains("agent.json.ptuf."),
"missing file_name must default to agent.json: {tmp:?}"
);
}
#[test]
fn write_json_atomically_propagates_dir_creation_error() {
let dir = workdir("write-json-dir-fail");
let blocker = dir.join("blocker");
fs::write(&blocker, "not-a-dir").expect("write blocker");
let target = blocker.join("nested").join("target.json");
let err = write_json_atomically(&target, &json!({"x": 1}))
.expect_err("must fail when parent can't be created");
assert!(
matches!(err, InitError::Io { .. }),
"expected Io, got {err:?}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_json_atomically_propagates_write_error_when_tmp_is_a_directory() {
let dir = workdir("write-tmp-blocked");
let target = dir.join("ptuf-guarded.json");
let collision = dir.join(format!("ptuf-guarded.json.ptuf.{}.tmp", std::process::id()));
fs::create_dir_all(&collision).unwrap();
let targets = single_target(target);
let err = install(&targets, "/x/ptuf", false).unwrap_err();
assert!(
matches!(err, InitError::Io { .. }),
"expected Io, got {err:?}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_json_atomically_propagates_rename_error_when_target_is_a_directory() {
let dir = workdir("kiro-write-json-rename-dir");
let target = dir.join("ptuf-guarded.json");
fs::create_dir_all(&target).unwrap();
let err = write_json_atomically(&target, &json!({"x": 1}))
.expect_err("rename onto dir must fail");
assert!(matches!(err, InitError::Io { .. }), "got {err:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn resolve_paths_falls_back_to_home_when_outside_git_tree() {
let opts = KiroInitOptions::default();
let result = resolve_paths_with(
Some(Path::new("/nonexistent-definitely-not-git")),
None,
&opts,
);
match result {
Ok(t) => {
assert!(!t.agent_config_paths.is_empty());
assert!(
t.agent_config_paths
.iter()
.all(|a| a.path.to_string_lossy().contains(".kiro/agents")),
"expected kiro paths, got {:?}",
t.agent_config_paths
);
},
Err(InitError::RepoRootNotFound) => {},
Err(e) => panic!("unexpected error: {e}"),
}
}
#[test]
fn pre_tool_use_commands_extracts_all_commands() {
let root = json!({
"hooks": {
"preToolUse": [
{ "command": "/x/ptuf hook kiro" },
{ "command": "/y/ptuf hook kiro" },
]
}
});
let cmds = pre_tool_use_commands(&root);
assert_eq!(cmds, vec!["/x/ptuf hook kiro", "/y/ptuf hook kiro"]);
}
#[test]
fn pre_tool_use_commands_returns_empty_when_key_missing() {
assert!(pre_tool_use_commands(&json!({})).is_empty());
assert!(pre_tool_use_commands(&json!({ "hooks": {} })).is_empty());
assert!(
pre_tool_use_commands(&json!({ "hooks": { "preToolUse": "not-array" } })).is_empty()
);
}
#[cfg(unix)]
#[test]
fn install_writes_agent_config_with_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = workdir("perm-kiro");
fs::create_dir_all(dir.join(".git")).unwrap();
fs::create_dir_all(dir.join(".kiro/agents")).unwrap();
let opts = KiroInitOptions {
mode: KiroMode::NewAgent,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), None, &opts).unwrap();
let outcome = install(&targets, "/usr/local/bin/ptuf", false).unwrap();
assert!(matches!(outcome.status, InstallStatus::Installed));
let written_path = &targets.agent_config_paths[0].path;
let mode = fs::metadata(written_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "fresh agent config must be owner-only");
let _ = fs::remove_dir_all(&dir);
}
fn workspace_fixture(tag: &str) -> PathBuf {
let dir = workdir(tag);
fs::create_dir_all(dir.join(".git")).unwrap();
fs::create_dir_all(dir.join(".kiro/agents")).unwrap();
fs::create_dir_all(dir.join(".kiro/settings")).unwrap();
dir
}
fn write_minimal_agent(path: &Path, name: &str) {
let body = json!({"name": name, "tools": ["*"]});
fs::write(path, serde_json::to_string_pretty(&body).unwrap()).unwrap();
}
#[test]
fn patch_existing_patches_every_json_in_workspace_agents_dir() {
let dir = workspace_fixture("patch-existing");
let a = dir.join(".kiro/agents/alpha.json");
let b = dir.join(".kiro/agents/beta.json");
write_minimal_agent(&a, "alpha");
write_minimal_agent(&b, "beta");
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), None, &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 2);
let outcome = install(&targets, "/x/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
for agent_path in [&a, &b] {
let parsed: Value = serde_json::from_str(&read(agent_path)).unwrap();
let arr = parsed
.pointer("/hooks/preToolUse")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("missing preToolUse in {agent_path:?}"));
assert_eq!(arr.len(), 1, "{agent_path:?}");
assert_eq!(
arr[0].get("command").and_then(Value::as_str),
Some("/x/ptuf hook kiro"),
);
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn patch_existing_skips_md_agents() {
let dir = workspace_fixture("skip-md");
let json_path = dir.join(".kiro/agents/default.json");
let md_path = dir.join(".kiro/agents/default.md");
write_minimal_agent(&json_path, "default");
fs::write(&md_path, "# system prompt").unwrap();
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), None, &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 1);
assert_eq!(targets.skipped_non_json, vec![md_path.clone()]);
let (_outcome, extras) = install_and_extras(&targets, "/x/ptuf", false);
assert_eq!(extras.skipped_non_json_agents, vec![md_path.clone()]);
assert_eq!(fs::read_to_string(&md_path).unwrap(), "# system prompt");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn patch_existing_fails_when_default_agent_missing() {
let dir = workspace_fixture("default-missing");
fs::write(
dir.join(".kiro/settings/cli.json"),
r#"{"chat.defaultAgent":"architect"}"#,
)
.unwrap();
write_minimal_agent(&dir.join(".kiro/agents/other.json"), "other");
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::WorkspaceOnly,
};
let err = resolve_paths_with(Some(dir.as_path()), None, &opts).unwrap_err();
match err {
InitError::Schema { path, message } => {
assert!(path.ends_with("cli.json"), "got: {path:?}");
assert!(message.contains("chat.defaultAgent"), "got: {message}");
assert!(message.contains("architect"), "got: {message}");
},
other => panic!("expected Schema, got: {other:?}"),
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn patch_existing_creates_default_json_when_empty() {
let dir = workspace_fixture("synth-default");
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), None, &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 1);
assert!(
targets.agent_config_paths[0]
.path
.ends_with(".kiro/agents/default.json")
);
let outcome = install(&targets, "/x/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
let path = &targets.agent_config_paths[0].path;
let parsed: Value = serde_json::from_str(&read(path)).unwrap();
assert_eq!(parsed.get("name").and_then(Value::as_str), Some("default"));
assert!(parsed.pointer("/hooks/preToolUse").is_some());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn patch_existing_marks_default_agent_covered_when_present() {
let dir = workspace_fixture("default-covered");
write_minimal_agent(&dir.join(".kiro/agents/architect.json"), "architect");
fs::write(
dir.join(".kiro/settings/cli.json"),
r#"{"chat.defaultAgent":"architect"}"#,
)
.unwrap();
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), None, &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 1);
let (_outcome, extras) = install_and_extras(&targets, "/x/ptuf", false);
assert_eq!(extras.default_agents.len(), 1);
assert_eq!(extras.default_agents[0].agent_name, "architect");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn workspace_only_scope_skips_home() {
let dir = workspace_fixture("workspace-only");
write_minimal_agent(&dir.join(".kiro/agents/alpha.json"), "alpha");
let home = workdir("workspace-only-home");
fs::create_dir_all(home.join(".kiro/agents")).unwrap();
write_minimal_agent(&home.join(".kiro/agents/beta.json"), "beta");
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), Some(home.as_path()), &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 1);
assert!(targets.agent_config_paths[0].path.starts_with(&dir));
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&home);
}
#[test]
fn global_only_scope_skips_workspace() {
let dir = workspace_fixture("global-only-ws");
write_minimal_agent(&dir.join(".kiro/agents/alpha.json"), "alpha");
let home = workdir("global-only-home");
fs::create_dir_all(home.join(".kiro/agents")).unwrap();
write_minimal_agent(&home.join(".kiro/agents/beta.json"), "beta");
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::GlobalOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), Some(home.as_path()), &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 1);
assert!(targets.agent_config_paths[0].path.starts_with(&home));
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&home);
}
#[test]
fn patch_existing_covers_workspace_and_home() {
let dir = workspace_fixture("ws-and-home");
write_minimal_agent(&dir.join(".kiro/agents/alpha.json"), "alpha");
let home = workdir("ws-and-home-home");
fs::create_dir_all(home.join(".kiro/agents")).unwrap();
write_minimal_agent(&home.join(".kiro/agents/beta.json"), "beta");
let opts = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::Both,
};
let targets = resolve_paths_with(Some(dir.as_path()), Some(home.as_path()), &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 2);
let outcome = install(&targets, "/x/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
for p in [
dir.join(".kiro/agents/alpha.json"),
home.join(".kiro/agents/beta.json"),
] {
let parsed: Value = serde_json::from_str(&read(&p)).unwrap();
assert!(
parsed
.pointer("/hooks/preToolUse")
.and_then(Value::as_array)
.is_some_and(|a| a.len() == 1),
"missing patched hook in {p:?}",
);
}
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&home);
}
#[test]
fn new_agent_mode_keeps_legacy_filename() {
let dir = workspace_fixture("new-agent-legacy");
let opts = KiroInitOptions {
mode: KiroMode::NewAgent,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), None, &opts).unwrap();
assert_eq!(targets.agent_config_paths.len(), 1);
assert!(
targets.agent_config_paths[0]
.path
.ends_with(".kiro/agents/ptuf-guarded.json")
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn existing_ptuf_guarded_json_is_idempotent_on_repatch() {
let dir = workspace_fixture("repatch-legacy");
let opts_legacy = KiroInitOptions {
mode: KiroMode::NewAgent,
scope: ScopeFilter::WorkspaceOnly,
};
let targets = resolve_paths_with(Some(dir.as_path()), None, &opts_legacy).unwrap();
install(&targets, "/x/ptuf", false).unwrap();
let opts_default = KiroInitOptions {
mode: KiroMode::PatchExisting,
scope: ScopeFilter::WorkspaceOnly,
};
let targets2 = resolve_paths_with(Some(dir.as_path()), None, &opts_default).unwrap();
let (outcome, extras) = install_and_extras(&targets2, "/x/ptuf", false);
assert_eq!(outcome.status, InstallStatus::AlreadyPresent);
assert_eq!(extras.already_present_count, 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_default_agent_returns_none_when_settings_missing() {
let dir = workdir("default-no-settings");
let opt = read_default_agent(&dir.join("settings")).unwrap();
assert!(opt.is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_default_agent_returns_none_when_key_absent() {
let dir = workdir("default-key-absent");
let settings_dir = dir.join("settings");
fs::create_dir_all(&settings_dir).unwrap();
fs::write(settings_dir.join("cli.json"), r#"{"other":"value"}"#).unwrap();
assert!(read_default_agent(&settings_dir).unwrap().is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_default_agent_returns_value_when_set() {
let dir = workdir("default-set");
let settings_dir = dir.join("settings");
fs::create_dir_all(&settings_dir).unwrap();
fs::write(
settings_dir.join("cli.json"),
r#"{"chat.defaultAgent":"architect","other":42}"#,
)
.unwrap();
assert_eq!(
read_default_agent(&settings_dir).unwrap().as_deref(),
Some("architect")
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_default_agent_rejects_invalid_json() {
let dir = workdir("default-bad-json");
let settings_dir = dir.join("settings");
fs::create_dir_all(&settings_dir).unwrap();
fs::write(settings_dir.join("cli.json"), "{not json").unwrap();
let err = read_default_agent(&settings_dir).unwrap_err();
assert!(matches!(err, InitError::Json { .. }));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn enumerate_agents_returns_sorted_split() {
let dir = workdir("enum-agents");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("b.json"), "{}").unwrap();
fs::write(dir.join("a.json"), "{}").unwrap();
fs::write(dir.join("c.md"), "# x").unwrap();
let (mut jsons, mut others) = enumerate_agents(&dir).unwrap();
jsons.sort();
others.sort();
assert!(jsons[0].ends_with("a.json"));
assert!(jsons[1].ends_with("b.json"));
assert_eq!(others.len(), 1);
assert!(others[0].ends_with("c.md"));
let _ = fs::remove_dir_all(&dir);
}
}