use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;
fn write_stdout(text: &str) -> Result<(), String> {
let mut out = io::stdout();
match out.write_all(text.as_bytes()).and_then(|_| out.flush()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(e.to_string()),
}
}
fn writeln_stdout(text: &str) -> Result<(), String> {
write_stdout(&format!("{text}\n"))
}
use clap::{Parser, Subcommand};
use craftbag::{
DiscoveryOptions, FormatOptions, ListFormat, LoadView, SkillSource, SkillSummary, discover,
find_skill_by_name, format_available_skills_xml, format_catalog, format_list_tsv,
format_load_view, format_skip_tsv, format_watch_dirs, format_why_text, normalize_skill_name,
parse_list_format, present_disabled_vendors, progressive_budgets,
unknown_or_skipped_skill_named, validate_path_with_options, validate_skill_name, watch_dirs,
watch_host_skips, why,
};
#[derive(Parser)]
#[command(
name = "craftbag",
version,
about = "Discover and load Agent Skills (SKILL.md) for CLI and MCP hosts"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(clap::Args, Debug, Clone)]
struct DiscoveryArgs {
#[arg(long = "path", value_name = "PATH")]
paths: Vec<String>,
#[arg(long, value_delimiter = ',')]
vendor: Vec<String>,
#[arg(long = "user-dir")]
user_dir: Option<PathBuf>,
#[arg(long = "ascii-names")]
ascii_names: bool,
#[arg(long = "no-implicit-roots")]
no_implicit_roots: bool,
#[arg(long = "disabled", value_name = "NAME")]
disabled: Vec<String>,
#[arg(long = "ignore", value_name = "PATH")]
ignore: Vec<String>,
}
#[derive(Subcommand)]
enum Cmd {
List {
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
xml: bool,
#[arg(long, conflicts_with_all = ["json", "xml"])]
catalog: bool,
#[arg(long = "watch-dirs", conflicts_with_all = ["json", "xml", "catalog"])]
watch_dirs: bool,
#[arg(long = "format", value_name = "FORMAT", conflicts_with_all = ["json", "xml", "catalog", "watch_dirs"])]
format: Option<String>,
#[command(flatten)]
discovery: DiscoveryArgs,
#[arg(long, allow_hyphen_values = true)]
context: Option<String>,
#[arg(long, default_value_t = 8_000)]
context_tokens: usize,
},
Load {
name: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "section")]
outline: bool,
#[arg(long, value_name = "KEY", conflicts_with = "outline")]
section: Option<String>,
#[arg(long = "args", default_value = "", allow_hyphen_values = true)]
args: String,
#[command(flatten)]
discovery: DiscoveryArgs,
},
Why {
name: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, allow_hyphen_values = true)]
context: Option<String>,
#[arg(long, default_value_t = 8_000)]
context_tokens: usize,
#[command(flatten)]
discovery: DiscoveryArgs,
},
Validate {
path: PathBuf,
#[arg(long)]
strict: bool,
#[arg(long)]
json: bool,
},
New {
name: String,
#[arg(long, value_name = "DIR")]
dir: Option<PathBuf>,
},
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(code) => code,
Err(e) => {
let _ = writeln!(io::stderr(), "{e}");
ExitCode::from(1)
}
}
}
fn run(cli: Cli) -> Result<ExitCode, String> {
match cli.cmd {
Cmd::List {
json,
xml,
catalog,
watch_dirs: watch,
format,
discovery,
context,
context_tokens,
} => {
let mode = list_output_mode(json, xml, catalog, watch, format)?;
let (cwd, opts) = discovery_opts(&discovery)?;
if matches!(mode, ListOutput::Watch) {
write_stdout(&format_watch_dirs(&watch_dirs(&cwd, &opts)))?;
let _ = write!(
io::stderr(),
"{}",
format_skip_tsv(&watch_host_skips(&cwd, &opts))
);
return Ok(ExitCode::SUCCESS);
}
let report = discover(&cwd, &opts);
if matches!(mode, ListOutput::Catalog) {
write_stdout(&format_catalog(
&report.skills,
context.as_deref().unwrap_or(""),
progressive_budgets(context_tokens),
FormatOptions::default(),
))?;
} else if matches!(mode, ListOutput::Xml) {
write_stdout(&format_available_skills_xml(&report.skills))?;
} else if matches!(mode, ListOutput::Json) {
let v = serde_json::json!({
"skills": report.skills.iter().map(SkillSummary::from).collect::<Vec<_>>(),
"skips": report.skips,
});
writeln_stdout(&serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?)?;
} else {
write_stdout(&format_list_tsv(&report.skills))?;
}
if !matches!(mode, ListOutput::Json) {
let _ = write!(io::stderr(), "{}", format_skip_tsv(&report.skips));
}
if report.skills.is_empty()
&& report.skips.is_empty()
&& !matches!(mode, ListOutput::Json | ListOutput::Xml)
{
write_empty_tree_note(&cwd, &opts);
}
Ok(ExitCode::SUCCESS)
}
Cmd::Load {
name,
json,
outline,
section,
args,
discovery,
} => {
let report = discover_cwd(&discovery)?;
match find_skill_by_name(&report.skills, &name) {
Some(skill) => {
let view = if outline {
LoadView::Outline
} else if let Some(key) = section.as_deref() {
LoadView::Section(key)
} else {
LoadView::Body
};
match format_load_view(skill, &args, FormatOptions::default(), view) {
Ok(text) => {
if json {
let v = serde_json::json!({
"name": skill.name,
"path": skill.source_path.as_ref().map(|p| p.display().to_string()),
"source": skill.source.as_str(),
"text": text,
});
writeln_stdout(
&serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?,
)?;
} else {
write_stdout(&text)?;
}
Ok(ExitCode::SUCCESS)
}
Err(msg) => {
let _ = writeln!(io::stderr(), "{msg}");
if json {
let v = serde_json::json!({
"error_kind": "unknown_section",
"error": msg,
});
writeln_stdout(
&serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?,
)?;
}
Ok(ExitCode::from(2))
}
}
}
None => {
let miss = unknown_or_skipped_skill_named(
&name,
&report.skips,
report.skills.iter().map(|s| s.name.as_str()),
);
let _ = writeln!(io::stderr(), "{miss}");
if json {
writeln_stdout(
&serde_json::to_string_pretty(&miss).map_err(|e| e.to_string())?,
)?;
}
Ok(ExitCode::from(2))
}
}
}
Cmd::Why {
name,
json,
context,
context_tokens,
discovery,
} => {
let (cwd, opts) = discovery_opts(&discovery)?;
let report = discover(&cwd, &opts);
let budgets = progressive_budgets(context_tokens);
let why = why(&report, name.as_deref(), context.as_deref(), Some(budgets));
if let Some(miss) = why.unknown_skill_miss() {
let _ = writeln!(io::stderr(), "{miss}");
if json {
writeln_stdout(
&serde_json::to_string_pretty(&miss).map_err(|e| e.to_string())?,
)?;
}
return Ok(ExitCode::from(2));
}
if json {
writeln_stdout(&serde_json::to_string_pretty(&why).map_err(|e| e.to_string())?)?;
} else {
write_stdout(&format_why_text(&why))?;
if report.skills.is_empty() && report.skips.is_empty() {
write_empty_tree_note(&cwd, &opts);
}
}
Ok(ExitCode::SUCCESS)
}
Cmd::New { name, dir } => write_new_skill(&name, dir.as_deref()),
Cmd::Validate { path, strict, json } => {
let report = validate_path_with_options(&path, strict);
if report.ok {
if json {
writeln_stdout(
&serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?,
)?;
} else {
writeln_stdout(&format!("ok\t{}", report.name.as_deref().unwrap_or("-")))?;
}
Ok(ExitCode::SUCCESS)
} else if let (true, Some(miss)) = (json, report.miss()) {
let _ = writeln!(io::stderr(), "{miss}");
writeln_stdout(&serde_json::to_string_pretty(&miss).map_err(|e| e.to_string())?)?;
Ok(ExitCode::from(1))
} else {
for e in &report.errors {
let _ = writeln!(io::stderr(), "{e}");
}
Ok(ExitCode::from(1))
}
}
}
}
enum ListOutput {
Tsv,
Json,
Xml,
Catalog,
Watch,
}
fn list_output_mode(
json: bool,
xml: bool,
catalog: bool,
watch: bool,
format: Option<String>,
) -> Result<ListOutput, String> {
if let Some(format) = format {
return Ok(match parse_list_format(&format)? {
ListFormat::Json => ListOutput::Json,
ListFormat::Xml => ListOutput::Xml,
ListFormat::Catalog => ListOutput::Catalog,
ListFormat::Watch => ListOutput::Watch,
});
}
if watch {
return Ok(ListOutput::Watch);
}
if catalog {
return Ok(ListOutput::Catalog);
}
if xml {
return Ok(ListOutput::Xml);
}
if json {
return Ok(ListOutput::Json);
}
Ok(ListOutput::Tsv)
}
fn discovery_opts(discovery: &DiscoveryArgs) -> Result<(PathBuf, DiscoveryOptions), String> {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
let vendor_roots = SkillSource::parse_vendor_roots(&discovery.vendor)?;
let opts = DiscoveryOptions {
paths: discovery.paths.clone(),
ignore: discovery.ignore.clone(),
disabled: discovery.disabled.clone(),
vendor_roots,
user_skills_dir: discovery.user_dir.clone(),
ascii_names: discovery.ascii_names,
implicit_roots: !discovery.no_implicit_roots,
};
Ok((cwd, opts))
}
fn write_empty_tree_note(cwd: &std::path::Path, opts: &DiscoveryOptions) {
let dirs = watch_dirs(cwd, opts);
let _ = writeln!(io::stderr(), "no skills found. searched:");
let implicit_miss = dirs.is_empty() && opts.implicit_roots;
if implicit_miss {
let _ = writeln!(
io::stderr(),
" (no .agents/skills directory on the cwd-to-git walk or under $HOME)"
);
} else if dirs.is_empty() {
let _ = writeln!(
io::stderr(),
" (no walk roots; pass --path DIR for a collection)"
);
} else {
for dir in &dirs {
let _ = writeln!(
io::stderr(),
" {}",
craftbag::sanitize_error_token(&dir.display().to_string())
);
}
}
if implicit_miss {
let _ = writeln!(
io::stderr(),
"hint: run craftbag new NAME, or pass --path DIR for a collection root"
);
} else {
let _ = writeln!(
io::stderr(),
"hint: a skill is a directory containing SKILL.md; pass --path DIR for a collection root"
);
}
for name in present_disabled_vendors(cwd, opts) {
let _ = writeln!(
io::stderr(),
"hint: .{name}/skills is present; pass --vendor {name}"
);
}
}
fn write_new_skill(raw_name: &str, dir: Option<&std::path::Path>) -> Result<ExitCode, String> {
let name = normalize_skill_name(raw_name);
match validate_skill_name(&name) {
Ok(()) => {}
Err(craftbag::ParseError::InvalidYaml(msg)) => return Err(msg),
Err(err) => return Err(err.to_string()),
}
if let Some(path) = dir {
refuse_new_dir(path)?;
}
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
let root = match dir {
Some(path) if path.is_absolute() => path.to_path_buf(),
Some(path) => cwd.join(path),
None => cwd.join(".agents").join("skills"),
};
let package = root.join(&name);
if let Some(err) = package_symlink_escapes(&root, &package) {
return Err(err);
}
let skill_md = package.join("SKILL.md");
let skill_md_lower = package.join("skill.md");
if skill_md.exists() || skill_md_lower.exists() {
let shown = if skill_md.exists() {
skill_md
} else {
skill_md_lower
};
return Err(format!(
"skill already exists: {}",
craftbag::sanitize_error_token(&shown.display().to_string())
));
}
std::fs::create_dir_all(&package).map_err(|err| {
if let Some(blocked) = file_blocking_create(&package) {
let shown = craftbag::sanitize_error_token(&blocked.display().to_string());
return format!("not a directory: {shown}");
}
let shown = craftbag::sanitize_error_token(&package.display().to_string());
format!(
"{shown}: {}",
craftbag::sanitize_error_token(&err.to_string())
)
})?;
let body = format!(
"---\nname: \"{name}\"\ndescription: Describe when to use this skill.\n---\n\n# {name}\n\nInstructions for the agent.\n"
);
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&skill_md);
let mut file = match file {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(format!(
"skill already exists: {}",
craftbag::sanitize_error_token(&skill_md.display().to_string())
));
}
Err(err) => return Err(err.to_string()),
};
if let Err(err) = std::io::Write::write_all(&mut file, body.as_bytes()) {
drop(file);
let _ = std::fs::remove_file(&skill_md);
return Err(craftbag::sanitize_error_token(&err.to_string()));
}
writeln_stdout(&craftbag::sanitize_error_token(
&skill_md.display().to_string(),
))?;
Ok(ExitCode::SUCCESS)
}
fn package_symlink_escapes(root: &std::path::Path, package: &std::path::Path) -> Option<String> {
let meta = std::fs::symlink_metadata(package).ok()?;
if !meta.file_type().is_symlink() {
return None;
}
let Some(root_c) = root.canonicalize().ok() else {
let shown = craftbag::sanitize_error_token(&package.display().to_string());
return Some(format!(
"package directory escapes collection root: {shown}"
));
};
let pkg_c = package.canonicalize().ok()?;
if pkg_c.starts_with(&root_c) {
return None;
}
let shown = craftbag::sanitize_error_token(&package.display().to_string());
Some(format!(
"package directory escapes collection root: {shown}"
))
}
fn file_blocking_create(path: &std::path::Path) -> Option<std::path::PathBuf> {
let mut prefix = std::path::PathBuf::new();
for component in path.components() {
prefix.push(component);
if prefix.is_file() {
return Some(prefix);
}
}
None
}
fn refuse_new_dir(path: &std::path::Path) -> Result<(), String> {
for component in path.components() {
match component.as_os_str().to_str() {
None => return Err("--dir is not valid UTF-8".to_owned()),
Some(text)
if text
.chars()
.any(|c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')) =>
{
return Err("--dir contains a line separator".to_owned());
}
Some(_) => {}
}
}
Ok(())
}
fn discover_cwd(discovery: &DiscoveryArgs) -> Result<craftbag::DiscoveryReport, String> {
let (cwd, opts) = discovery_opts(discovery)?;
Ok(discover(&cwd, &opts))
}