use std::path::PathBuf;
const U001: &str = "U001";
const U002: &str = "U002";
const U003: &str = "U003";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SuggestionKind {
Fix,
Info,
}
struct Suggestion {
code: &'static str,
kind: SuggestionKind,
message: String,
}
impl std::fmt::Display for Suggestion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tag = match self.kind {
SuggestionKind::Fix => "fix",
SuggestionKind::Info => "info",
};
write!(f, "[{tag}] {}: {}", self.code, self.message)
}
}
pub(crate) fn run(
skill_dir: PathBuf,
apply: bool,
dry_run: bool,
full: bool,
format: super::Format,
) {
let _ = dry_run;
let dir = super::resolve_skill_dir(&skill_dir);
match run_upgrade(&dir, apply, full) {
Ok((suggestions, full_messages, has_full_errors)) => {
if suggestions.is_empty() && full_messages.is_empty() {
eprintln!("No upgrade suggestions — skill follows current best practices.");
} else {
match format {
super::Format::Text => {
for msg in &full_messages {
eprintln!("{msg}");
}
for s in &suggestions {
eprintln!("{s}");
}
let fix_count = suggestions
.iter()
.filter(|s| s.kind == SuggestionKind::Fix)
.count();
let info_count = suggestions
.iter()
.filter(|s| s.kind == SuggestionKind::Info)
.count();
if !apply && fix_count > 0 {
eprint!("\nRun with --apply to apply {fix_count} fix(es).");
if info_count > 0 {
eprint!(" {info_count} informational suggestion(s) shown above.");
}
eprintln!();
} else if !apply && info_count > 0 {
eprintln!(
"\n{info_count} informational suggestion(s) — no auto-fixes available."
);
}
}
super::Format::Json => {
let json_suggestions: Vec<serde_json::Value> = suggestions
.iter()
.map(|s| {
serde_json::json!({
"code": s.code,
"kind": match s.kind {
SuggestionKind::Fix => "fix",
SuggestionKind::Info => "info",
},
"message": s.message,
})
})
.collect();
let mut json = serde_json::json!({
"suggestions": json_suggestions,
"applied": apply,
});
if !full_messages.is_empty() {
json["diagnostics"] = serde_json::json!(full_messages);
}
println!("{}", serde_json::to_string_pretty(&json).unwrap());
}
}
let has_unapplied_fixes =
!apply && suggestions.iter().any(|s| s.kind == SuggestionKind::Fix);
if has_unapplied_fixes || has_full_errors {
std::process::exit(1);
}
}
}
Err(e) => {
eprintln!("aigent upgrade: {e}");
std::process::exit(1);
}
}
}
fn extract_frontmatter_lines(content: &str) -> Vec<String> {
content
.lines()
.skip(1) .take_while(|l| l.trim_end() != "---")
.map(|l| l.to_string())
.collect()
}
fn run_upgrade(
dir: &std::path::Path,
apply: bool,
full: bool,
) -> std::result::Result<(Vec<Suggestion>, Vec<String>, bool), aigent::AigentError> {
let mut suggestions = Vec::new();
let mut full_messages = Vec::new();
let mut has_full_errors = false;
if full {
let mut diags = aigent::validate(dir);
if let Ok(props) = aigent::read_properties(dir) {
let body = aigent::read_body(dir).unwrap_or_default();
diags.extend(aigent::lint(&props, &body));
}
if apply {
let fixable: Vec<_> = diags
.iter()
.filter(|d| d.suggestion.is_some())
.cloned()
.collect();
if !fixable.is_empty() {
let fix_count = aigent::apply_fixes(dir, &fixable)?;
if fix_count > 0 {
full_messages.push(format!(
"[full] Applied {fix_count} validation/lint fix(es)"
));
}
diags = aigent::validate(dir);
if let Ok(props) = aigent::read_properties(dir) {
let body = aigent::read_body(dir).unwrap_or_default();
diags.extend(aigent::lint(&props, &body));
}
}
}
let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
let warnings: Vec<_> = diags.iter().filter(|d| d.is_warning()).collect();
if !errors.is_empty() {
has_full_errors = true;
}
if !errors.is_empty() || !warnings.is_empty() {
for d in &errors {
full_messages.push(format!("[full] error: {d}"));
}
for d in &warnings {
full_messages.push(format!("[full] warning: {d}"));
}
}
}
let props = aigent::read_properties(dir)?;
if props.compatibility.is_none() {
suggestions.push(Suggestion {
code: U001,
kind: SuggestionKind::Fix,
message: "Missing 'compatibility' field — recommended for multi-platform skills."
.to_string(),
});
}
let desc_lower = props.description.to_lowercase();
let has_trigger = aigent::linter::TRIGGER_PHRASES
.iter()
.any(|p| desc_lower.contains(p));
if !has_trigger {
suggestions.push(Suggestion {
code: U002,
kind: SuggestionKind::Info,
message:
"Description lacks 'Use when...' trigger phrase — helps Claude activate the skill."
.to_string(),
});
}
let body = aigent::read_body(dir)?;
let line_count = body.lines().count();
if line_count > 500 {
suggestions.push(Suggestion {
code: U003,
kind: SuggestionKind::Info,
message: format!(
"Body is {line_count} lines — consider splitting into reference files (recommended < 500)."
),
});
}
if apply && suggestions.iter().any(|s| s.kind == SuggestionKind::Fix) {
if let Some(path) = aigent::find_skill_md(dir) {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok((raw_map, body)) = aigent::parse_frontmatter(&content) {
let front_lines = extract_frontmatter_lines(&content);
let mut updated_lines = front_lines.clone();
if props.compatibility.is_none() && !raw_map.contains_key("compatibility") {
updated_lines.push("compatibility: claude-code".to_string());
}
let updated_yaml = updated_lines.join("\n");
let new_content = format!("---\n{updated_yaml}\n---\n{body}");
if new_content != content {
std::fs::write(&path, &new_content)?;
eprintln!("Applied upgrades to {}", path.display());
}
}
}
}
}
Ok((suggestions, full_messages, has_full_errors))
}