Skip to main content

mars_agents/cli/
skills.rs

1//! `mars skills` — list and inspect skills from the .mars/ canonical store.
2
3use crate::compiler::skills::{parse_skill_content, parse_skill_profile};
4use crate::error::MarsError;
5use crate::frontmatter;
6use crate::lock::ItemKind;
7
8use super::output;
9
10#[derive(serde::Serialize)]
11struct SkillEntry {
12    name: String,
13    description: String,
14    #[serde(rename = "type")]
15    skill_type: String,
16    #[serde(rename = "model-invocable")]
17    model_invocable: bool,
18}
19
20/// Arguments for `mars skills`.
21#[derive(Debug, clap::Args)]
22pub struct SkillsArgs {
23    /// Filter by skill type (e.g. guardrail, reference, principle).
24    #[arg(long = "type", id = "skill_type")]
25    pub skill_type: Option<String>,
26
27    /// Filter to model-invocable skills only.
28    #[arg(long)]
29    pub model_invocable: bool,
30
31    /// Filter by source name.
32    #[arg(long)]
33    pub source: Option<String>,
34
35    #[command(subcommand)]
36    pub command: Option<SkillsCommand>,
37}
38
39#[derive(Debug, clap::Subcommand)]
40pub enum SkillsCommand {
41    /// Show full metadata for a named skill.
42    Show {
43        /// Skill name.
44        name: String,
45    },
46}
47
48/// Run `mars skills`.
49pub fn run(args: &SkillsArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
50    match &args.command {
51        Some(SkillsCommand::Show { name }) => run_show(name, ctx, json),
52        None => run_list(args, ctx, json),
53    }
54}
55
56fn run_list(args: &SkillsArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
57    let lock = crate::lock::load(&ctx.project_root)?;
58    let mars_dir = ctx.project_root.join(".mars");
59
60    let mut entries: Vec<SkillEntry> = Vec::new();
61
62    for (dest_path, item) in lock.canonical_flat_items() {
63        if item.kind != ItemKind::Skill {
64            continue;
65        }
66
67        // source filter
68        if let Some(ref filter_source) = args.source
69            && item.source != *filter_source
70        {
71            continue;
72        }
73
74        let disk_path = dest_path.resolve(&mars_dir);
75        let skill_md = disk_path.join("SKILL.md");
76        let content = match std::fs::read_to_string(&skill_md) {
77            Ok(c) => c,
78            Err(err) => {
79                eprintln!("warning: skipping {}: {err}", skill_md.display());
80                continue;
81            }
82        };
83
84        let fm = match frontmatter::parse(&content) {
85            Ok(fm) => fm,
86            Err(err) => {
87                eprintln!("warning: skipping {}: {err}", skill_md.display());
88                continue;
89            }
90        };
91
92        let mut diags = Vec::new();
93        let profile = parse_skill_profile(&fm, &mut diags);
94
95        // model_invocable filter
96        if args.model_invocable && !profile.model_invocable {
97            continue;
98        }
99
100        // type filter
101        let type_str = profile.skill_type.clone().unwrap_or_default();
102        if let Some(ref filter_type) = args.skill_type
103            && type_str != *filter_type
104        {
105            continue;
106        }
107
108        let name = profile.name.clone().unwrap_or_else(|| dir_name(&disk_path));
109        let description = profile.description.clone().unwrap_or_default();
110
111        entries.push(SkillEntry {
112            name,
113            description,
114            skill_type: type_str,
115            model_invocable: profile.model_invocable,
116        });
117    }
118
119    entries.sort_by(|a, b| a.name.cmp(&b.name));
120
121    if json {
122        output::print_json(&serde_json::json!({ "skills": entries }));
123    } else {
124        if entries.is_empty() {
125            println!("  no skills");
126        } else {
127            let name_w = entries
128                .iter()
129                .map(|e| e.name.len())
130                .max()
131                .unwrap_or(4)
132                .max(4);
133            let type_w = entries
134                .iter()
135                .map(|e| e.skill_type.len())
136                .max()
137                .unwrap_or(4)
138                .max(4);
139            println!(
140                "{:<name_w$}  {:<type_w$}  {:<5}  DESCRIPTION",
141                "NAME", "TYPE", "M-INV"
142            );
143            for e in &entries {
144                let inv = if e.model_invocable { "yes" } else { "no" };
145                println!(
146                    "{:<name_w$}  {:<type_w$}  {:<5}  {}",
147                    e.name, e.skill_type, inv, e.description
148                );
149            }
150        }
151    }
152
153    Ok(0)
154}
155
156fn run_show(name: &str, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
157    let lock = crate::lock::load(&ctx.project_root)?;
158    let mars_dir = ctx.project_root.join(".mars");
159
160    for (dest_path, item) in lock.canonical_flat_items() {
161        if item.kind != ItemKind::Skill {
162            continue;
163        }
164
165        let disk_path = dest_path.resolve(&mars_dir);
166        let skill_md = disk_path.join("SKILL.md");
167        let content = match std::fs::read_to_string(&skill_md) {
168            Ok(c) => c,
169            Err(err) => {
170                eprintln!("warning: skipping {}: {err}", skill_md.display());
171                continue;
172            }
173        };
174
175        let mut diags = Vec::new();
176        let (profile, _fm) = match parse_skill_content(&content, &mut diags) {
177            Ok(p) => p,
178            Err(err) => {
179                eprintln!("warning: skipping {}: {err}", skill_md.display());
180                continue;
181            }
182        };
183
184        let fallback = dir_name(&disk_path);
185        let skill_name = profile.name.as_deref().unwrap_or(fallback.as_str());
186        if !skill_name.eq_ignore_ascii_case(name) {
187            continue;
188        }
189
190        let description_str = profile.description.as_deref().unwrap_or("");
191        let type_str = profile.skill_type.as_deref().unwrap_or("");
192
193        if json {
194            output::print_json(&serde_json::json!({
195                "name": skill_name,
196                "description": description_str,
197                "type": type_str,
198                "model-invocable": profile.model_invocable,
199                "user-invocable": profile.user_invocable,
200                "allowed-tools": profile.allowed_tools,
201            }));
202        } else {
203            println!("name:          {skill_name}");
204            println!("description:   {description_str}");
205            println!("type:          {type_str}");
206            println!("model-invocable: {}", profile.model_invocable);
207            println!("user-invocable:  {}", profile.user_invocable);
208            if profile.allowed_tools.is_empty() {
209                println!("allowed-tools: (none)");
210            } else {
211                println!("allowed-tools: {}", profile.allowed_tools.join(", "));
212            }
213        }
214
215        return Ok(0);
216    }
217
218    eprintln!("error: skill `{name}` not found");
219    Ok(1)
220}
221
222fn dir_name(path: &std::path::Path) -> String {
223    path.file_name()
224        .and_then(|s| s.to_str())
225        .unwrap_or("unknown")
226        .to_string()
227}