1use std::cell::RefCell;
7use std::ffi::OsStr;
8use std::io::Write;
9use std::path::PathBuf;
10
11use crate::core::errors::{Result, SkillflagError};
12use crate::core::list::{list_skills, list_skills_json};
13use crate::core::paths::{resolve_skill_dir_from_roots, resolve_skills_roots, SkillsRoot};
14use crate::core::show::skill_md_bytes;
15use crate::core::tar::build_skill_tar;
16use crate::install::cli::run_install_cli_streams;
17use crate::install::install::InstallInput;
18use crate::install::resolve::SystemEnv;
19use crate::stdio::{read_line, InputStream, StdinStream};
20
21pub const SKILLFLAG_SKILLS_ROOT_ENV: &str = "SKILLFLAG_SKILLS_ROOT";
25
26pub struct Options {
33 pub skills_roots: Vec<PathBuf>,
34 pub include_bundled_skill: bool,
35 pub cwd: Option<PathBuf>,
36 pub stdin: Option<RefCell<Box<dyn InputStream>>>,
37 pub stdout: Option<RefCell<Box<dyn Write>>>,
38 pub stderr: Option<RefCell<Box<dyn Write>>>,
39}
40
41impl Default for Options {
42 fn default() -> Self {
43 Self {
44 skills_roots: Vec::new(),
45 include_bundled_skill: true,
46 cwd: None,
47 stdin: None,
48 stdout: None,
49 stderr: None,
50 }
51 }
52}
53
54const USAGE_LINES: [&str; 6] = [
55 "Usage:",
56 " --skill install [<id> ...] [--agent <agent>] [--scope <scope>] [--force]",
57 " --skill list [--json]",
58 " --skill export <id>",
59 " --skill show <id>",
60 " --skill help",
61];
62
63pub const SKILLFLAG_HELP_TEXT: &str = "Skillflag help\n\nInstall skillflag globally to get both binaries on your PATH:\n npm install -g skillflag\n\nPrefer not to install globally? Use npx for one-off runs:\n npx skillflag list\n npx skillflag install --agent codex --scope repo < ./skill.tar\n\nList available skills:\n tool --skill list\n tool --skill list --json\n\nShow a skill's documentation:\n tool --skill show <id>\n\nExport a skill bundle:\n tool --skill export <id>\n\nInstall a skill bundle:\n tool --skill install [<id> ...] [--agent <agent>] [--scope <scope>]\n tool --skill export <id> | skill-install --agent <agent> --scope <scope>\n\nFor full details, read docs/SKILLFLAG_SPEC.md.";
64
65fn usage() -> String {
66 USAGE_LINES.join("\n")
67}
68
69#[derive(Debug, PartialEq, Eq)]
70enum SkillAction {
71 Install {
72 ids: Option<Vec<String>>,
73 install_args: Vec<String>,
74 },
75 List {
76 json: bool,
77 },
78 Export {
79 id: String,
80 },
81 Show {
82 id: String,
83 },
84 Help,
85}
86
87fn resolve_skill_action_args(argv: &[String]) -> &[String] {
93 if let Some(index) = argv.iter().position(|arg| arg == "--skill") {
94 return &argv[index + 1..];
95 }
96 if argv.is_empty() {
97 argv
98 } else {
99 &argv[1..]
100 }
101}
102
103fn unique_values(values: Vec<String>) -> Vec<String> {
104 let mut out: Vec<String> = Vec::new();
105 for value in values {
106 if !out.contains(&value) {
107 out.push(value);
108 }
109 }
110 out
111}
112
113fn parse_install_ids(values: &[String]) -> (Option<Vec<String>>, Vec<String>) {
114 let mut ids: Vec<String> = Vec::new();
115 let mut index = 0;
116 while index < values.len() {
117 let value = &values[index];
118 if value.starts_with('-') {
119 break;
120 }
121 for part in value.split(',') {
122 let part = part.trim();
123 if !part.is_empty() {
124 ids.push(part.to_string());
125 }
126 }
127 index += 1;
128 }
129 let install_args = values[index..].to_vec();
130 if ids.is_empty() {
131 (None, install_args)
132 } else {
133 (Some(unique_values(ids)), install_args)
134 }
135}
136
137fn parse_skill_args(argv: &[String]) -> Result<SkillAction> {
138 let args = resolve_skill_action_args(argv);
139 let action = args.first();
140 let Some(action) = action.filter(|action| !action.starts_with('-')) else {
141 return Err(SkillflagError::new(format!(
142 "Missing --skill action.\n{}",
143 usage()
144 )));
145 };
146
147 match action.as_str() {
148 "install" => {
149 let (ids, install_args) = parse_install_ids(&args[1..]);
150 Ok(SkillAction::Install { ids, install_args })
151 }
152 "list" => Ok(SkillAction::List {
153 json: args[1..].iter().any(|arg| arg == "--json"),
154 }),
155 "help" => Ok(SkillAction::Help),
156 "export" | "show" => {
157 let id = args.get(1);
158 let Some(id) = id.filter(|id| !id.starts_with('-')) else {
159 return Err(SkillflagError::new(format!(
160 "Missing skill id.\n{}",
161 usage()
162 )));
163 };
164 if action == "export" {
165 Ok(SkillAction::Export { id: id.clone() })
166 } else {
167 Ok(SkillAction::Show { id: id.clone() })
168 }
169 }
170 other => Err(SkillflagError::new(format!(
171 "Unknown --skill action: {other}.\n{}",
172 usage()
173 ))),
174 }
175}
176
177fn resolve_install_skill_ids(
178 ids: Option<Vec<String>>,
179 roots: &[SkillsRoot],
180 stdin: &mut dyn InputStream,
181 stderr: &mut dyn Write,
182) -> Result<Vec<String>> {
183 if let Some(ids) = ids {
184 if !ids.is_empty() {
185 return Ok(ids);
186 }
187 }
188
189 let skills = list_skills(roots);
190 if skills.is_empty() {
191 return Err(SkillflagError::new("No skills are available to install."));
192 }
193 if skills.len() == 1 {
194 return Ok(vec![skills[0].id.clone()]);
195 }
196 if !stdin.is_tty() {
197 return Err(SkillflagError::new(
198 "Multiple skills are available; pass one or more ids with --skill install <id> [...].",
199 ));
200 }
201
202 writeln!(stderr, "Select skills to install:")?;
204 for (index, skill) in skills.iter().enumerate() {
205 match &skill.summary {
206 Some(summary) => writeln!(stderr, " {}) {}\t{summary}", index + 1, skill.id)?,
207 None => writeln!(stderr, " {}) {}", index + 1, skill.id)?,
208 }
209 }
210 write!(stderr, "Skills (numbers or ids, comma-separated): ")?;
211 stderr.flush()?;
212 let line = read_line(stdin)?.ok_or_else(|| SkillflagError::new("Install cancelled."))?;
213
214 let mut selected: Vec<String> = Vec::new();
215 for token in line.split(',') {
216 let token = token.trim();
217 if token.is_empty() {
218 continue;
219 }
220 if let Ok(number) = token.parse::<usize>() {
221 if number >= 1 && number <= skills.len() {
222 selected.push(skills[number - 1].id.clone());
223 continue;
224 }
225 return Err(SkillflagError::new(format!("Invalid selection: {token}")));
226 }
227 if skills.iter().any(|skill| skill.id == *token) {
228 selected.push(token.to_string());
229 continue;
230 }
231 return Err(SkillflagError::new(format!("Invalid selection: {token}")));
232 }
233 if selected.is_empty() {
234 return Err(SkillflagError::new("Install cancelled."));
235 }
236 Ok(unique_values(selected))
237}
238
239fn run_install_action(
240 ids: Option<Vec<String>>,
241 install_args: &[String],
242 roots: &[SkillsRoot],
243 cwd: &std::path::Path,
244 stdin: &mut dyn InputStream,
245 stdout: &mut dyn Write,
246 stderr: &mut dyn Write,
247) -> Result<i32> {
248 let skill_ids = resolve_install_skill_ids(ids, roots, stdin, stderr)?;
249
250 let mut inputs: Vec<InstallInput> = Vec::with_capacity(skill_ids.len());
251 for skill_id in &skill_ids {
252 let skill_dir = resolve_skill_dir_from_roots(roots, skill_id)?;
253 let (tar, _) = build_skill_tar(&skill_dir, skill_id)?;
254 inputs.push(InstallInput::Tar(tar));
255 }
256
257 Ok(run_install_cli_streams(
258 install_args,
259 stdin,
260 stdout,
261 stderr,
262 cwd,
263 &inputs,
264 &skill_ids,
265 &SystemEnv,
266 ))
267}
268
269fn run(
270 argv: &[String],
271 opts: &Options,
272 stdin: &mut dyn InputStream,
273 stdout: &mut dyn Write,
274 stderr: &mut dyn Write,
275) -> Result<i32> {
276 let action = parse_skill_args(argv)?;
277
278 let cwd = opts
279 .cwd
280 .clone()
281 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
282 let mut roots: Vec<SkillsRoot> = resolve_skills_roots(&opts.skills_roots, &cwd)
283 .into_iter()
284 .map(SkillsRoot::Dir)
285 .collect();
286 if opts.include_bundled_skill {
287 roots.push(SkillsRoot::Bundled);
288 }
289
290 match action {
291 SkillAction::Install { ids, install_args } => {
292 run_install_action(ids, &install_args, &roots, &cwd, stdin, stdout, stderr)
293 }
294 SkillAction::List { json } => {
295 if json {
296 let payload = list_skills_json(&roots)?;
297 stdout.write_all(payload.as_bytes())?;
298 } else {
299 let skills = list_skills(&roots);
300 if !skills.is_empty() {
301 let lines: Vec<String> = skills
302 .iter()
303 .map(|skill| match &skill.summary {
304 Some(summary) => format!("{}\t{summary}", skill.id),
305 None => skill.id.clone(),
306 })
307 .collect();
308 stdout.write_all(format!("{}\n", lines.join("\n")).as_bytes())?;
309 }
310 }
311 Ok(0)
312 }
313 SkillAction::Export { id } => {
314 let skill_dir = resolve_skill_dir_from_roots(&roots, &id)?;
315 let (tar, _) = build_skill_tar(&skill_dir, &id)?;
316 stdout.write_all(&tar)?;
317 Ok(0)
318 }
319 SkillAction::Show { id } => {
320 let skill_dir = resolve_skill_dir_from_roots(&roots, &id)?;
321 let content = skill_md_bytes(&skill_dir)?;
322 stdout.write_all(&content)?;
323 Ok(0)
324 }
325 SkillAction::Help => {
326 writeln!(stdout, "{SKILLFLAG_HELP_TEXT}")?;
327 Ok(0)
328 }
329 }
330}
331
332pub fn handle_skillflag(argv: &[String], opts: &Options) -> i32 {
338 let mut stdin_guard;
339 let mut default_stdin;
340 let stdin: &mut dyn InputStream = match &opts.stdin {
341 Some(cell) => {
342 stdin_guard = cell.borrow_mut();
343 &mut **stdin_guard
344 }
345 None => {
346 default_stdin = StdinStream::new();
347 &mut default_stdin
348 }
349 };
350 let mut stdout_guard;
351 let mut default_stdout;
352 let stdout: &mut dyn Write = match &opts.stdout {
353 Some(cell) => {
354 stdout_guard = cell.borrow_mut();
355 &mut **stdout_guard
356 }
357 None => {
358 default_stdout = std::io::stdout();
359 &mut default_stdout
360 }
361 };
362 let mut stderr_guard;
363 let mut default_stderr;
364 let stderr: &mut dyn Write = match &opts.stderr {
365 Some(cell) => {
366 stderr_guard = cell.borrow_mut();
367 &mut **stderr_guard
368 }
369 None => {
370 default_stderr = std::io::stderr();
371 &mut default_stderr
372 }
373 };
374
375 let code = match run(argv, opts, stdin, stdout, stderr) {
376 Ok(code) => code,
377 Err(err) => {
378 let _ = writeln!(stderr, "{}", err.message);
379 err.exit_code
380 }
381 };
382 let _ = stdout.flush();
383 let _ = stderr.flush();
384 code
385}
386
387pub fn maybe_handle_skillflag(argv: &[String], opts: &Options) -> Option<i32> {
392 if !argv.iter().any(|arg| arg == "--skill") {
393 return None;
394 }
395 Some(handle_skillflag(argv, opts))
396}
397
398pub fn standalone_options_from(skills_root_env: Option<&OsStr>) -> Options {
403 match skills_root_env {
404 Some(value) => Options {
405 skills_roots: std::env::split_paths(value)
406 .filter(|path| !path.as_os_str().is_empty())
407 .collect(),
408 include_bundled_skill: false,
409 ..Options::default()
410 },
411 None => Options::default(),
412 }
413}
414
415pub fn standalone_options() -> Options {
417 standalone_options_from(std::env::var_os(SKILLFLAG_SKILLS_ROOT_ENV).as_deref())
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn argv(list: &[&str]) -> Vec<String> {
425 list.iter().map(|s| s.to_string()).collect()
426 }
427
428 #[test]
429 fn action_args_start_after_skill_token() {
430 let action = parse_skill_args(&argv(&["mycli", "run", "--skill", "list", "--json"]));
431 assert_eq!(action.unwrap(), SkillAction::List { json: true });
432 }
433
434 #[test]
435 fn standalone_accepts_action_directly() {
436 let action = parse_skill_args(&argv(&["skillflag", "list"]));
437 assert_eq!(action.unwrap(), SkillAction::List { json: false });
438 }
439
440 #[test]
441 fn missing_action_errors_with_usage() {
442 let err = parse_skill_args(&argv(&["skillflag"])).unwrap_err();
443 assert!(err.message.starts_with("Missing --skill action.\nUsage:"));
444 let err = parse_skill_args(&argv(&["x", "--skill", "--json"])).unwrap_err();
445 assert!(err.message.starts_with("Missing --skill action."));
446 }
447
448 #[test]
449 fn unknown_action_errors() {
450 let err = parse_skill_args(&argv(&["x", "--skill", "frobnicate"])).unwrap_err();
451 assert!(err
452 .message
453 .starts_with("Unknown --skill action: frobnicate.\nUsage:"));
454 }
455
456 #[test]
457 fn export_requires_id() {
458 let err = parse_skill_args(&argv(&["x", "--skill", "export"])).unwrap_err();
459 assert!(err.message.starts_with("Missing skill id.\nUsage:"));
460 let err = parse_skill_args(&argv(&["x", "--skill", "show", "--json"])).unwrap_err();
461 assert!(err.message.starts_with("Missing skill id.\nUsage:"));
462 }
463
464 #[test]
465 fn install_ids_split_trim_dedupe_and_stop_at_flags() {
466 let action = parse_skill_args(&argv(&[
467 "x", "--skill", "install", "a, b", "b,c", "--agent", "codex",
468 ]))
469 .unwrap();
470 assert_eq!(
471 action,
472 SkillAction::Install {
473 ids: Some(vec!["a".into(), "b".into(), "c".into()]),
474 install_args: argv(&["--agent", "codex"]),
475 }
476 );
477 }
478
479 #[test]
480 fn install_without_ids_passes_flags_through() {
481 let action = parse_skill_args(&argv(&["x", "--skill", "install", "--force"])).unwrap();
482 assert_eq!(
483 action,
484 SkillAction::Install {
485 ids: None,
486 install_args: argv(&["--force"]),
487 }
488 );
489 }
490
491 #[test]
492 fn standalone_options_from_env_value() {
493 let opts = standalone_options_from(None);
494 assert!(opts.skills_roots.is_empty());
495 assert!(opts.include_bundled_skill);
496
497 let value = std::env::join_paths(["/roots/a", "/roots/b"]).unwrap();
498 let opts = standalone_options_from(Some(value.as_os_str()));
499 assert_eq!(
500 opts.skills_roots,
501 vec![PathBuf::from("/roots/a"), PathBuf::from("/roots/b")]
502 );
503 assert!(!opts.include_bundled_skill);
504 }
505
506 #[test]
507 fn maybe_handle_returns_none_without_skill_token() {
508 let opts = Options::default();
509 assert!(maybe_handle_skillflag(&argv(&["tool", "build"]), &opts).is_none());
510 }
511}