use std::path::PathBuf;
use std::sync::Arc;
use agent_contracts::{LoopEventSink, SkillRegistry};
use clap::Parser;
use operation_backend::process_group::ProcessGroupCleanupGuard;
use serde_json::Value;
use skill::audit::{audit_skill_directory, SkillAuditOptions};
use skill::registry::FileSkillRegistry;
use skill::types::config::SkillsConfig;
use xiaoo_app::cli::config::FileConfig;
use xiaoo_app::cli::{
build_compression_pipeline, build_llm_provider, resolve_effective_context_window, CliConfig,
CliEventSink,
};
use xiaoo_app::gateway::{
session_record::SubagentRoleRecord, AppBootstrap, AppTurnRequest, GatewayEntryContext,
HostedSessionRuntimeConfig, HostedSessionRuntimeResolver, InMemorySessionStore,
SessionRuntimeBindings, SessionRuntimeDescriptor, SessionRuntimeResolver, SessionStore,
};
use agent_types::common::ids::AgentId;
use agent_types::context::{FeatureFlags, TokenBudgetConfig};
use agent_types::hook::{HookerDefaultMode, HookerRegistryConfig};
use agent_types::ReasoningEffort;
const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../prompts/cli_default_system_prompt.txt");
#[derive(Parser)]
#[command(name = "xiaoo", about = "XiaoO AgentLoop CLI")]
struct Args {
#[arg(long, global = true)]
config: Option<String>,
#[arg(long, global = true)]
debug: bool,
#[command(subcommand)]
command: Command,
}
#[derive(clap::Subcommand)]
enum Command {
Run {
#[arg(short, long)]
prompt: String,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
api_base: Option<String>,
#[arg(
long,
default_value_t = DEFAULT_SYSTEM_PROMPT.trim_end_matches(['\r', '\n']).to_string()
)]
system: String,
#[arg(long, default_value_t = 10)]
max_turns: u32,
#[arg(long)]
no_tools: bool,
#[arg(long, value_delimiter = ',')]
tools: Option<Vec<String>>,
#[arg(long, value_parser = clap::value_parser!(ReasoningEffort))]
reasoning_effort: Option<ReasoningEffort>,
},
Skill {
#[command(subcommand)]
command: SkillCommands,
},
}
#[derive(clap::Subcommand)]
enum SkillCommands {
List,
Show { name: String },
Audit { path: String },
Install { source: String },
Remove { name: String },
}
#[tokio::main]
async fn main() {
let _cleanup_guard = ProcessGroupCleanupGuard;
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let args = Args::parse();
let debug = args.debug;
let config_path = FileConfig::resolve_path(args.config.as_deref());
match args.command {
Command::Run {
prompt,
provider,
model,
api_key,
api_base,
system,
max_turns,
no_tools,
tools,
reasoning_effort,
} => {
if let Some(path) = config_path.as_ref() {
if let Err(error) = xiaoo_app::llm_secrets::init_on_demand_secret_provider(path) {
eprintln!(
"Failed to initialize LLM secrets from {}: {}",
path.display(),
error
);
std::process::exit(1);
}
}
let file_cfg = config_path
.as_ref()
.map(|path| FileConfig::load_from_path(path, debug))
.unwrap_or_default();
let llm = file_cfg.llm.as_ref();
let provider = provider
.or_else(|| llm.and_then(|l| l.provider.clone()))
.unwrap_or_else(|| "anthropic".into());
let model = model
.or_else(|| llm.and_then(|l| l.model.clone()))
.unwrap_or_else(|| "claude-sonnet-4-20250514".into());
let api_key = api_key.or_else(|| {
llm.and_then(|l| l.api_key_env.clone())
.and_then(|env_name| {
xiaoo_app::gateway::get_decrypted_api_key(env_name.as_str())
})
});
let api_key_env = llm.and_then(|l| l.api_key_env.clone());
let api_base = api_base.or_else(|| llm.and_then(|l| l.api_base.clone()));
let reasoning_effort = reasoning_effort.unwrap_or_default();
let skills_config = resolve_skills_config_from_file(&file_cfg);
let config = CliConfig {
provider,
model,
api_key,
api_key_env,
api_base,
trace: file_cfg
.trace
.clone()
.unwrap_or_else(|| Value::Object(serde_json::Map::new())),
system_prompt: system,
max_turns,
enable_tools: !no_tools,
visible_tools: tools.filter(|t| !t.is_empty()),
reasoning_effort,
kvcache_enabled: llm.and_then(|l| l.kvcache_enabled).unwrap_or(false),
kvcache_debug_enabled: llm.and_then(|l| l.kvcache_debug_enabled).unwrap_or(false),
compact: file_cfg.compact.unwrap_or_default(),
hooker: file_cfg.hooker.clone().unwrap_or(HookerRegistryConfig {
default: HookerDefaultMode::None,
..HookerRegistryConfig::default()
}),
operation_backend: None,
skills_config,
subagent: file_cfg.subagent.clone(),
};
run_once(config, prompt, debug).await;
}
Command::Skill { command } => {
handle_skill_command(command);
}
}
}
fn resolve_skills_config_from_file(file_cfg: &FileConfig) -> skill::SkillsConfig {
let mut skills_dirs = Vec::new();
skills_dirs.push(PathBuf::from(".xiaoo/skills"));
if let Some(skills_section) = file_cfg.skills.as_ref() {
if let Some(extra_dirs) = skills_section.dirs.as_ref() {
for dir in extra_dirs {
let path = PathBuf::from(dir);
let dir_str = path.to_string_lossy();
if dir_str != ".xiaoo/skills"
&& !dir_str.ends_with("/.xiaoo/skills")
&& !dir_str.ends_with("\\.xiaoo\\skills")
&& dir_str != "/usr/lib/.xiaoo/skills"
{
skills_dirs.push(path);
}
}
}
}
if let Some(home) = dirs::home_dir() {
skills_dirs.push(home.join(".xiaoo").join("skills"));
}
skills_dirs.push(PathBuf::from("/usr/lib/.xiaoo/skills"));
skill::SkillsConfig {
skills_dirs,
allow_scripts: file_cfg
.skills
.as_ref()
.and_then(|s| s.allow_scripts)
.unwrap_or(false),
..skill::SkillsConfig::default()
}
}
fn resolve_all_skills_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
dirs.push(PathBuf::from(".xiaoo/skills"));
let file_cfg = FileConfig::load(None, false);
if let Some(skills) = file_cfg.skills.as_ref() {
if let Some(extra_dirs) = skills.dirs.as_ref() {
for dir in extra_dirs {
let path = PathBuf::from(dir);
let dir_str = path.to_string_lossy();
if dir_str != ".xiaoo/skills"
&& !dir_str.ends_with("/.xiaoo/skills")
&& !dir_str.ends_with("\\.xiaoo\\skills")
&& dir_str != "/usr/lib/.xiaoo/skills"
{
dirs.push(path);
}
}
}
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".xiaoo").join("skills"));
}
dirs.push(PathBuf::from("/usr/lib/.xiaoo/skills"));
dirs
}
fn build_skills_config() -> SkillsConfig {
let skills_dirs = resolve_all_skills_dirs();
let file_cfg = FileConfig::load(None, false);
let allow_scripts = file_cfg
.skills
.as_ref()
.and_then(|s| s.allow_scripts)
.unwrap_or(false);
SkillsConfig {
skills_dirs,
allow_scripts,
..SkillsConfig::default()
}
}
fn project_skills_dir() -> PathBuf {
PathBuf::from(".xiaoo/skills")
}
fn user_skills_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".xiaoo").join("skills"))
}
fn system_skills_dir() -> PathBuf {
PathBuf::from("/usr/lib/.xiaoo/skills")
}
fn default_skills_config() -> SkillsConfig {
build_skills_config()
}
fn handle_skill_command(command: SkillCommands) {
match command {
SkillCommands::List => {
let registry = FileSkillRegistry::new(&default_skills_config());
let skills = registry.list_skills();
if skills.is_empty() {
println!("No skills installed.");
let dirs = resolve_all_skills_dirs();
for d in &dirs {
println!(" Skills directory: {}", d.display());
}
return;
}
println!("{:<20} {}", "NAME", "DESCRIPTION");
println!("{:<20} {}", "----", "-----------");
for s in &skills {
println!("{:<20} {}", s.skill_id, s.description);
}
println!("\n{} skill(s) found.", skills.len());
}
SkillCommands::Show { name } => {
let registry = FileSkillRegistry::new(&default_skills_config());
match registry.get_skill(&name) {
Some(spec) => {
println!("Skill: {}", spec.skill_id());
println!("Description: {}", spec.description());
if !spec.arguments().is_empty() {
println!("Arguments: {}", spec.arguments().join(", "));
}
if let Some(hint) = spec.argument_hint() {
println!("Argument hint: {}", hint);
}
println!("Context: {:?}", spec.context());
println!("User invocable: {}", spec.user_invocable());
if let Some(loc) = spec.location() {
println!("Location: {}", loc.display());
}
println!("\n--- Prompt ---\n{}", spec.full_prompt());
}
None => {
eprintln!("Skill '{}' not found.", name);
std::process::exit(1);
}
}
}
SkillCommands::Audit { path } => {
let dir = PathBuf::from(&path);
if !dir.is_dir() {
eprintln!("Not a directory: {}", path);
std::process::exit(1);
}
let report = audit_skill_directory(&dir, &SkillAuditOptions::default());
println!("Audited: {}", dir.display());
println!("Files scanned: {}", report.files_scanned);
if report.is_clean() {
println!("Result: CLEAN");
} else {
println!("Result: {} issue(s) found:", report.findings.len());
for (i, f) in report.findings.iter().enumerate() {
println!(" {}. {}", i + 1, f);
}
std::process::exit(1);
}
}
SkillCommands::Install { source } => {
let is_git = source.ends_with(".git")
|| source.starts_with("https://")
|| source.starts_with("http://")
|| source.starts_with("git@")
|| source.starts_with("file://");
let skill_name = if is_git {
extract_repo_name(&source)
} else {
let p = PathBuf::from(&source);
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.strip_suffix(".git")
.unwrap_or(p.file_name().and_then(|n| n.to_str()).unwrap_or("unknown"))
.to_string()
};
if skill_name.contains("..") || skill_name.contains('/') || skill_name.contains('\\') {
eprintln!("Invalid skill name: {}", skill_name);
std::process::exit(1);
}
// Check all skill directories for existing skill BEFORE cloning
let project_dest = project_skills_dir().join(&skill_name);
let user_dest = user_skills_dir().as_ref().map(|d| d.join(&skill_name));
let system_dest = system_skills_dir().join(&skill_name);
// Check config file directories
let config_dests = {
let file_cfg = FileConfig::load(None, false);
file_cfg
.skills
.as_ref()
.and_then(|s| s.dirs.as_ref())
.map(|dirs| {
dirs.iter()
.map(|d| PathBuf::from(d).join(&skill_name))
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
// Check project level first (highest priority)
if project_dest.exists() {
eprintln!(
"Skill '{}' already installed at {} (project level, highest priority)",
skill_name,
project_dest.display()
);
std::process::exit(1);
}
// Check config file directories (medium priority)
for config_dest in &config_dests {
if config_dest.exists() {
eprintln!(
"Skill '{}' already installed at {} (config directory, medium priority)",
skill_name,
config_dest.display()
);
std::process::exit(1);
}
}
// Check user level
if let Some(ref user_d) = user_dest {
if user_d.exists() {
eprintln!(
"Skill '{}' already installed at {} (user level)",
skill_name,
user_d.display()
);
std::process::exit(1);
}
}
// Check system level (lowest priority, for built-in skills only)
if system_dest.exists() {
eprintln!(
"Skill '{}' already installed at {} (system level, built-in skill)",
skill_name,
system_dest.display()
);
std::process::exit(1);
}
// Now clone/copy the source
let src_dir = if is_git {
let tmp = std::env::temp_dir().join(&skill_name);
let _ = std::fs::remove_dir_all(&tmp);
println!("Cloning {} ...", source);
let status = std::process::Command::new("git")
.args([
"clone",
"--depth",
"1",
&source,
tmp.to_str().unwrap_or("."),
])
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("git clone failed: {}", s);
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to run git: {}", e);
std::process::exit(1);
}
}
let _ = std::fs::remove_dir_all(tmp.join(".git"));
tmp
} else {
let p = PathBuf::from(&source);
if !p.is_dir() {
eprintln!("Not a directory: {}", source);
std::process::exit(1);
}
p
};
// Validate that source directory contains a valid skill (SKILL.md or SKILL.toml)
let has_manifest =
src_dir.join("SKILL.md").exists() || src_dir.join("SKILL.toml").exists();
if !has_manifest {
eprintln!("Error: Source directory is not a valid skill directory.");
eprintln!("A valid skill directory must contain either SKILL.md or SKILL.toml.");
if is_git {
let _ = std::fs::remove_dir_all(&src_dir);
}
std::process::exit(1);
}
// Install to user directory by default
// Users can manually copy to project level or config directories to override
// System level (/usr/lib/.xiaoo/skills) is reserved for built-in skills only
let dest = user_dest.unwrap_or_else(|| project_skills_dir().join(&skill_name));
// Audit is currently disabled by default; use `xiaoo skill audit <path>` for manual checks.
if let Err(e) = copy_dir_recursive(&src_dir, &dest) {
eprintln!("Failed to install: {}", e);
if is_git {
let _ = std::fs::remove_dir_all(&src_dir);
}
std::process::exit(1);
}
if is_git {
let _ = std::fs::remove_dir_all(&src_dir);
}
println!("Installed skill '{}' to {}", skill_name, dest.display());
}
SkillCommands::Remove { name } => {
if name.contains("..") || name.contains('/') || name.contains('\\') {
eprintln!("Invalid skill name: {}", name);
std::process::exit(1);
}
let project_dir = project_skills_dir().join(&name);
let user_dir = user_skills_dir().as_ref().map(|d| d.join(&name));
let system_dir = system_skills_dir().join(&name);
// Get config file directories
let config_dirs = {
let file_cfg = FileConfig::load(None, false);
file_cfg
.skills
.as_ref()
.and_then(|s| s.dirs.as_ref())
.map(|dirs| {
dirs.iter()
.map(|d| PathBuf::from(d).join(&name))
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
// Priority: remove from highest priority level first
// 1. Project level (highest)
if project_dir.is_dir() {
if let Err(e) = std::fs::remove_dir_all(&project_dir) {
eprintln!("Failed to remove from project: {}", e);
std::process::exit(1);
}
println!(
"Removed skill '{}' from {} (project level, highest priority).",
name,
project_dir.display()
);
// Warn if config dirs still exist
for config_dir in &config_dirs {
if config_dir.is_dir() {
eprintln!(
"Warning: Skill '{}' still exists at {} (config directory).",
name,
config_dir.display()
);
}
}
// Warn if user level still exists
if let Some(ref user_d) = user_dir {
if user_d.is_dir() {
eprintln!(
"Warning: Skill '{}' still exists at {} (user level).",
name,
user_d.display()
);
}
}
// Warn if system level still exists
if system_dir.is_dir() {
eprintln!(
"Warning: Skill '{}' still exists at {} (system level, built-in skill).",
name,
system_dir.display()
);
}
return;
}
// 2. Config directories (medium)
for config_dir in &config_dirs {
if config_dir.is_dir() {
if let Err(e) = std::fs::remove_dir_all(config_dir) {
eprintln!("Failed to remove from config directory: {}", e);
std::process::exit(1);
}
println!(
"Removed skill '{}' from {} (config directory).",
name,
config_dir.display()
);
// Warn if other config dirs or user/system still exist
for other_config_dir in &config_dirs {
if other_config_dir != config_dir && other_config_dir.is_dir() {
eprintln!(
"Warning: Skill '{}' still exists at {} (other config directory).",
name,
other_config_dir.display()
);
}
}
if let Some(ref user_d) = user_dir {
if user_d.is_dir() {
eprintln!(
"Warning: Skill '{}' still exists at {} (user level).",
name,
user_d.display()
);
}
}
if system_dir.is_dir() {
eprintln!(
"Warning: Skill '{}' still exists at {} (system level, built-in skill).",
name,
system_dir.display()
);
}
return;
}
}
// 3. User level
if let Some(ref user_d) = user_dir {
if user_d.is_dir() {
if let Err(e) = std::fs::remove_dir_all(user_d) {
eprintln!("Failed to remove from user directory: {}", e);
std::process::exit(1);
}
println!(
"Removed skill '{}' from {} (user level).",
name,
user_d.display()
);
// Warn if system level still exists
if system_dir.is_dir() {
eprintln!(
"Warning: Skill '{}' still exists at {} (system level, built-in skill).",
name,
system_dir.display()
);
}
return;
}
}
// 4. System level (built-in skills only - requires root privileges to remove)
if system_dir.is_dir() {
eprintln!(
"Skill '{}' is a built-in skill at {} (system level).",
name,
system_dir.display()
);
eprintln!("Built-in skills require root privileges to remove.");
eprintln!("To remove: sudo rm -rf {}", system_dir.display());
std::process::exit(1);
}
// Skill not found anywhere
eprintln!("Skill '{}' not found in any skills directory.", name);
eprintln!("Checked directories:");
eprintln!(
" - {} (project level, highest priority)",
project_dir.display()
);
for config_dir in &config_dirs {
eprintln!(" - {} (config directory)", config_dir.display());
}
if let Some(ref user_d) = user_dir {
eprintln!(" - {} (user level)", user_d.display());
}
eprintln!(
" - {} (system level, built-in skills)",
system_dir.display()
);
std::process::exit(1);
}
}
}
fn extract_repo_name(url: &str) -> String {
let name = url.trim_end_matches('/').rsplit('/').next().unwrap_or(url);
let name = name.rsplit(':').next().unwrap_or(name);
let name = name.rsplit('/').next().unwrap_or(name);
let name = name.strip_suffix(".git").unwrap_or(name);
if name.is_empty() {
format!("skill-{}", std::process::id())
} else {
name.to_string()
}
}
fn copy_dir_recursive(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
reject_nested_copy(src, dest)?;
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let dst = dest.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&entry.path(), &dst)?;
} else {
std::fs::copy(entry.path(), dst)?;
}
}
Ok(())
}
fn reject_nested_copy(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
let src = src.canonicalize()?;
let dest_parent = dest.parent().unwrap_or_else(|| std::path::Path::new("."));
let dest_name = dest.file_name().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "destination has no name")
})?;
let dest_abs = dest_parent
.canonicalize()
.unwrap_or_else(|_| dest_parent.to_path_buf())
.join(dest_name);
if dest_abs.starts_with(&src) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"destination must not be inside source directory",
));
}
Ok(())
}
async fn run_once(config: CliConfig, prompt: String, debug: bool) {
if debug {
eprintln!(
"[config] provider={}, model={}, max_turns={}",
config.provider, config.model, config.max_turns
);
}
// 1. LLM provider (shared with compression pipeline)
let llm_provider = match build_llm_provider(&config, Some("defaultagent".into())) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to create LLM provider: {}", e);
std::process::exit(1);
}
};
// 2. Compression pipeline
let compression_pipeline = match build_compression_pipeline(&config, &llm_provider) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to build compression pipeline: {}", e);
std::process::exit(1);
}
};
// 3. Session runtime config
let total_budget = resolve_effective_context_window(&config, &llm_provider).await;
let reserved_for_output = total_budget / 10;
let reserved_for_system = total_budget / 20;
let runtime_config = HostedSessionRuntimeConfig {
descriptor: SessionRuntimeDescriptor {
agent_id: AgentId("defaultagent".into()),
model: config.model.clone(),
system_prompt: config.system_prompt.clone(),
feature_flags: FeatureFlags {
tool_execution: config.enable_tools,
kvcache_enabled: config.kvcache_enabled,
kvcache_debug_enabled: config.kvcache_debug_enabled,
..FeatureFlags::default()
},
token_budget: TokenBudgetConfig {
total_budget,
reserved_for_output,
reserved_for_system,
hard_limit_ratio: 0.9,
},
workspace_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
max_turns: Some(config.max_turns),
subagent_roles: config
.subagent
.iter()
.map(|(role_id, cfg)| {
(
role_id.clone(),
SubagentRoleRecord {
role_id: role_id.clone(),
description: cfg.description.clone(),
prompt: cfg.prompt.clone(),
max_turns: cfg.max_turns,
tools: cfg.tools.clone(),
},
)
})
.collect(),
},
trace: config.trace.clone(),
provider: config.provider.clone(),
model: config.model.clone(),
api_key: config.api_key.clone(),
api_key_env: config.api_key_env.clone(),
api_base: config.api_base.clone(),
visible_tool_names: if !config.enable_tools {
Some(Vec::new())
} else {
config.visible_tools.clone()
},
compression_pipeline: Some(compression_pipeline),
llm_provider: Some(llm_provider),
hooker: config.hooker.clone(),
lsp_registry: None,
operation_backend: None,
skills_config: config.skills_config.clone(),
subagent_roles: config
.subagent
.iter()
.map(|(k, v)| {
(
k.clone(),
xiaoo_app::gateway::SubagentRoleConfigEntry {
description: v.description.clone(),
prompt: v.prompt.clone(),
max_turns: v.max_turns,
tools: v.tools.clone(),
},
)
})
.collect(),
};
// 4. Bindings (CliEventSink for debug output)
let loop_event_sink: Option<Arc<dyn LoopEventSink>> =
debug.then(|| Arc::new(CliEventSink::new()) as Arc<dyn LoopEventSink>);
let bindings = SessionRuntimeBindings {
loop_event_sink,
tool_event_sink: None,
interaction_handle: None,
channel_file_sender: None,
pending_user_messages: None,
};
// 5. Bootstrap gateway
let store: Arc<dyn SessionStore> = Arc::new(InMemorySessionStore::default());
let resolver: Arc<dyn SessionRuntimeResolver> =
Arc::new(HostedSessionRuntimeResolver::new(runtime_config, bindings));
let deps = match AppBootstrap::from_session_components_with_hooks(
store,
resolver,
config.hooker.clone(),
) {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to bootstrap session: {}", e);
std::process::exit(1);
}
};
// 6. Turn request
let session_id = uuid::Uuid::new_v4().to_string();
let request = AppTurnRequest {
session_id: session_id.clone(),
entry: GatewayEntryContext::cli(),
channel: None,
message_id: None,
conversation_id: session_id.clone(),
sender_id: "cli-user".into(),
text: prompt,
channel_instance_id: None,
channel_identity_prompt: None,
reply_to_message_id: None,
root_message_id: None,
mentions: Vec::new(),
reasoning_effort: config.reasoning_effort,
};
// 7. Run turn via gateway session service, then explicitly close the
// session so SessionClosed lifecycle hookers fire in CLI mode as well.
let turn_result = deps.session_service.run_turn(request).await;
if let Err(err) = deps
.session_control_plane
.force_close_session(&session_id)
.await
{
eprintln!("[warn] failed to close session: {}", err);
}
match turn_result {
Ok(result) => {
if !result.raw_reply.is_empty() {
println!("{}", result.raw_reply);
}
}
Err(e) => {
eprintln!("[error] {}", e);
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::copy_dir_recursive;
use std::fs;
use tempfile::tempdir;
#[test]
fn copy_dir_rejects_destination_inside_source() {
let temp = tempdir().unwrap();
let src = temp.path().join("skills");
fs::create_dir(&src).unwrap();
fs::write(src.join("SKILL.md"), "test").unwrap();
let err = copy_dir_recursive(&src, &src.join("nested")).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
}