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    ///
25    /// Global so it is accepted both as `mars skills --type ...` and on the
26    /// `list` subcommand (`mars skills list --type ...`).
27    #[arg(long = "type", id = "skill_type", global = true)]
28    pub skill_type: Option<String>,
29
30    /// Filter to model-invocable skills only.
31    #[arg(long, global = true)]
32    pub model_invocable: bool,
33
34    /// Filter by source name.
35    #[arg(long, global = true)]
36    pub source: Option<String>,
37
38    #[command(subcommand)]
39    pub command: Option<SkillsCommand>,
40}
41
42#[derive(Debug, clap::Subcommand)]
43pub enum SkillsCommand {
44    /// List all skills (same as bare `mars skills`).
45    List,
46    /// Show full metadata for a named skill.
47    Show {
48        /// Skill name.
49        name: String,
50    },
51}
52
53/// Run `mars skills`.
54pub fn run(args: &SkillsArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
55    match &args.command {
56        Some(SkillsCommand::List) => run_list(args, ctx, json),
57        Some(SkillsCommand::Show { name }) => run_show(name, ctx, json),
58        None => run_list(args, ctx, json),
59    }
60}
61
62fn run_list(args: &SkillsArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
63    let lock = crate::lock::load(&ctx.project_root)?;
64    let mars_dir = ctx.project_root.join(".mars");
65
66    let mut entries: Vec<SkillEntry> = Vec::new();
67
68    for (dest_path, item) in lock.canonical_flat_items() {
69        if item.kind != ItemKind::Skill {
70            continue;
71        }
72
73        // source filter
74        if let Some(ref filter_source) = args.source
75            && item.source != *filter_source
76        {
77            continue;
78        }
79
80        let disk_path = dest_path.resolve(&mars_dir);
81        let skill_md = disk_path.join("SKILL.md");
82        let content = match std::fs::read_to_string(&skill_md) {
83            Ok(c) => c,
84            Err(err) => {
85                eprintln!("warning: skipping {}: {err}", skill_md.display());
86                continue;
87            }
88        };
89
90        let fm = match frontmatter::parse(&content) {
91            Ok(fm) => fm,
92            Err(err) => {
93                eprintln!("warning: skipping {}: {err}", skill_md.display());
94                continue;
95            }
96        };
97
98        let mut diags = Vec::new();
99        let profile = parse_skill_profile(&fm, &mut diags);
100
101        // model_invocable filter
102        if args.model_invocable && !profile.model_invocable {
103            continue;
104        }
105
106        // type filter
107        let type_str = profile.skill_type.clone().unwrap_or_default();
108        if let Some(ref filter_type) = args.skill_type
109            && type_str != *filter_type
110        {
111            continue;
112        }
113
114        let name = profile.name.clone().unwrap_or_else(|| dir_name(&disk_path));
115        let description = profile.description.clone().unwrap_or_default();
116
117        entries.push(SkillEntry {
118            name,
119            description,
120            skill_type: type_str,
121            model_invocable: profile.model_invocable,
122        });
123    }
124
125    entries.sort_by(|a, b| a.name.cmp(&b.name));
126
127    if json {
128        output::print_json(&serde_json::json!({ "skills": entries }));
129    } else {
130        if entries.is_empty() {
131            println!("  no skills");
132        } else {
133            let name_w = entries
134                .iter()
135                .map(|e| e.name.len())
136                .max()
137                .unwrap_or(4)
138                .max(4);
139            let type_w = entries
140                .iter()
141                .map(|e| e.skill_type.len())
142                .max()
143                .unwrap_or(4)
144                .max(4);
145            println!(
146                "{:<name_w$}  {:<type_w$}  {:<5}  DESCRIPTION",
147                "NAME", "TYPE", "M-INV"
148            );
149            for e in &entries {
150                let inv = if e.model_invocable { "yes" } else { "no" };
151                println!(
152                    "{:<name_w$}  {:<type_w$}  {:<5}  {}",
153                    e.name, e.skill_type, inv, e.description
154                );
155            }
156        }
157    }
158
159    Ok(0)
160}
161
162fn run_show(name: &str, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
163    let lock = crate::lock::load(&ctx.project_root)?;
164    let mars_dir = ctx.project_root.join(".mars");
165
166    for (dest_path, item) in lock.canonical_flat_items() {
167        if item.kind != ItemKind::Skill {
168            continue;
169        }
170
171        let disk_path = dest_path.resolve(&mars_dir);
172        let skill_md = disk_path.join("SKILL.md");
173        let content = match std::fs::read_to_string(&skill_md) {
174            Ok(c) => c,
175            Err(err) => {
176                eprintln!("warning: skipping {}: {err}", skill_md.display());
177                continue;
178            }
179        };
180
181        let mut diags = Vec::new();
182        let (profile, _fm) = match parse_skill_content(&content, &mut diags) {
183            Ok(p) => p,
184            Err(err) => {
185                eprintln!("warning: skipping {}: {err}", skill_md.display());
186                continue;
187            }
188        };
189
190        let fallback = dir_name(&disk_path);
191        let skill_name = profile.name.as_deref().unwrap_or(fallback.as_str());
192        if !skill_name.eq_ignore_ascii_case(name) {
193            continue;
194        }
195
196        let description_str = profile.description.as_deref().unwrap_or("");
197        let type_str = profile.skill_type.as_deref().unwrap_or("");
198
199        if json {
200            output::print_json(&serde_json::json!({
201                "name": skill_name,
202                "description": description_str,
203                "type": type_str,
204                "model-invocable": profile.model_invocable,
205                "user-invocable": profile.user_invocable,
206                "tools": profile.tools,
207                "disallowed-tools": profile.disallowed_tools,
208                "tools-denied": profile.tools_denied,
209            }));
210        } else {
211            println!("name:          {skill_name}");
212            println!("description:   {description_str}");
213            println!("type:          {type_str}");
214            println!("model-invocable: {}", profile.model_invocable);
215            println!("user-invocable:  {}", profile.user_invocable);
216            print_str_list("tools", &profile.tools);
217            print_str_list("disallowed-tools", &profile.disallowed_tools);
218            print_str_list("tools-denied", &profile.tools_denied);
219        }
220
221        return Ok(0);
222    }
223
224    eprintln!("error: skill `{name}` not found");
225    Ok(1)
226}
227
228fn print_str_list(label: &str, items: &[String]) {
229    if items.is_empty() {
230        println!("{label}:          (none)");
231    } else {
232        println!("{label}:          {}", items.join(", "));
233    }
234}
235
236fn dir_name(path: &std::path::Path) -> String {
237    path.file_name()
238        .and_then(|s| s.to_str())
239        .unwrap_or("unknown")
240        .to_string()
241}
242
243#[cfg(test)]
244mod filter_flag_tests {
245    use crate::cli::{Cli, Command};
246    use clap::Parser;
247
248    fn skills_args(args: &[&str]) -> super::SkillsArgs {
249        match Cli::try_parse_from(args).expect("should parse").command {
250            Command::Skills(s) => s,
251            other => panic!("expected skills command, got {other:?}"),
252        }
253    }
254
255    #[test]
256    fn type_filter_populates_on_both_bare_and_list_forms() {
257        for args in [
258            ["mars", "skills", "list", "--type", "guardrail"].as_slice(),
259            ["mars", "skills", "--type", "guardrail", "list"].as_slice(),
260            ["mars", "skills", "--type", "guardrail"].as_slice(),
261        ] {
262            let parsed = skills_args(args);
263            assert_eq!(
264                parsed.skill_type.as_deref(),
265                Some("guardrail"),
266                "args: {args:?}"
267            );
268        }
269    }
270
271    #[test]
272    fn model_invocable_flag_populates_on_list_form() {
273        let parsed = skills_args(&["mars", "skills", "list", "--model-invocable"]);
274        assert!(parsed.model_invocable);
275    }
276}