Skip to main content

starweaver_cli/
launcher.rs

1//! Product launcher for `starweaver` and `sw`.
2
3use std::{
4    env,
5    io::Write as _,
6    path::{Path, PathBuf},
7    process::{Command, Stdio},
8    time::Duration,
9};
10
11use starweaver_core::sdk_name;
12
13use crate::{update_check, CliError, CliResult};
14
15const INSTALL_SCRIPT_URL: &str =
16    "https://raw.githubusercontent.com/Wh1isper/starweaver/main/scripts/install.sh";
17
18const DIRECT_CLI_COMMANDS: &[&str] = &[
19    "run",
20    "session",
21    "profile",
22    "setup",
23    "auth",
24    "skill",
25    "subagent",
26    "mcp",
27    "tools",
28    "tui",
29    "approval",
30    "deferred",
31    "resume",
32    "reset",
33    "diagnostics",
34    "replay-check",
35    "config",
36    "completion",
37];
38
39/// Update command options shared by launcher and direct CLI paths.
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41pub(crate) struct UpdateOptions {
42    /// Print the update plan without downloading or installing.
43    pub(crate) dry_run: bool,
44    /// Reinstall even when the selected release matches the current version.
45    pub(crate) force: bool,
46}
47
48impl UpdateOptions {
49    fn with_env(self) -> Self {
50        Self {
51            dry_run: self.dry_run || env::var_os("STARWEAVER_UPDATE_DRY_RUN").is_some(),
52            force: self.force || env::var_os("STARWEAVER_UPDATE_FORCE").is_some(),
53        }
54    }
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
58struct ParsedUpdateCommand {
59    target: String,
60    options: UpdateOptions,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
64struct UpdateTargetVersion {
65    version: String,
66    source: &'static str,
67    explicit: bool,
68}
69
70enum LauncherDispatch<'a> {
71    Cli(&'a [String]),
72    External(&'a str, &'a [String]),
73}
74
75/// Run launcher from process arguments.
76pub fn run_from_env() -> CliResult<()> {
77    run(env::args())
78}
79
80/// Run launcher from arguments.
81pub fn run(args: impl IntoIterator<Item = String>) -> CliResult<()> {
82    let args = args.into_iter().collect::<Vec<_>>();
83    match launcher_dispatch_parts(&args) {
84        Some(LauncherDispatch::Cli(remaining)) => {
85            let cli_args = std::iter::once("starweaver-cli".to_string()).chain(remaining.to_vec());
86            return crate::run(cli_args);
87        }
88        Some(LauncherDispatch::External(command, remaining)) => {
89            return run_external(command, remaining);
90        }
91        None => {}
92    }
93    let output = command_output(args)?;
94    print!("{output}");
95    Ok(())
96}
97
98/// Return launcher command output.
99pub fn command_output(args: impl IntoIterator<Item = String>) -> CliResult<String> {
100    let mut args = args.into_iter();
101    let program = display_program_name(args.next().as_deref());
102    match args.next().as_deref() {
103        None | Some("--help" | "-h") => Ok(launcher_help(&program)),
104        Some("help") => launcher_help_command(&program, args.collect()),
105        Some("version" | "--version" | "-V") => {
106            let remaining = args.collect::<Vec<_>>();
107            version_output(&program, &remaining)
108        }
109        Some("doctor") => {
110            let remaining = args.collect::<Vec<_>>();
111            doctor_output(&program, &remaining)
112        }
113        Some("update") => update_component_from_args(&program, args.collect()),
114        Some("cli") => {
115            let remaining = args.collect::<Vec<_>>();
116            if remaining.first().is_some_and(|arg| arg == "update") {
117                return update_component_from_args(
118                    &program,
119                    remaining.into_iter().skip(1).collect(),
120                );
121            }
122            let cli_args = std::iter::once("starweaver-cli".to_string()).chain(remaining);
123            crate::command_output(cli_args)
124        }
125        Some(command) if is_direct_cli_arg(command) => {
126            let cli_args = std::iter::once("starweaver-cli".to_string())
127                .chain(std::iter::once(command.to_string()))
128                .chain(args);
129            crate::command_output(cli_args)
130        }
131        Some(command) => dispatch_external(command, args.collect()),
132    }
133}
134
135fn launcher_dispatch_parts(args: &[String]) -> Option<LauncherDispatch<'_>> {
136    let command = args.get(1)?;
137    match command.as_str() {
138        "help" | "--help" | "-h" | "version" | "--version" | "-V" | "doctor" | "update" => None,
139        "cli" => Some(LauncherDispatch::Cli(&args[2..])),
140        command if is_direct_cli_arg(command) => Some(LauncherDispatch::Cli(&args[1..])),
141        _ => Some(LauncherDispatch::External(command, &args[2..])),
142    }
143}
144
145fn is_direct_cli_arg(command: &str) -> bool {
146    is_cli_option(command) || DIRECT_CLI_COMMANDS.contains(&command)
147}
148
149fn is_cli_option(command: &str) -> bool {
150    command.starts_with('-') && !matches!(command, "--help" | "-h" | "--version" | "-V")
151}
152
153fn display_program_name(program: Option<&str>) -> String {
154    program
155        .and_then(|value| Path::new(value).file_name())
156        .and_then(|value| value.to_str())
157        .filter(|value| !value.is_empty())
158        .map_or("sw", strip_windows_exe_suffix)
159        .to_string()
160}
161
162fn strip_windows_exe_suffix(value: &str) -> &str {
163    let Some(suffix) = value.get(value.len().saturating_sub(4)..) else {
164        return value;
165    };
166    if suffix.eq_ignore_ascii_case(".exe") {
167        value.get(..value.len() - 4).unwrap_or(value)
168    } else {
169        value
170    }
171}
172
173fn launcher_help(program: &str) -> String {
174    format!(
175        "\
176Starweaver product launcher
177
178Usage:
179  {program} [COMMAND] [ARGS...]
180  {program} -p \"prompt\"
181  {program} run \"prompt\"
182  {program} cli [ARGS...]
183
184Commands:
185  cli          Open the local CLI/TUI or pass through CLI arguments
186  run          Run a prompt directly
187  session      Manage local sessions
188  config       Get or set configuration
189  rpc          Run the standalone JSON-RPC host
190  update       Update installed CLI artifacts
191  doctor       Print launcher diagnostics
192  version      Print SDK identity
193  help         Print this help or CLI command help
194
195Examples:
196  {program} -p \"summarize this repository\" --output text
197  {program} run \"hello\" --output silent
198  {program} session list
199  {program} update --dry-run
200  {program} rpc stdio
201
202Use `{program} cli --help` for the full local CLI surface.
203Use `{program} <command> --help` for direct CLI command help.
204"
205    )
206}
207
208fn launcher_help_command(program: &str, topics: Vec<String>) -> CliResult<String> {
209    let Some(topic) = topics.first() else {
210        return Ok(launcher_help(program));
211    };
212    if topic == "cli" {
213        let cli_args = std::iter::once("starweaver-cli".to_string())
214            .chain(topics.into_iter().skip(1))
215            .chain(std::iter::once("--help".to_string()));
216        return crate::command_output(cli_args);
217    }
218    if topic == "update" {
219        return Ok(update_help(program));
220    }
221    if topic == "doctor" {
222        return Ok(doctor_help(program));
223    }
224    if topic == "version" {
225        return Ok(version_help(program));
226    }
227    if DIRECT_CLI_COMMANDS.contains(&topic.as_str()) {
228        let cli_args = std::iter::once("starweaver-cli".to_string())
229            .chain(std::iter::once("help".to_string()))
230            .chain(topics);
231        return crate::command_output(cli_args);
232    }
233    Ok(launcher_help(program))
234}
235
236fn version_output(program: &str, args: &[String]) -> CliResult<String> {
237    if args
238        .iter()
239        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
240    {
241        return Ok(version_help(program));
242    }
243    if args.is_empty() {
244        Ok(format!("{}\n", sdk_name()))
245    } else {
246        Err(CliError::Usage(
247            "version does not accept positional arguments".to_string(),
248        ))
249    }
250}
251
252fn version_help(program: &str) -> String {
253    format!(
254        "\
255Print SDK identity.
256
257Usage:
258  {program} version
259"
260    )
261}
262
263fn doctor_output(program: &str, args: &[String]) -> CliResult<String> {
264    if args
265        .iter()
266        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
267    {
268        return Ok(doctor_help(program));
269    }
270    if !args.is_empty() {
271        return Err(CliError::Usage(
272            "doctor does not accept positional arguments".to_string(),
273        ));
274    }
275    Ok(format!(
276        "sdk={}\nlauncher=starweaver\ncli=starweaver-cli\ninstall_script={}\n",
277        sdk_name(),
278        INSTALL_SCRIPT_URL
279    ))
280}
281
282fn doctor_help(program: &str) -> String {
283    format!(
284        "\
285Print launcher diagnostics.
286
287Usage:
288  {program} doctor
289"
290    )
291}
292
293/// Update an installed Starweaver component.
294pub fn update_component(component: &str) -> CliResult<String> {
295    update_component_with_env_options(component, UpdateOptions::default())
296}
297
298pub(crate) fn update_component_with_env_options(
299    component: &str,
300    options: UpdateOptions,
301) -> CliResult<String> {
302    let install_dir = env::var("STARWEAVER_INSTALL_DIR").unwrap_or_else(|_| default_install_dir());
303    update_component_with_options(component, &install_dir, options.with_env())
304}
305
306pub(crate) fn update_component_with_options(
307    component: &str,
308    install_dir: &str,
309    options: UpdateOptions,
310) -> CliResult<String> {
311    let normalized = match component {
312        "cli" | "starweaver-cli" | "starweaver" | "launcher" | "rpc" | "starweaver-rpc" => "cli",
313        other => return Err(CliError::Usage(format!("unknown update target {other}"))),
314    };
315    let command = update_shell_command(normalized, install_dir);
316    let current_version = env!("CARGO_PKG_VERSION");
317    if options.dry_run {
318        let mut output =
319            update_output_prefix(normalized, current_version, None, options, "dry-run");
320        output.push_str("command=");
321        output.push_str(&command);
322        output.push('\n');
323        return Ok(output);
324    }
325    let target_version = resolve_update_target_version();
326    if !options.force {
327        if let Some(target_version) = &target_version {
328            if update_should_skip(current_version, target_version) {
329                return Ok(update_output_prefix(
330                    normalized,
331                    current_version,
332                    Some(target_version),
333                    options,
334                    "up-to-date",
335                ));
336            }
337        }
338    }
339    let script = fetch_install_script()?;
340    let mut child = Command::new("sh")
341        .env("STARWEAVER_COMPONENTS", normalized)
342        .env("STARWEAVER_INSTALL_DIR", install_dir)
343        .stdin(Stdio::piped())
344        .stdout(Stdio::piped())
345        .stderr(Stdio::piped())
346        .spawn()
347        .map_err(|error| CliError::Run(error.to_string()))?;
348    child
349        .stdin
350        .as_mut()
351        .ok_or_else(|| CliError::Run("installer stdin unavailable".to_string()))?
352        .write_all(script.as_bytes())
353        .map_err(|error| CliError::Run(error.to_string()))?;
354    let output = child
355        .wait_with_output()
356        .map_err(|error| CliError::Run(error.to_string()))?;
357    if output.status.success() {
358        let stdout = String::from_utf8_lossy(&output.stdout);
359        let mut update = update_output_prefix(
360            normalized,
361            current_version,
362            target_version.as_ref(),
363            options,
364            "updated",
365        );
366        update.push_str(&stdout);
367        Ok(update)
368    } else {
369        let stderr = String::from_utf8_lossy(&output.stderr);
370        Err(CliError::Run(format!(
371            "update target {normalized} exited with status {}: {stderr}",
372            output.status
373        )))
374    }
375}
376
377fn fetch_install_script() -> CliResult<String> {
378    let runtime = tokio::runtime::Builder::new_current_thread()
379        .enable_all()
380        .build()
381        .map_err(|error| CliError::Run(error.to_string()))?;
382    runtime.block_on(async {
383        let response = reqwest::Client::new()
384            .get(INSTALL_SCRIPT_URL)
385            .header(reqwest::header::USER_AGENT, "starweaver-cli")
386            .timeout(Duration::from_secs(10))
387            .send()
388            .await
389            .map_err(|error| CliError::Run(error.to_string()))?;
390        let status = response.status();
391        let body = response
392            .text()
393            .await
394            .map_err(|error| CliError::Run(error.to_string()))?;
395        if status.is_success() {
396            Ok(body)
397        } else {
398            Err(CliError::Run(format!(
399                "failed to download installer from {INSTALL_SCRIPT_URL}: {body}"
400            )))
401        }
402    })
403}
404
405fn update_component_from_args(program: &str, args: Vec<String>) -> CliResult<String> {
406    if args
407        .iter()
408        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
409    {
410        return Ok(update_help(program));
411    }
412    let command = parse_update_args(args)?;
413    update_component_with_env_options(&command.target, command.options)
414}
415
416fn update_help(program: &str) -> String {
417    format!(
418        "\
419Update installed Starweaver CLI artifacts.
420
421Usage:
422  {program} update [TARGET] [--dry-run] [--force]
423
424Arguments:
425  TARGET       Update target, defaults to cli
426
427Options:
428  --dry-run    Print the update plan without downloading or installing
429  -f, --force  Reinstall even when the selected release matches the current version
430  -h, --help   Print help
431"
432    )
433}
434
435fn parse_update_args(args: Vec<String>) -> CliResult<ParsedUpdateCommand> {
436    let mut target = None;
437    let mut options = UpdateOptions::default();
438    for arg in args {
439        if arg == "--dry-run" {
440            options.dry_run = true;
441        } else if arg == "--force" || arg == "-f" {
442            options.force = true;
443        } else if arg.starts_with('-') {
444            return Err(CliError::Usage(format!("unknown update option {arg}")));
445        } else if target.replace(arg).is_some() {
446            return Err(CliError::Usage(
447                "update accepts at most one target".to_string(),
448            ));
449        }
450    }
451    Ok(ParsedUpdateCommand {
452        target: target.unwrap_or_else(|| "cli".to_string()),
453        options,
454    })
455}
456
457fn resolve_update_target_version() -> Option<UpdateTargetVersion> {
458    if let Some(version) = requested_version_from_env() {
459        return Some(UpdateTargetVersion {
460            version,
461            source: "env",
462            explicit: true,
463        });
464    }
465    let cache = update_check::fetch_latest_release().ok()?;
466    cache.latest_version.map(|version| UpdateTargetVersion {
467        version,
468        source: "latest",
469        explicit: false,
470    })
471}
472
473fn requested_version_from_env() -> Option<String> {
474    let version = env::var("STARWEAVER_VERSION").ok()?;
475    let version = version.trim();
476    if version.is_empty() || version == "latest" {
477        None
478    } else {
479        Some(version.trim_start_matches('v').to_string())
480    }
481}
482
483fn update_should_skip(current_version: &str, target_version: &UpdateTargetVersion) -> bool {
484    if target_version.explicit {
485        update_check::versions_match(current_version, &target_version.version)
486    } else {
487        !update_check::update_is_newer(current_version, &target_version.version)
488    }
489}
490
491fn update_output_prefix(
492    normalized: &str,
493    current_version: &str,
494    target_version: Option<&UpdateTargetVersion>,
495    options: UpdateOptions,
496    status: &str,
497) -> String {
498    let mut output =
499        format!("update=github-release\ntarget={normalized}\ncurrent_version={current_version}\n");
500    if let Some(target_version) = target_version {
501        output.push_str("target_version=");
502        output.push_str(&target_version.version);
503        output.push_str("\ntarget_version_source=");
504        output.push_str(target_version.source);
505        output.push('\n');
506    }
507    if options.force {
508        output.push_str("force=true\n");
509    }
510    output.push_str("status=");
511    output.push_str(status);
512    output.push('\n');
513    output
514}
515
516fn update_shell_command(normalized: &str, install_dir: &str) -> String {
517    let mut env_parts = inherited_update_env();
518    env_parts.push(format!("STARWEAVER_COMPONENTS={normalized}"));
519    env_parts.push(format!(
520        "STARWEAVER_INSTALL_DIR={}",
521        shell_quote(install_dir)
522    ));
523    format!("download {INSTALL_SCRIPT_URL} | {} sh", env_parts.join(" "))
524}
525
526fn inherited_update_env() -> Vec<String> {
527    [
528        "STARWEAVER_VERSION",
529        "STARWEAVER_GITHUB_REPO",
530        "STARWEAVER_REPO",
531    ]
532    .iter()
533    .filter_map(|key| {
534        let value = env::var(key).ok()?;
535        (!value.is_empty()).then(|| format!("{key}={}", shell_quote(&value)))
536    })
537    .collect()
538}
539
540fn shell_quote(value: &str) -> String {
541    format!("'{}'", value.replace('\'', "'\\''"))
542}
543
544fn default_install_dir() -> String {
545    env::var_os("HOME")
546        .map_or_else(|| PathBuf::from("."), PathBuf::from)
547        .join(".local/bin")
548        .display()
549        .to_string()
550}
551
552fn dispatch_external(command: &str, args: Vec<String>) -> CliResult<String> {
553    let binary = format!("starweaver-{command}");
554    let program = find_installed_binary(&binary).unwrap_or_else(|| PathBuf::from(&binary));
555    let output = Command::new(&program)
556        .args(args)
557        .output()
558        .map_err(|error| CliError::Usage(format!("unknown command {command}: {error}")))?;
559    if output.status.success() {
560        String::from_utf8(output.stdout).map_err(|error| CliError::Run(error.to_string()))
561    } else {
562        let stderr = String::from_utf8_lossy(&output.stderr);
563        Err(CliError::Run(format!(
564            "{binary} exited with status {}: {stderr}",
565            output.status
566        )))
567    }
568}
569
570fn run_external(command: &str, args: &[String]) -> CliResult<()> {
571    let binary = format!("starweaver-{command}");
572    let program = find_installed_binary(&binary).unwrap_or_else(|| PathBuf::from(&binary));
573    let status = Command::new(&program)
574        .args(args)
575        .status()
576        .map_err(|error| CliError::Usage(format!("unknown command {command}: {error}")))?;
577    if status.success() {
578        Ok(())
579    } else {
580        Err(CliError::Run(format!(
581            "{binary} exited with status {status}"
582        )))
583    }
584}
585
586fn find_installed_binary(binary: &str) -> Option<PathBuf> {
587    let current = env::current_exe().ok()?;
588    let install_dir = current.parent()?;
589    let candidate = install_dir.join(binary);
590    candidate.exists().then_some(candidate)
591}
592
593#[cfg(test)]
594mod tests {
595    #![allow(clippy::unwrap_used)]
596
597    use super::*;
598
599    #[test]
600    fn launcher_dispatches_cli_in_process() {
601        let temp = tempfile::tempdir().unwrap();
602        let args = vec![
603            "starweaver".to_string(),
604            "cli".to_string(),
605            "--store".to_string(),
606            temp.path().join("sessions.sqlite").display().to_string(),
607            "-p".to_string(),
608            "hello".to_string(),
609            "--profile".to_string(),
610            "general".to_string(),
611            "--output".to_string(),
612            "display-jsonl".to_string(),
613        ];
614        let output = command_output(args).unwrap();
615        let first: serde_json::Value =
616            serde_json::from_str(output.lines().next().unwrap()).unwrap();
617        assert_eq!(first["schema"], "starweaver.display.v1");
618        assert_eq!(first["type"], "RUN_QUEUED");
619    }
620
621    #[test]
622    fn launcher_reports_version_doctor_and_update_plan() {
623        assert_eq!(
624            command_output(["sw".to_string(), "version".to_string()]).unwrap(),
625            "starweaver-agent-sdk\n"
626        );
627        let windows_help = command_output(["sw.exe".to_string(), "--help".to_string()]).unwrap();
628        assert!(windows_help.contains("Use `sw cli --help`"));
629        let help = command_output(["sw".to_string()]).unwrap();
630        assert!(help.contains("Starweaver product launcher"));
631        assert!(help.contains("Usage:"));
632        assert!(help.contains("sw run \"hello\""));
633        let flag_help = command_output(["sw".to_string(), "--help".to_string()]).unwrap();
634        assert!(flag_help.contains("Use `sw cli --help`"));
635        let command_help = command_output(["sw".to_string(), "help".to_string()]).unwrap();
636        assert!(command_help.contains("Commands:"));
637        let doctor = command_output(["starweaver".to_string(), "doctor".to_string()]).unwrap();
638        assert!(doctor.contains("launcher=starweaver"));
639        assert!(doctor.contains("cli=starweaver-cli"));
640        let doctor_help = command_output([
641            "starweaver".to_string(),
642            "doctor".to_string(),
643            "--help".to_string(),
644        ])
645        .unwrap();
646        assert!(doctor_help.contains("starweaver doctor"));
647        let temp = tempfile::tempdir().unwrap();
648        let install_dir = temp.path().display().to_string();
649        let update = update_component_with_options("cli", &install_dir, dry_run_options()).unwrap();
650        assert!(update.contains("update=github-release"));
651        assert!(update.contains("target=cli"));
652        assert!(update.contains("current_version="));
653        assert!(update.contains("status=dry-run"));
654        let quoted =
655            update_component_with_options("cli", "dir with ' quote", dry_run_options()).unwrap();
656        assert!(quoted.contains("target=cli"));
657        assert!(quoted.contains("'\\''"));
658        let update_help =
659            command_output(["sw".to_string(), "update".to_string(), "--help".to_string()]).unwrap();
660        assert!(update_help.contains("sw update [TARGET]"));
661        let run_help =
662            command_output(["sw".to_string(), "run".to_string(), "--help".to_string()]).unwrap();
663        assert!(run_help.contains("Run a prompt"));
664    }
665
666    #[test]
667    fn launcher_run_dispatches_long_lived_commands_without_capture() {
668        let args = vec![
669            "starweaver".to_string(),
670            "rpc".to_string(),
671            "stdio".to_string(),
672        ];
673        let Some(LauncherDispatch::External(command, remaining)) = launcher_dispatch_parts(&args)
674        else {
675            panic!("expected external dispatch");
676        };
677        assert_eq!(command, "rpc");
678        assert_eq!(remaining, &["stdio".to_string()]);
679
680        let direct_run = vec!["sw".to_string(), "run".to_string(), "hello".to_string()];
681        let Some(LauncherDispatch::Cli(remaining)) = launcher_dispatch_parts(&direct_run) else {
682            panic!("expected cli dispatch");
683        };
684        assert_eq!(remaining, &["run".to_string(), "hello".to_string()]);
685
686        let prompt_flag = vec!["sw".to_string(), "-p".to_string(), "hello".to_string()];
687        let Some(LauncherDispatch::Cli(remaining)) = launcher_dispatch_parts(&prompt_flag) else {
688            panic!("expected cli dispatch");
689        };
690        assert_eq!(remaining, &["-p".to_string(), "hello".to_string()]);
691    }
692
693    #[test]
694    fn cli_update_uses_cli_target() {
695        let temp = tempfile::tempdir().unwrap();
696        let output = update_component_with_options(
697            "starweaver-cli",
698            &temp.path().display().to_string(),
699            dry_run_options(),
700        )
701        .unwrap();
702        assert!(output.contains("target=cli"));
703        assert!(output.contains("STARWEAVER_COMPONENTS=cli"));
704
705        let starweaver =
706            update_component_with_options("starweaver", "/tmp/install", dry_run_options()).unwrap();
707        assert!(starweaver.contains("target=cli"));
708        let launcher =
709            update_component_with_options("launcher", "/tmp/install", dry_run_options()).unwrap();
710        assert!(launcher.contains("target=cli"));
711        let rpc =
712            update_component_with_options("starweaver-rpc", "/tmp/install", dry_run_options())
713                .unwrap();
714        assert!(rpc.contains("target=cli"));
715        assert!(matches!(
716            update_component_with_options("unknown", "/tmp/install", dry_run_options()),
717            Err(CliError::Usage(message)) if message.contains("unknown update target unknown")
718        ));
719    }
720
721    #[test]
722    fn launcher_update_args_parse_flags_and_target() {
723        assert_eq!(
724            parse_update_args(vec!["--dry-run".to_string(), "--force".to_string()]).unwrap(),
725            ParsedUpdateCommand {
726                target: "cli".to_string(),
727                options: UpdateOptions {
728                    dry_run: true,
729                    force: true,
730                },
731            }
732        );
733        assert_eq!(
734            parse_update_args(vec!["rpc".to_string(), "-f".to_string()]).unwrap(),
735            ParsedUpdateCommand {
736                target: "rpc".to_string(),
737                options: UpdateOptions {
738                    dry_run: false,
739                    force: true,
740                },
741            }
742        );
743        assert!(matches!(
744            parse_update_args(vec!["--unknown".to_string()]),
745            Err(CliError::Usage(message)) if message.contains("unknown update option --unknown")
746        ));
747        assert!(matches!(
748            parse_update_args(vec!["cli".to_string(), "rpc".to_string()]),
749            Err(CliError::Usage(message)) if message.contains("at most one target")
750        ));
751    }
752
753    #[test]
754    fn launcher_update_skip_rule_distinguishes_latest_from_pinned_versions() {
755        let latest_same = UpdateTargetVersion {
756            version: "0.0.1".to_string(),
757            source: "latest",
758            explicit: false,
759        };
760        let latest_newer = UpdateTargetVersion {
761            version: "0.0.2".to_string(),
762            source: "latest",
763            explicit: false,
764        };
765        let pinned_same = UpdateTargetVersion {
766            version: "0.0.1".to_string(),
767            source: "env",
768            explicit: true,
769        };
770        let pinned_older = UpdateTargetVersion {
771            version: "0.0.0".to_string(),
772            source: "env",
773            explicit: true,
774        };
775        assert!(update_should_skip("0.0.1", &latest_same));
776        assert!(!update_should_skip("0.0.1", &latest_newer));
777        assert!(update_should_skip("0.0.1", &pinned_same));
778        assert!(!update_should_skip("0.0.1", &pinned_older));
779    }
780
781    #[test]
782    fn launcher_command_output_covers_version_aliases_and_external_errors() {
783        assert!(matches!(
784            command_output(["starweaver".to_string(), "unknown".to_string()]),
785            Err(CliError::Usage(message)) if message.contains("unknown command unknown")
786        ));
787        assert!(matches!(
788            command_output([
789                "starweaver".to_string(),
790                "external".to_string(),
791                "doctor".to_string(),
792            ]),
793            Err(CliError::Usage(message)) if message.contains("unknown command external")
794        ));
795        assert!(command_output(["sw".to_string()])
796            .unwrap()
797            .contains("Usage:"));
798        assert!(command_output(["sw".to_string(), "--version".to_string()])
799            .unwrap()
800            .contains("starweaver-agent-sdk"));
801        assert!(command_output(["sw".to_string(), "-V".to_string()])
802            .unwrap()
803            .contains("starweaver-agent-sdk"));
804    }
805
806    const fn dry_run_options() -> UpdateOptions {
807        UpdateOptions {
808            dry_run: true,
809            force: false,
810        }
811    }
812}