use std::{
fmt::Write as _,
fs,
io::{self, Write},
path::{Path, PathBuf},
};
use serde_json::{Value, json};
use crate::agent::{AgentSubcommand, DescribeFormat, EmitScope, EmitTarget, ListFormat};
use crate::{AgentCapability, ProcessEnv, ToolSpec};
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum EmitDirError {
#[error("$HOME is not set")]
HomeUnset,
}
#[must_use]
pub fn run_agent_subcommand(spec: &ToolSpec, env: &ProcessEnv, command: &AgentSubcommand) -> i32 {
if env.agent.active {
eprintln!("agent skill generation is not available under agent supervision");
return 1;
}
match command {
AgentSubcommand::List { format } => {
println!("{}", render_list(spec, *format));
0
}
AgentSubcommand::Describe { name, format } => find_capability(spec, name).map_or_else(
|| {
eprintln!("unknown agent capability: {name}");
1
},
|capability| {
println!("{}", render_describe(spec, capability, *format));
0
},
),
AgentSubcommand::EmitSkills {
target,
scope,
out,
install,
} => run_emit_skills(
spec,
env.home.as_deref(),
*target,
*scope,
out.as_deref(),
*install,
),
}
}
fn run_emit_skills(
spec: &ToolSpec,
home: Option<&Path>,
target: EmitTarget,
scope: EmitScope,
out: Option<&Path>,
install: bool,
) -> i32 {
if !install && out.is_none() {
eprintln!(
"agent emit-skills requires --install or --out=DIR (refusing to dump multiple artifacts to stdout)"
);
return 2;
}
let capabilities = capabilities_for(spec);
if capabilities.is_empty() {
eprintln!("tool declares no agent capabilities");
return 1;
}
let base = match resolve_emit_dir(home, target, scope, out) {
Ok(path) => path,
Err(err) => {
eprintln!("could not resolve skill destination: {err}");
return 1;
}
};
for capability in capabilities {
let skill_name = skill_name(spec, capability);
let dir = base.join(&skill_name);
if let Err(err) = fs::create_dir_all(&dir) {
eprintln!("failed to create {}: {err}", dir.display());
return 1;
}
let body = render_skill_md(spec, capability);
let path = dir.join("SKILL.md");
if let Err(err) = write_file(&path, &body) {
eprintln!("failed to write {}: {err}", path.display());
return 1;
}
println!("wrote {}", path.display());
}
0
}
fn write_file(path: &Path, body: &str) -> io::Result<()> {
let mut file = fs::File::create(path)?;
file.write_all(body.as_bytes())?;
if !body.ends_with('\n') {
file.write_all(b"\n")?;
}
Ok(())
}
pub fn resolve_emit_dir(
home: Option<&Path>,
target: EmitTarget,
scope: EmitScope,
out: Option<&Path>,
) -> Result<PathBuf, EmitDirError> {
if let Some(path) = out {
return Ok(path.to_path_buf());
}
let segment = match target {
EmitTarget::Claude => ".claude",
EmitTarget::Codex => ".codex",
};
match scope {
EmitScope::Project => Ok(PathBuf::from(segment).join("skills")),
EmitScope::User => {
let home = home.ok_or(EmitDirError::HomeUnset)?;
Ok(home.join(segment).join("skills"))
}
}
}
fn capabilities_for(spec: &ToolSpec) -> &'static [AgentCapability] {
spec.agent_surface
.map_or(&[][..], |surface| surface.capabilities)
}
fn find_capability(spec: &ToolSpec, name: &str) -> Option<&'static AgentCapability> {
capabilities_for(spec)
.iter()
.find(|capability| capability.name == name)
}
#[must_use]
pub fn skill_name(spec: &ToolSpec, capability: &AgentCapability) -> String {
format!("{}-{}", spec.bin_name, capability.name)
}
#[must_use]
pub fn render_list(spec: &ToolSpec, format: ListFormat) -> String {
let capabilities = capabilities_for(spec);
match format {
ListFormat::Text => render_list_text(spec, capabilities),
ListFormat::Json => render_list_json(spec, capabilities).to_string(),
}
}
fn render_list_text(spec: &ToolSpec, capabilities: &[AgentCapability]) -> String {
let mut lines = vec![format!("tool: {}", spec.bin_name)];
if capabilities.is_empty() {
lines.push(String::from("capabilities: none declared"));
} else {
lines.push(String::from("capabilities:"));
for capability in capabilities {
lines.push(format!(
"- {}: {}",
capability.name,
summary_or_default(capability),
));
}
}
lines.join("\n")
}
fn render_list_json(spec: &ToolSpec, capabilities: &[AgentCapability]) -> Value {
let entries = capabilities
.iter()
.map(|capability| {
json!({
"name": capability.name,
"summary": summary_or_default(capability),
"skill_name": skill_name(spec, capability),
})
})
.collect::<Vec<_>>();
json!({
"tool": spec.bin_name,
"version": spec.version,
"capabilities": entries,
})
}
#[must_use]
pub fn render_describe(
spec: &ToolSpec,
capability: &AgentCapability,
format: DescribeFormat,
) -> String {
match format {
DescribeFormat::Text => render_describe_text(spec, capability),
DescribeFormat::Json => render_describe_json(spec, capability).to_string(),
DescribeFormat::SkillMd => render_skill_md(spec, capability),
}
}
fn render_describe_text(spec: &ToolSpec, capability: &AgentCapability) -> String {
let mut sections = vec![
format!("tool: {}", spec.bin_name),
format!("capability: {}", capability.name),
format!("summary: {}", summary_or_default(capability)),
];
if let Some(text) = capability.when_to_use {
sections.push(format!("when-to-use: {text}"));
}
if let Some(text) = capability.when_not_to_use {
sections.push(format!("when-not-to-use: {text}"));
}
sections.push(format!("commands:\n{}", render_command_lines(capability)));
sections.push(format!("flags:\n{}", render_flag_lines(capability)));
sections.push(format!("examples:\n{}", render_example_lines(capability)));
sections.push(format!("output: {}", output_or_default(capability)));
sections.push(format!(
"constraints: {}",
constraints_or_default(capability)
));
sections.join("\n")
}
fn render_describe_json(spec: &ToolSpec, capability: &AgentCapability) -> Value {
json!({
"tool": spec.bin_name,
"version": spec.version,
"capability": capability.name,
"skill_name": skill_name(spec, capability),
"summary": summary_or_default(capability),
"when_to_use": capability.when_to_use,
"when_not_to_use": capability.when_not_to_use,
"commands": capability
.commands
.iter()
.map(|selector| selector.path.join(" "))
.collect::<Vec<_>>(),
"flags": capability
.flags
.iter()
.map(|flag| {
if flag.command_path.is_empty() {
format!("--{}", flag.long)
} else {
format!("{} --{}", flag.command_path.join(" "), flag.long)
}
})
.collect::<Vec<_>>(),
"examples": capability.examples.unwrap_or(&[]),
"output": output_or_default(capability),
"constraints": constraints_or_default(capability),
})
}
#[must_use]
pub fn render_skill_md(spec: &ToolSpec, capability: &AgentCapability) -> String {
let name = skill_name(spec, capability);
let description = synthesize_description(capability);
let mut body = String::new();
body.push_str("---\n");
let _ = writeln!(body, "name: {name}");
let _ = writeln!(body, "description: {}", yaml_escape(&description));
body.push_str("---\n\n");
let _ = writeln!(body, "# {} — {}\n", spec.bin_name, capability.name);
let _ = writeln!(body, "{}\n", summary_or_default(capability));
if let Some(text) = capability.when_to_use {
body.push_str("## When to use\n\n");
body.push_str(text);
body.push_str("\n\n");
}
if let Some(text) = capability.when_not_to_use {
body.push_str("## When not to use\n\n");
body.push_str(text);
body.push_str("\n\n");
}
body.push_str("## Commands\n\n");
if capability.commands.is_empty() {
body.push_str("- none declared\n");
} else {
for selector in capability.commands {
let _ = writeln!(body, "- `{} {}`", spec.bin_name, selector.path.join(" "));
}
}
body.push('\n');
body.push_str("## Flags\n\n");
if capability.flags.is_empty() {
body.push_str("- none declared\n");
} else {
for flag in capability.flags {
if flag.command_path.is_empty() {
let _ = writeln!(body, "- `--{}`", flag.long);
} else {
let _ = writeln!(body, "- `{} --{}`", flag.command_path.join(" "), flag.long);
}
}
}
body.push('\n');
body.push_str("## Examples\n\n");
match capability.examples {
Some(examples) if !examples.is_empty() => {
for example in examples {
let _ = writeln!(body, "- `{example}`");
}
}
_ => body.push_str("- none declared\n"),
}
body.push('\n');
body.push_str("## Output\n\n");
body.push_str(&output_or_default(capability));
body.push_str("\n\n");
body.push_str("## Constraints\n\n");
body.push_str(&constraints_or_default(capability));
body.push('\n');
body
}
fn synthesize_description(capability: &AgentCapability) -> String {
let mut parts = vec![summary_or_default(capability)];
if let Some(text) = capability.when_to_use {
parts.push(format!("Use when {}", lower_first(text)));
}
if let Some(text) = capability.when_not_to_use {
parts.push(format!("Do not use when {}", lower_first(text)));
}
parts.join(". ")
}
fn lower_first(text: &str) -> String {
let mut chars = text.chars();
chars.next().map_or_else(String::new, |first| {
first.to_lowercase().collect::<String>() + chars.as_str()
})
}
fn yaml_escape(value: &str) -> String {
let needs_quote = value.contains(':')
|| value.contains('#')
|| value.contains('\n')
|| value.starts_with(['-', '?', '!', '&', '*', '|', '>', '%', '@', '`']);
if needs_quote {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
let single_line = escaped.replace('\n', " ");
format!("\"{single_line}\"")
} else {
value.to_string()
}
}
fn summary_or_default(capability: &AgentCapability) -> String {
if let Some(summary) = capability.summary {
return String::from(summary);
}
if let Some(primary) = capability.commands.first() {
return format!(
"Use {} via {}",
capability.name.replace('-', " "),
primary.path.join(" ")
);
}
format!("Use {}", capability.name.replace('-', " "))
}
fn output_or_default(capability: &AgentCapability) -> String {
capability.output.map_or_else(
|| {
capability.commands.first().map_or_else(
|| String::from("output follows the existing CLI contract"),
|primary| {
format!(
"output follows the existing CLI contract for {}",
primary.path.join(" ")
)
},
)
},
String::from,
)
}
fn constraints_or_default(capability: &AgentCapability) -> String {
capability.constraints.map_or_else(
|| String::from("existing command validation and auth rules still apply"),
String::from,
)
}
fn render_command_lines(capability: &AgentCapability) -> String {
if capability.commands.is_empty() {
String::from("- none declared")
} else {
capability
.commands
.iter()
.map(|selector| format!("- {}", selector.path.join(" ")))
.collect::<Vec<_>>()
.join("\n")
}
}
fn render_flag_lines(capability: &AgentCapability) -> String {
if capability.flags.is_empty() {
String::from("- none declared")
} else {
capability
.flags
.iter()
.map(|flag| {
if flag.command_path.is_empty() {
format!("- --{}", flag.long)
} else {
format!("- {} --{}", flag.command_path.join(" "), flag.long)
}
})
.collect::<Vec<_>>()
.join("\n")
}
}
fn render_example_lines(capability: &AgentCapability) -> String {
capability.examples.map_or_else(
|| String::from("- none declared"),
|examples| {
if examples.is_empty() {
String::from("- none declared")
} else {
examples
.iter()
.map(|example| format!("- {example}"))
.collect::<Vec<_>>()
.join("\n")
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
AgentCapability, AgentModeContext, AgentSurfaceSpec, CommandSelector, FlagSelector,
LicenseType, RepoInfo, ToolSpec,
};
fn inactive_env() -> ProcessEnv {
ProcessEnv {
agent: AgentModeContext { active: false },
home: None,
}
}
fn active_env() -> ProcessEnv {
ProcessEnv {
agent: AgentModeContext { active: true },
home: None,
}
}
const SCAN_COMMAND: CommandSelector = CommandSelector::new(&["scan"]);
const SCAN_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["scan"], "limit");
const SCAN_CAPABILITY: AgentCapability = AgentCapability::new(
"scan-tree",
"Scan a directory tree",
&[SCAN_COMMAND],
&[SCAN_LIMIT_FLAG],
)
.with_examples(&["tool scan --limit 5"])
.with_output("plain text on stdout, exit 1 on findings")
.with_constraints("reads only the working tree")
.with_when_to_use("the user wants to enumerate matching files in the current tree")
.with_when_not_to_use("the user is asking about remote or non-filesystem state");
const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[SCAN_CAPABILITY]);
fn spec() -> ToolSpec {
ToolSpec::new(
"tool",
"Tool",
"1.2.3",
LicenseType::MIT,
RepoInfo::new("owner", "repo"),
true,
true,
)
.with_agent_surface(&AGENT_SURFACE)
}
#[test]
fn list_text_includes_capability() {
let rendered = render_list(&spec(), ListFormat::Text);
assert!(rendered.contains("tool: tool"));
assert!(rendered.contains("- scan-tree: Scan a directory tree"));
}
#[test]
fn list_json_includes_skill_name() {
let rendered = render_list(&spec(), ListFormat::Json);
assert!(rendered.contains("\"skill_name\":\"tool-scan-tree\""));
assert!(rendered.contains("\"version\":\"1.2.3\""));
}
#[test]
fn describe_text_emits_when_sections() {
let rendered = render_describe(&spec(), &SCAN_CAPABILITY, DescribeFormat::Text);
assert!(rendered.contains("when-to-use: the user wants"));
assert!(rendered.contains("when-not-to-use: the user is asking"));
assert!(rendered.contains("- scan --limit"));
}
#[test]
fn describe_json_round_trips_optional_fields() {
let rendered = render_describe(&spec(), &SCAN_CAPABILITY, DescribeFormat::Json);
let value: Value = serde_json::from_str(&rendered).expect("valid json");
assert_eq!(value["capability"], "scan-tree");
assert_eq!(value["skill_name"], "tool-scan-tree");
assert_eq!(
value["when_to_use"],
"the user wants to enumerate matching files in the current tree"
);
assert_eq!(value["commands"][0], "scan");
assert_eq!(value["flags"][0], "scan --limit");
}
#[test]
fn skill_md_has_frontmatter_and_sections() {
let rendered = render_skill_md(&spec(), &SCAN_CAPABILITY);
assert!(rendered.starts_with("---\n"));
assert!(rendered.contains("name: tool-scan-tree"));
assert!(rendered.contains("description:"));
assert!(rendered.contains("## When to use"));
assert!(rendered.contains("## When not to use"));
assert!(rendered.contains("## Commands"));
assert!(rendered.contains("- `tool scan`"));
assert!(rendered.contains("## Flags"));
assert!(rendered.contains("- `scan --limit`"));
assert!(rendered.contains("## Examples"));
assert!(rendered.contains("- `tool scan --limit 5`"));
assert!(rendered.contains("## Output"));
assert!(rendered.contains("## Constraints"));
}
#[test]
fn skill_md_quotes_description_when_colon_present() {
const COLON_CAPABILITY: AgentCapability =
AgentCapability::new("with-colon", "Summary: contains a colon", &[], &[]);
const COLON_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[COLON_CAPABILITY]);
let spec = ToolSpec::new(
"tool",
"Tool",
"1.2.3",
LicenseType::MIT,
RepoInfo::new("owner", "repo"),
true,
true,
)
.with_agent_surface(&COLON_SURFACE);
let rendered = render_skill_md(&spec, &COLON_CAPABILITY);
assert!(rendered.contains("description: \"Summary: contains a colon\""));
}
#[test]
fn run_agent_describe_unknown_returns_one() {
let exit = run_agent_subcommand(
&spec(),
&inactive_env(),
&AgentSubcommand::Describe {
name: "nope".into(),
format: DescribeFormat::Text,
},
);
assert_eq!(exit, 1);
}
#[test]
fn run_agent_subcommand_blocks_under_active_agent_mode() {
let env = active_env();
let exit_list = run_agent_subcommand(
&spec(),
&env,
&AgentSubcommand::List {
format: ListFormat::Text,
},
);
let exit_describe = run_agent_subcommand(
&spec(),
&env,
&AgentSubcommand::Describe {
name: "scan-tree".into(),
format: DescribeFormat::Text,
},
);
let dir = tempdir();
let exit_emit = run_agent_subcommand(
&spec(),
&env,
&AgentSubcommand::EmitSkills {
target: EmitTarget::Claude,
scope: EmitScope::User,
out: Some(dir.clone()),
install: false,
},
);
let wrote_file = dir.join("tool-scan-tree").join("SKILL.md").exists();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(exit_list, 1);
assert_eq!(exit_describe, 1);
assert_eq!(exit_emit, 1);
assert!(
!wrote_file,
"emit-skills must not write artifacts under agent mode"
);
}
#[test]
fn run_emit_skills_without_target_or_install_errors() {
let exit = run_agent_subcommand(
&spec(),
&inactive_env(),
&AgentSubcommand::EmitSkills {
target: EmitTarget::Claude,
scope: EmitScope::User,
out: None,
install: false,
},
);
assert_eq!(exit, 2);
}
#[test]
fn run_emit_skills_writes_skill_files_under_out_dir() {
let dir = tempdir();
let exit = run_agent_subcommand(
&spec(),
&inactive_env(),
&AgentSubcommand::EmitSkills {
target: EmitTarget::Claude,
scope: EmitScope::User,
out: Some(dir.clone()),
install: false,
},
);
assert_eq!(exit, 0);
let path = dir.join("tool-scan-tree").join("SKILL.md");
let contents = std::fs::read_to_string(&path).expect("skill file should exist");
assert!(contents.contains("name: tool-scan-tree"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_emit_dir_user_scope_uses_home_for_claude() {
let path = resolve_emit_dir(
Some(Path::new("/tmp/fake-home")),
EmitTarget::Claude,
EmitScope::User,
None,
)
.expect("resolves with $HOME");
assert_eq!(path, PathBuf::from("/tmp/fake-home/.claude/skills"));
}
#[test]
fn resolve_emit_dir_user_scope_uses_home_for_codex() {
let path = resolve_emit_dir(
Some(Path::new("/tmp/fake-home")),
EmitTarget::Codex,
EmitScope::User,
None,
)
.expect("resolves with $HOME");
assert_eq!(path, PathBuf::from("/tmp/fake-home/.codex/skills"));
}
#[test]
fn resolve_emit_dir_user_scope_errors_without_home() {
let err = resolve_emit_dir(None, EmitTarget::Claude, EmitScope::User, None)
.expect_err("user scope requires $HOME");
assert_eq!(err, EmitDirError::HomeUnset);
assert_eq!(err.to_string(), "$HOME is not set");
}
#[test]
fn resolve_emit_dir_project_scope_is_relative() {
let path = resolve_emit_dir(None, EmitTarget::Codex, EmitScope::Project, None)
.expect("project scope resolves without home");
assert_eq!(path, PathBuf::from(".codex/skills"));
}
#[test]
fn resolve_emit_dir_explicit_out_overrides_scope() {
let path = resolve_emit_dir(
None,
EmitTarget::Claude,
EmitScope::User,
Some(Path::new("/tmp/explicit")),
)
.expect("explicit path overrides resolution");
assert_eq!(path, PathBuf::from("/tmp/explicit"));
}
fn tempdir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let base = std::env::temp_dir().join(format!(
"tftio-cli-common-agent-skill-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
if let Err(e) = std::fs::remove_dir_all(&base) {
eprintln!("failed to clean up tempdir {}: {e}", base.display());
}
std::fs::create_dir_all(&base).expect("create tempdir");
base
}
}