1use super::{
4 build_skill_record, build_skill_records, format_skill_card, ExtensionError, ExtensionId,
5 ExtensionRegistry, PathRequiresProbe, SkillRecord,
6};
7use crate::error::{BuiltinDomain, EmitError, UsageErrorKind};
8use wyvern_schema::{ErrorCode, SerializeError, StderrError};
9
10#[derive(Debug)]
12pub enum ExtensionsCmdError {
13 Usage {
15 kind: UsageErrorKind,
17 message: String,
19 },
20 Stage {
22 stderr: String,
24 exit_code: i32,
26 },
27 Emit(EmitError),
29}
30
31#[must_use]
33pub fn extensions_usage_message() -> String {
34 concat!(
35 "Usage: wyvern extensions [list] [--json]\n",
36 " wyvern extensions show <id> [--json]\n",
37 " wyvern extensions --help\n",
38 "\n",
39 "Commands:\n",
40 " list List shipped and project CLI extensions\n",
41 " show <id> Print one skill (text or --json object)\n",
42 "\n",
43 "Options:\n",
44 " --json Print SkillRecord JSON (array for list, object for show)\n",
45 "\n",
46 "Warning: `.wyvern/extensions.json` is trusted preexec; review it before running wyvern.\n",
47 "See also: wyvern --help\n",
48 )
49 .to_string()
50}
51
52pub fn run_extensions_command(args: &[String]) -> Result<String, ExtensionsCmdError> {
58 if args
59 .first()
60 .is_some_and(|token| token == "--help" || token == "-h")
61 {
62 return Ok(extensions_usage_message());
63 }
64 match args.first().map(String::as_str) {
65 None => run_list(args),
66 Some("list") => run_list(&args[1..]),
67 Some("show") => run_show(&args[1..]),
68 Some("--json") => run_list(args),
69 Some(other) if other.starts_with('-') => Err(unknown_flag(other)),
70 Some(other) => Err(ExtensionsCmdError::Usage {
71 kind: UsageErrorKind::UnknownSubcommand {
72 domain: BuiltinDomain::Extensions,
73 token: other.to_string(),
74 },
75 message: format!(
76 "unknown extensions subcommand '{other}'\n{}",
77 extensions_usage_message()
78 ),
79 }),
80 }
81}
82
83fn run_list(args: &[String]) -> Result<String, ExtensionsCmdError> {
84 if wants_help(args) {
85 return Ok(extensions_usage_message());
86 }
87 let json = parse_list_flags(args)?;
88 let registry = ExtensionRegistry::load_default().map_err(map_ext)?;
89 let records = build_skill_records(®istry, &PathRequiresProbe);
90 if json {
91 serialize_records_json(&records)
92 } else {
93 Ok(format_skill_cards(&records))
94 }
95}
96
97fn run_show(args: &[String]) -> Result<String, ExtensionsCmdError> {
98 if wants_help(args) {
99 return Ok(extensions_usage_message());
100 }
101 let (id, json) = parse_show_args(args)?;
102 let registry = ExtensionRegistry::load_default().map_err(map_ext)?;
103 let Some(ext) = registry.extensions().iter().find(|ext| ext.id == id) else {
104 return Err(unknown_id(id.as_str()));
105 };
106 let record = build_skill_record(ext, &PathRequiresProbe);
107 if json {
108 serialize_record_json(&record)
109 } else {
110 Ok(format_skill_card(&record))
111 }
112}
113
114fn wants_help(args: &[String]) -> bool {
115 args.iter().any(|arg| arg == "--help" || arg == "-h")
116}
117
118fn parse_list_flags(args: &[String]) -> Result<bool, ExtensionsCmdError> {
119 let mut json = false;
120 for arg in args {
121 match arg.as_str() {
122 "--json" => json = true,
123 other => return Err(unknown_flag(other)),
124 }
125 }
126 Ok(json)
127}
128
129fn parse_show_args(args: &[String]) -> Result<(ExtensionId, bool), ExtensionsCmdError> {
130 let mut json = false;
131 let mut id = None;
132 for arg in args {
133 match arg.as_str() {
134 "--json" => json = true,
135 other if other.starts_with('-') => return Err(unknown_flag(other)),
136 other if id.is_none() => {
137 id = Some(ExtensionId::try_from(other.to_string()).map_err(|_| missing_id())?);
138 }
139 other => return Err(unknown_flag(other)),
140 }
141 }
142 let Some(id) = id else {
143 return Err(missing_id());
144 };
145 Ok((id, json))
146}
147
148fn missing_id() -> ExtensionsCmdError {
149 ExtensionsCmdError::Usage {
150 kind: UsageErrorKind::MissingExtensionId,
151 message: format!(
152 "extensions show requires an extension id\n{}",
153 extensions_usage_message()
154 ),
155 }
156}
157
158#[must_use]
160pub fn format_extensions_list(registry: &ExtensionRegistry) -> String {
161 format_skill_cards(&build_skill_records(registry, &PathRequiresProbe))
162}
163
164fn format_skill_cards(records: &[SkillRecord]) -> String {
165 records
166 .iter()
167 .map(format_skill_card)
168 .collect::<Vec<_>>()
169 .join("\n")
170}
171
172fn serialize_records_json(records: &[SkillRecord]) -> Result<String, ExtensionsCmdError> {
173 match serde_json::to_string_pretty(records) {
174 Ok(mut text) => {
175 if !text.ends_with('\n') {
176 text.push('\n');
177 }
178 Ok(text)
179 }
180 Err(err) => Err(ExtensionsCmdError::Emit(EmitError::Serialize(
181 SerializeError {
182 message: err.to_string(),
183 },
184 ))),
185 }
186}
187
188fn serialize_record_json(record: &SkillRecord) -> Result<String, ExtensionsCmdError> {
189 match serde_json::to_string_pretty(record) {
190 Ok(mut text) => {
191 if !text.ends_with('\n') {
192 text.push('\n');
193 }
194 Ok(text)
195 }
196 Err(err) => Err(ExtensionsCmdError::Emit(EmitError::Serialize(
197 SerializeError {
198 message: err.to_string(),
199 },
200 ))),
201 }
202}
203
204fn unknown_flag(flag: &str) -> ExtensionsCmdError {
205 match StderrError::new(ErrorCode::ValidationError, format!("unknown flag '{flag}'"))
206 .cause("extensions list/show accept only --json")
207 .recovery("Run wyvern extensions list")
208 .recovery("Run wyvern extensions list --json")
209 .recovery("Run wyvern extensions show <id>")
210 .recovery("Run wyvern extensions --help")
211 .docs("docs/wyvern/requirements.md (REQ-0132)")
212 .to_json_string()
213 {
214 Ok(stderr) => ExtensionsCmdError::Stage {
215 stderr,
216 exit_code: ErrorCode::ValidationError.exit_code(),
217 },
218 Err(err) => ExtensionsCmdError::Emit(EmitError::Serialize(err)),
219 }
220}
221
222fn unknown_id(id: &str) -> ExtensionsCmdError {
223 match StderrError::new(
224 ErrorCode::ValidationError,
225 format!("unknown extension id '{id}'"),
226 )
227 .cause("No shipped or project extension has that id")
228 .recovery("Run wyvern extensions list")
229 .recovery("Run wyvern extensions list --json")
230 .recovery("Run wyvern extensions --help")
231 .docs("docs/wyvern/requirements.md (REQ-0132)")
232 .to_json_string()
233 {
234 Ok(stderr) => ExtensionsCmdError::Stage {
235 stderr,
236 exit_code: ErrorCode::ValidationError.exit_code(),
237 },
238 Err(err) => ExtensionsCmdError::Emit(EmitError::Serialize(err)),
239 }
240}
241
242fn map_ext(err: ExtensionError) -> ExtensionsCmdError {
243 match crate::error::emit_extension_error(&err) {
244 Ok(stderr) => ExtensionsCmdError::Stage {
245 stderr,
246 exit_code: err.exit_code(),
247 },
248 Err(e) => ExtensionsCmdError::Emit(e),
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn list_includes_markdown_suffix() {
258 let registry = ExtensionRegistry::from_json_str(super::super::SHIPPED_EXTENSIONS_JSON)
259 .expect("shipped");
260 let text = format_extensions_list(®istry);
261 assert!(text.contains("markdown-suffix"), "{text}");
262 assert!(text.contains("suffix: .md"), "{text}");
263 }
264
265 #[test]
266 fn extensions_help_mentions_list_and_show() {
267 let text = extensions_usage_message();
268 assert!(text.contains("list"), "{text}");
269 assert!(text.contains("show"), "{text}");
270 assert!(
271 text.contains(".wyvern/extensions.json") && text.contains("trusted preexec"),
272 "{text}"
273 );
274 }
275
276 #[test]
277 fn bare_extensions_defaults_to_list() {
278 let text = run_extensions_command(&[]).expect("list");
279 assert!(text.contains("markdown-suffix"), "{text}");
280 }
281
282 #[test]
283 fn show_without_id_is_missing_extension_id() {
284 let err = run_extensions_command(&["show".into()]).expect_err("usage");
285 match err {
286 ExtensionsCmdError::Usage { kind, message } => {
287 assert_eq!(kind, UsageErrorKind::MissingExtensionId);
288 assert!(
289 message.contains("extensions show requires an extension id"),
290 "{message}"
291 );
292 }
293 other => panic!("expected Usage, got {other:?}"),
294 }
295 }
296
297 #[test]
298 fn unknown_extensions_subcommand_is_discriminated() {
299 let err = run_extensions_command(&["dump".into()]).expect_err("usage");
300 match err {
301 ExtensionsCmdError::Usage { kind, message } => {
302 assert!(matches!(
303 kind,
304 UsageErrorKind::UnknownSubcommand { domain, ref token }
305 if domain == BuiltinDomain::Extensions && token == "dump"
306 ));
307 assert!(
308 message.contains("unknown extensions subcommand"),
309 "{message}"
310 );
311 }
312 other => panic!("expected Usage, got {other:?}"),
313 }
314 }
315
316 #[test]
317 fn list_prints_requires_availability() {
318 let json = r#"{
319 "version": 1,
320 "extensions": [
321 {
322 "id": "compose-render",
323 "match": { "argv_prefix": ["compose", "render"] },
324 "preexec": { "cmd": "sc-compose", "requires": ["sc-compose"] },
325 "expand": { "command": { "type": "markdown", "content": "x" } }
326 }
327 ]
328 }"#;
329 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
330 let text = format_extensions_list(®istry);
331 assert!(text.contains("compose-render"), "{text}");
332 assert!(text.contains("prefix: compose render"), "{text}");
333 assert!(text.contains("sc-compose"), "{text}");
334 assert!(
335 text.contains("[available]") || text.contains("[missing]"),
336 "{text}"
337 );
338 }
339
340 #[test]
341 fn list_unknown_flag_is_validation_error() {
342 let err = run_extensions_command(&["list".into(), "--foo".into()]).expect_err("flag");
343 match err {
344 ExtensionsCmdError::Stage { stderr, exit_code } => {
345 assert_eq!(exit_code, 4);
346 let value: serde_json::Value = serde_json::from_str(&stderr).expect("json");
347 assert_eq!(value["code"], "VALIDATION_ERROR");
348 assert!(
349 value["message"].as_str().unwrap_or("").contains("--foo"),
350 "{stderr}"
351 );
352 }
353 other => panic!("expected Stage, got {other:?}"),
354 }
355 }
356}