Skip to main content

skillflag/
dispatch.rs

1//! The producer `--skill` interface: argument parsing and action dispatch.
2//!
3//! Mirrors `typescript/src/skillflag.ts`. Output formats are byte-exact with
4//! the reference implementation.
5
6use 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
21/// Environment variable honored by the standalone producer binary: a
22/// path-list of skills roots (colon-separated on Unix). When set, the
23/// bundled skill is excluded.
24pub const SKILLFLAG_SKILLS_ROOT_ENV: &str = "SKILLFLAG_SKILLS_ROOT";
25
26/// Options for [`handle_skillflag`].
27///
28/// Streams are injectable for tests and embedders; when `None`, the process
29/// stdio is used. `skills_roots` may be empty (e.g. the standalone binary
30/// serves only the bundled skill). The bundled root, when included, is
31/// appended LAST (lowest precedence).
32pub 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
87/// Determine where the action arguments start.
88///
89/// When a `--skill` token is present, the action starts right after it.
90/// Otherwise `argv[0]` is treated as the program name and skipped, so the
91/// standalone binary accepts the action directly (`skillflag list`).
92fn 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    // Simplified interactive selection (numbered prompt on stderr).
203    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
332/// Handle a `--skill` invocation. Returns the process exit code.
333///
334/// `argv` is the full argument vector; when a `--skill` token is present the
335/// action starts right after it, otherwise `argv[0]` is treated as the
336/// program name and the action starts at `argv[1]`.
337pub 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
387/// Handle a `--skill` invocation when one is present.
388///
389/// Returns `None` (doing nothing) when `argv` contains no `--skill` token,
390/// otherwise `Some(exit_code)`.
391pub 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
398/// Producer options for the standalone binary, derived from an optional
399/// `SKILLFLAG_SKILLS_ROOT` value: when set, its paths (split with the
400/// platform path-list separator) become the skills roots and the bundled
401/// skill is excluded; when unset, only the bundled skill is served.
402pub 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
415/// [`standalone_options_from`] using the real process environment.
416pub 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}