1use 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#[derive(Debug, clap::Args)]
22pub struct SkillsArgs {
23 #[arg(long = "type", id = "skill_type", global = true)]
28 pub skill_type: Option<String>,
29
30 #[arg(long, global = true)]
32 pub model_invocable: bool,
33
34 #[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,
46 Show {
48 name: String,
50 },
51}
52
53pub 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 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 if args.model_invocable && !profile.model_invocable {
103 continue;
104 }
105
106 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}