mars_agents/cli/
skills.rs1use 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")]
25 pub skill_type: Option<String>,
26
27 #[arg(long)]
29 pub model_invocable: bool,
30
31 #[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 {
43 name: String,
45 },
46}
47
48pub 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 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 if args.model_invocable && !profile.model_invocable {
97 continue;
98 }
99
100 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}