use crate::cli::{AgentSkillArgs, clap_command};
use crate::error::Result;
use clap::Arg;
use clap::Command as ClapCommand;
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::OnceLock;
const TEMPLATE: &str = include_str!("agent_skill.template.md");
const IGNORED_COMMANDS: &[&str] = &[
"commands",
"daemon",
"stop",
"stop-all",
"server-capabilities",
"detect",
"languages",
"build-index",
"update",
"completion",
"agent-skill",
"run",
];
const IGNORED_OPTIONS: &[&str] = &[
"debug",
"no-debug",
"timeout",
"download",
"no-download",
"no-detach",
"detach",
"no-json",
"wait-for-index",
];
#[allow(clippy::unnecessary_wraps)]
pub(super) fn run(_args: &AgentSkillArgs) -> Result<String> {
Ok(render_skill())
}
fn render_skill() -> String {
let root = clap_command();
let mut replacements = command_replacements(&root);
replacements.extend(option_replacements(&root));
render_template(TEMPLATE, &replacements)
}
fn command_replacements(root: &ClapCommand) -> BTreeMap<String, String> {
validate_ignored_commands(root);
root.get_subcommands()
.filter(|command| command.get_name() != "help")
.filter(|command| !IGNORED_COMMANDS.contains(&command.get_name()))
.map(|command| {
let placeholder = command_placeholder(command.get_name()).unwrap_or_else(|| {
panic!(
"non-ignored command `{}` is missing an agent-skill template placeholder",
command.get_name()
)
});
(placeholder.to_string(), command_about(command))
})
.collect()
}
fn option_replacements(root: &ClapCommand) -> BTreeMap<String, String> {
let mut counts = BTreeMap::<String, BTreeMap<String, usize>>::new();
let mut known_options = BTreeSet::<String>::new();
for command in root
.get_subcommands()
.filter(|command| command.get_name() != "help")
.filter(|command| !IGNORED_COMMANDS.contains(&command.get_name()))
{
for arg in command.get_arguments() {
let Some(long) = arg.get_long() else {
continue;
};
if long == "help" || arg.is_hide_set() {
continue;
}
known_options.insert(long.to_string());
if IGNORED_OPTIONS.contains(&long) {
continue;
}
let help = option_help(arg);
*counts
.entry(long.to_string())
.or_default()
.entry(help)
.or_default() += 1;
}
}
for ignored in IGNORED_OPTIONS {
assert!(
known_options.contains(*ignored),
"ignored agent-skill option `--{ignored}` does not exist"
);
}
counts
.into_iter()
.map(|(long, variants)| {
let help = select_option_help(&long, &variants);
(option_placeholder(&long), help)
})
.collect()
}
fn validate_ignored_commands(root: &ClapCommand) {
let known = root
.get_subcommands()
.map(ClapCommand::get_name)
.collect::<BTreeSet<_>>();
for ignored in IGNORED_COMMANDS {
assert!(
known.contains(ignored),
"ignored agent-skill command `{ignored}` does not exist"
);
}
}
fn command_placeholder(command: &str) -> Option<&'static str> {
match command {
"grep" => Some("CMD/GREP"),
"list-symbols" => Some("CMD/LIST_SYMBOLS"),
"list-functions" => Some("CMD/LIST_FUNCTIONS"),
"list-files" => Some("CMD/LIST_FILES"),
"definition" => Some("CMD/DEFINITION"),
"declaration" => Some("CMD/DECLARATION"),
"implementation" => Some("CMD/IMPLEMENTATION"),
"type-definition" => Some("CMD/TYPE_DEFINITION"),
"references" => Some("CMD/REFERENCES"),
"callers" => Some("CMD/CALLERS"),
"callees" => Some("CMD/CALLEES"),
"diagnostics" => Some("CMD/DIAGNOSTICS"),
"format" => Some("CMD/FORMAT"),
"languages" => Some("CMD/LANGUAGES"),
"servers" => Some("CMD/SERVERS"),
_ => None,
}
}
fn option_placeholder(long: &str) -> String {
format!("OPT/{}", long.replace('-', "_").to_uppercase())
}
fn select_option_help(long: &str, variants: &BTreeMap<String, usize>) -> String {
let max_count = variants
.values()
.copied()
.max()
.unwrap_or_else(|| panic!("agent-skill option `--{long}` has no help variants"));
let best = variants
.iter()
.filter(|(_, count)| **count == max_count)
.map(|(help, _)| help.clone())
.collect::<Vec<_>>();
assert!(
best.len() == 1,
"agent-skill option `--{long}` has ambiguous help variants: {}",
best.join(" | ")
);
best[0].clone()
}
fn render_template(template: &str, replacements: &BTreeMap<String, String>) -> String {
let placeholder_regex = placeholder_regex();
let used = placeholder_regex
.captures_iter(template)
.map(|captures| captures[1].to_string())
.collect::<BTreeSet<_>>();
for placeholder in &used {
assert!(
replacements.contains_key(placeholder.as_str()),
"agent-skill template contains unknown placeholder `{{{placeholder}}}`"
);
}
for placeholder in replacements.keys() {
assert!(
used.contains(placeholder),
"agent-skill template argument `{{{placeholder}}}` was provided but never used"
);
}
placeholder_regex
.replace_all(template, |captures: ®ex::Captures<'_>| {
replacements
.get(&captures[1].to_string())
.unwrap_or_else(|| panic!("missing replacement for `{{{}}}`", &captures[1]))
.clone()
})
.into_owned()
}
fn placeholder_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| match Regex::new(r"\{([A-Z0-9/_]+)\}") {
Ok(regex) => regex,
Err(error) => panic!("placeholder regex should compile: {error}"),
})
}
fn command_about(command: &ClapCommand) -> String {
if let Some(about) = command.get_about() {
about.to_string()
} else {
"No summary available.".to_string()
}
}
fn option_help(arg: &Arg) -> String {
if let Some(help) = arg.get_help() {
help.to_string()
} else {
"No help available.".to_string()
}
}
#[cfg(test)]
mod tests {
use super::{render_skill, render_template, run};
use crate::cli::AgentSkillArgs;
use std::collections::BTreeMap;
#[test]
fn renders_curated_skill_sections() {
let markdown = render_skill();
assert!(markdown.starts_with("---\nname: lsp-cli\n"));
assert!(markdown.contains("# lsp-cli skill"));
assert!(markdown.contains("## Core commands"));
assert!(markdown.contains("## Setup and troubleshooting"));
assert!(markdown.contains("Search workspace symbols (regex syntax is server-dependent)"));
assert!(markdown.contains("Find definitions of a symbol name"));
assert!(markdown.contains("List known LSP servers"));
assert!(markdown.contains("If automatic selection is ambiguous, these options help:"));
assert!(markdown.contains("Prefer `--json`"));
}
#[test]
fn excludes_plumbing_commands_from_generated_skill() {
let markdown = render_skill();
assert!(!markdown.contains("### `completion`"));
assert!(!markdown.contains("### `run`"));
assert!(!markdown.contains("### `commands`"));
assert!(!markdown.contains("### `stop`"));
assert!(!markdown.contains("### `stop-all`"));
assert!(!markdown.contains("### `update`"));
}
#[test]
fn run_returns_markdown_for_stdout() {
let markdown = run(&AgentSkillArgs {}).expect("skill should render");
assert!(markdown.starts_with("---\n"));
assert!(markdown.contains("# lsp-cli skill"));
}
#[test]
fn output_starts_with_frontmatter_block() {
let markdown = run(&AgentSkillArgs {}).expect("skill should render to stdout");
assert!(markdown.starts_with("---\nname: lsp-cli\n"));
assert!(markdown.contains("\n---\n\n# lsp-cli skill"));
}
#[test]
fn agent_skill_does_not_panic() {
let result = std::panic::catch_unwind(render_skill);
assert!(result.is_ok(), "agent-skill should not panic");
}
#[test]
fn panics_for_unknown_template_placeholders() {
let args = BTreeMap::from([("CMD/GREP".to_string(), "grep about".to_string())]);
let result = std::panic::catch_unwind(|| render_template("{CMD/GREP} {CMD/NOPE}", &args));
assert!(result.is_err(), "unknown placeholders should panic");
}
#[test]
fn panics_for_unused_template_arguments() {
let args = BTreeMap::from([
("CMD/GREP".to_string(), "grep about".to_string()),
("CMD/FORMAT".to_string(), "format about".to_string()),
]);
let result = std::panic::catch_unwind(|| render_template("{CMD/GREP}", &args));
assert!(result.is_err(), "unused template arguments should panic");
}
#[test]
fn renders_option_placeholders_from_clap_help() {
let markdown = render_skill();
assert!(markdown.contains("`--json`: Print results as JSON."));
assert!(markdown.contains(
"`--limit <N>`: Maximum number of results to print. Mainly usable for code agents."
));
assert!(markdown.contains("`--lsp <LSP>`: Use a specific configured LSP server."));
assert!(markdown.contains("`--lang <LANG>`: Select this language."));
assert!(
markdown.contains(
"`-l, --files-with-matches`: Print only file paths that contain matches."
)
);
}
}