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::{CliError, CliResult, update_check};
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        && let Some(target_version) = &target_version
328        && update_should_skip(current_version, target_version)
329    {
330        return Ok(update_output_prefix(
331            normalized,
332            current_version,
333            Some(target_version),
334            options,
335            "up-to-date",
336        ));
337    }
338    let script = fetch_install_script()?;
339    let mut child = Command::new("sh")
340        .env("STARWEAVER_COMPONENTS", normalized)
341        .env("STARWEAVER_INSTALL_DIR", install_dir)
342        .stdin(Stdio::piped())
343        .stdout(Stdio::piped())
344        .stderr(Stdio::piped())
345        .spawn()
346        .map_err(|error| CliError::Run(error.to_string()))?;
347    child
348        .stdin
349        .as_mut()
350        .ok_or_else(|| CliError::Run("installer stdin unavailable".to_string()))?
351        .write_all(script.as_bytes())
352        .map_err(|error| CliError::Run(error.to_string()))?;
353    let output = child
354        .wait_with_output()
355        .map_err(|error| CliError::Run(error.to_string()))?;
356    if output.status.success() {
357        let stdout = String::from_utf8_lossy(&output.stdout);
358        let mut update = update_output_prefix(
359            normalized,
360            current_version,
361            target_version.as_ref(),
362            options,
363            "updated",
364        );
365        update.push_str(&stdout);
366        Ok(update)
367    } else {
368        let stderr = String::from_utf8_lossy(&output.stderr);
369        Err(CliError::Run(format!(
370            "update target {normalized} exited with status {}: {stderr}",
371            output.status
372        )))
373    }
374}
375
376fn fetch_install_script() -> CliResult<String> {
377    let runtime = tokio::runtime::Builder::new_current_thread()
378        .enable_all()
379        .build()
380        .map_err(|error| CliError::Run(error.to_string()))?;
381    runtime.block_on(async {
382        let response = reqwest::Client::new()
383            .get(INSTALL_SCRIPT_URL)
384            .header(reqwest::header::USER_AGENT, "starweaver-cli")
385            .timeout(Duration::from_secs(10))
386            .send()
387            .await
388            .map_err(|error| CliError::Run(error.to_string()))?;
389        let status = response.status();
390        let body = response
391            .text()
392            .await
393            .map_err(|error| CliError::Run(error.to_string()))?;
394        if status.is_success() {
395            Ok(body)
396        } else {
397            Err(CliError::Run(format!(
398                "failed to download installer from {INSTALL_SCRIPT_URL}: {body}"
399            )))
400        }
401    })
402}
403
404fn update_component_from_args(program: &str, args: Vec<String>) -> CliResult<String> {
405    if args
406        .iter()
407        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
408    {
409        return Ok(update_help(program));
410    }
411    let command = parse_update_args(args)?;
412    update_component_with_env_options(&command.target, command.options)
413}
414
415fn update_help(program: &str) -> String {
416    format!(
417        "\
418Update installed Starweaver CLI artifacts.
419
420Usage:
421  {program} update [TARGET] [--dry-run] [--force]
422
423Arguments:
424  TARGET       Update target, defaults to cli
425
426Options:
427  --dry-run    Print the update plan without downloading or installing
428  -f, --force  Reinstall even when the selected release matches the current version
429  -h, --help   Print help
430"
431    )
432}
433
434fn parse_update_args(args: Vec<String>) -> CliResult<ParsedUpdateCommand> {
435    let mut target = None;
436    let mut options = UpdateOptions::default();
437    for arg in args {
438        if arg == "--dry-run" {
439            options.dry_run = true;
440        } else if arg == "--force" || arg == "-f" {
441            options.force = true;
442        } else if arg.starts_with('-') {
443            return Err(CliError::Usage(format!("unknown update option {arg}")));
444        } else if target.replace(arg).is_some() {
445            return Err(CliError::Usage(
446                "update accepts at most one target".to_string(),
447            ));
448        }
449    }
450    Ok(ParsedUpdateCommand {
451        target: target.unwrap_or_else(|| "cli".to_string()),
452        options,
453    })
454}
455
456fn resolve_update_target_version() -> Option<UpdateTargetVersion> {
457    if let Some(version) = requested_version_from_env() {
458        return Some(UpdateTargetVersion {
459            version,
460            source: "env",
461            explicit: true,
462        });
463    }
464    let cache = update_check::fetch_latest_release().ok()?;
465    cache.latest_version.map(|version| UpdateTargetVersion {
466        version,
467        source: "latest",
468        explicit: false,
469    })
470}
471
472fn requested_version_from_env() -> Option<String> {
473    let version = env::var("STARWEAVER_VERSION").ok()?;
474    let version = version.trim();
475    if version.is_empty() || version == "latest" {
476        None
477    } else {
478        Some(version.trim_start_matches('v').to_string())
479    }
480}
481
482fn update_should_skip(current_version: &str, target_version: &UpdateTargetVersion) -> bool {
483    if target_version.explicit {
484        update_check::versions_match(current_version, &target_version.version)
485    } else {
486        !update_check::update_is_newer(current_version, &target_version.version)
487    }
488}
489
490fn update_output_prefix(
491    normalized: &str,
492    current_version: &str,
493    target_version: Option<&UpdateTargetVersion>,
494    options: UpdateOptions,
495    status: &str,
496) -> String {
497    let mut output =
498        format!("update=github-release\ntarget={normalized}\ncurrent_version={current_version}\n");
499    if let Some(target_version) = target_version {
500        output.push_str("target_version=");
501        output.push_str(&target_version.version);
502        output.push_str("\ntarget_version_source=");
503        output.push_str(target_version.source);
504        output.push('\n');
505    }
506    if options.force {
507        output.push_str("force=true\n");
508    }
509    output.push_str("status=");
510    output.push_str(status);
511    output.push('\n');
512    output
513}
514
515fn update_shell_command(normalized: &str, install_dir: &str) -> String {
516    let mut env_parts = inherited_update_env();
517    env_parts.push(format!("STARWEAVER_COMPONENTS={normalized}"));
518    env_parts.push(format!(
519        "STARWEAVER_INSTALL_DIR={}",
520        shell_quote(install_dir)
521    ));
522    format!("download {INSTALL_SCRIPT_URL} | {} sh", env_parts.join(" "))
523}
524
525fn inherited_update_env() -> Vec<String> {
526    [
527        "STARWEAVER_VERSION",
528        "STARWEAVER_GITHUB_REPO",
529        "STARWEAVER_REPO",
530    ]
531    .iter()
532    .filter_map(|key| {
533        let value = env::var(key).ok()?;
534        (!value.is_empty()).then(|| format!("{key}={}", shell_quote(&value)))
535    })
536    .collect()
537}
538
539fn shell_quote(value: &str) -> String {
540    format!("'{}'", value.replace('\'', "'\\''"))
541}
542
543fn default_install_dir() -> String {
544    env::var_os("HOME")
545        .map_or_else(|| PathBuf::from("."), PathBuf::from)
546        .join(".local/bin")
547        .display()
548        .to_string()
549}
550
551fn dispatch_external(command: &str, args: Vec<String>) -> CliResult<String> {
552    let binary = format!("starweaver-{command}");
553    let program = find_installed_binary(&binary).unwrap_or_else(|| PathBuf::from(&binary));
554    let output = Command::new(&program)
555        .args(args)
556        .output()
557        .map_err(|error| CliError::Usage(format!("unknown command {command}: {error}")))?;
558    if output.status.success() {
559        String::from_utf8(output.stdout).map_err(|error| CliError::Run(error.to_string()))
560    } else {
561        let stderr = String::from_utf8_lossy(&output.stderr);
562        Err(CliError::Run(format!(
563            "{binary} exited with status {}: {stderr}",
564            output.status
565        )))
566    }
567}
568
569fn run_external(command: &str, args: &[String]) -> CliResult<()> {
570    let binary = format!("starweaver-{command}");
571    let program = find_installed_binary(&binary).unwrap_or_else(|| PathBuf::from(&binary));
572    let status = Command::new(&program)
573        .args(args)
574        .status()
575        .map_err(|error| CliError::Usage(format!("unknown command {command}: {error}")))?;
576    if status.success() {
577        Ok(())
578    } else {
579        Err(CliError::Run(format!(
580            "{binary} exited with status {status}"
581        )))
582    }
583}
584
585fn find_installed_binary(binary: &str) -> Option<PathBuf> {
586    let current = env::current_exe().ok()?;
587    let install_dir = current.parent()?;
588    let candidate = install_dir.join(binary);
589    candidate.exists().then_some(candidate)
590}
591
592#[cfg(test)]
593mod tests {
594    #![allow(clippy::unwrap_used)]
595
596    use super::*;
597
598    #[test]
599    fn launcher_dispatches_cli_in_process() {
600        let temp = tempfile::tempdir().unwrap();
601        let args = vec![
602            "starweaver".to_string(),
603            "cli".to_string(),
604            "--store".to_string(),
605            temp.path().join("sessions.sqlite").display().to_string(),
606            "-p".to_string(),
607            "hello".to_string(),
608            "--profile".to_string(),
609            "general".to_string(),
610            "--output".to_string(),
611            "display-jsonl".to_string(),
612        ];
613        let output = command_output(args).unwrap();
614        let first: serde_json::Value =
615            serde_json::from_str(output.lines().next().unwrap()).unwrap();
616        assert_eq!(first["schema"], "starweaver.display.v1");
617        assert_eq!(first["type"], "RUN_QUEUED");
618    }
619
620    #[test]
621    fn launcher_reports_version_doctor_and_update_plan() {
622        assert_eq!(
623            command_output(["sw".to_string(), "version".to_string()]).unwrap(),
624            "starweaver-agent-sdk\n"
625        );
626        let windows_help = command_output(["sw.exe".to_string(), "--help".to_string()]).unwrap();
627        assert!(windows_help.contains("Use `sw cli --help`"));
628        let help = command_output(["sw".to_string()]).unwrap();
629        assert!(help.contains("Starweaver product launcher"));
630        assert!(help.contains("Usage:"));
631        assert!(help.contains("sw run \"hello\""));
632        let flag_help = command_output(["sw".to_string(), "--help".to_string()]).unwrap();
633        assert!(flag_help.contains("Use `sw cli --help`"));
634        let command_help = command_output(["sw".to_string(), "help".to_string()]).unwrap();
635        assert!(command_help.contains("Commands:"));
636        let doctor = command_output(["starweaver".to_string(), "doctor".to_string()]).unwrap();
637        assert!(doctor.contains("launcher=starweaver"));
638        assert!(doctor.contains("cli=starweaver-cli"));
639        let doctor_help = command_output([
640            "starweaver".to_string(),
641            "doctor".to_string(),
642            "--help".to_string(),
643        ])
644        .unwrap();
645        assert!(doctor_help.contains("starweaver doctor"));
646        let temp = tempfile::tempdir().unwrap();
647        let install_dir = temp.path().display().to_string();
648        let update = update_component_with_options("cli", &install_dir, dry_run_options()).unwrap();
649        assert!(update.contains("update=github-release"));
650        assert!(update.contains("target=cli"));
651        assert!(update.contains("current_version="));
652        assert!(update.contains("status=dry-run"));
653        let quoted =
654            update_component_with_options("cli", "dir with ' quote", dry_run_options()).unwrap();
655        assert!(quoted.contains("target=cli"));
656        assert!(quoted.contains("'\\''"));
657        let update_help =
658            command_output(["sw".to_string(), "update".to_string(), "--help".to_string()]).unwrap();
659        assert!(update_help.contains("sw update [TARGET]"));
660        let run_help =
661            command_output(["sw".to_string(), "run".to_string(), "--help".to_string()]).unwrap();
662        assert!(run_help.contains("Run a prompt"));
663    }
664
665    #[test]
666    fn launcher_run_dispatches_long_lived_commands_without_capture() {
667        let args = vec![
668            "starweaver".to_string(),
669            "rpc".to_string(),
670            "stdio".to_string(),
671        ];
672        let Some(LauncherDispatch::External(command, remaining)) = launcher_dispatch_parts(&args)
673        else {
674            panic!("expected external dispatch");
675        };
676        assert_eq!(command, "rpc");
677        assert_eq!(remaining, &["stdio".to_string()]);
678
679        let direct_run = vec!["sw".to_string(), "run".to_string(), "hello".to_string()];
680        let Some(LauncherDispatch::Cli(remaining)) = launcher_dispatch_parts(&direct_run) else {
681            panic!("expected cli dispatch");
682        };
683        assert_eq!(remaining, &["run".to_string(), "hello".to_string()]);
684
685        let prompt_flag = vec!["sw".to_string(), "-p".to_string(), "hello".to_string()];
686        let Some(LauncherDispatch::Cli(remaining)) = launcher_dispatch_parts(&prompt_flag) else {
687            panic!("expected cli dispatch");
688        };
689        assert_eq!(remaining, &["-p".to_string(), "hello".to_string()]);
690    }
691
692    #[test]
693    fn cli_update_uses_cli_target() {
694        let temp = tempfile::tempdir().unwrap();
695        let output = update_component_with_options(
696            "starweaver-cli",
697            &temp.path().display().to_string(),
698            dry_run_options(),
699        )
700        .unwrap();
701        assert!(output.contains("target=cli"));
702        assert!(output.contains("STARWEAVER_COMPONENTS=cli"));
703
704        let starweaver =
705            update_component_with_options("starweaver", "/tmp/install", dry_run_options()).unwrap();
706        assert!(starweaver.contains("target=cli"));
707        let launcher =
708            update_component_with_options("launcher", "/tmp/install", dry_run_options()).unwrap();
709        assert!(launcher.contains("target=cli"));
710        let rpc =
711            update_component_with_options("starweaver-rpc", "/tmp/install", dry_run_options())
712                .unwrap();
713        assert!(rpc.contains("target=cli"));
714        assert!(matches!(
715            update_component_with_options("unknown", "/tmp/install", dry_run_options()),
716            Err(CliError::Usage(message)) if message.contains("unknown update target unknown")
717        ));
718    }
719
720    #[test]
721    fn launcher_update_args_parse_flags_and_target() {
722        assert_eq!(
723            parse_update_args(vec!["--dry-run".to_string(), "--force".to_string()]).unwrap(),
724            ParsedUpdateCommand {
725                target: "cli".to_string(),
726                options: UpdateOptions {
727                    dry_run: true,
728                    force: true,
729                },
730            }
731        );
732        assert_eq!(
733            parse_update_args(vec!["rpc".to_string(), "-f".to_string()]).unwrap(),
734            ParsedUpdateCommand {
735                target: "rpc".to_string(),
736                options: UpdateOptions {
737                    dry_run: false,
738                    force: true,
739                },
740            }
741        );
742        assert!(matches!(
743            parse_update_args(vec!["--unknown".to_string()]),
744            Err(CliError::Usage(message)) if message.contains("unknown update option --unknown")
745        ));
746        assert!(matches!(
747            parse_update_args(vec!["cli".to_string(), "rpc".to_string()]),
748            Err(CliError::Usage(message)) if message.contains("at most one target")
749        ));
750    }
751
752    #[test]
753    fn launcher_update_skip_rule_distinguishes_latest_from_pinned_versions() {
754        let latest_same = UpdateTargetVersion {
755            version: "0.0.1".to_string(),
756            source: "latest",
757            explicit: false,
758        };
759        let latest_newer = UpdateTargetVersion {
760            version: "0.0.2".to_string(),
761            source: "latest",
762            explicit: false,
763        };
764        let pinned_same = UpdateTargetVersion {
765            version: "0.0.1".to_string(),
766            source: "env",
767            explicit: true,
768        };
769        let pinned_older = UpdateTargetVersion {
770            version: "0.0.0".to_string(),
771            source: "env",
772            explicit: true,
773        };
774        assert!(update_should_skip("0.0.1", &latest_same));
775        assert!(!update_should_skip("0.0.1", &latest_newer));
776        assert!(update_should_skip("0.0.1", &pinned_same));
777        assert!(!update_should_skip("0.0.1", &pinned_older));
778    }
779
780    #[test]
781    fn launcher_command_output_covers_version_aliases_and_external_errors() {
782        assert!(matches!(
783            command_output(["starweaver".to_string(), "unknown".to_string()]),
784            Err(CliError::Usage(message)) if message.contains("unknown command unknown")
785        ));
786        assert!(matches!(
787            command_output([
788                "starweaver".to_string(),
789                "external".to_string(),
790                "doctor".to_string(),
791            ]),
792            Err(CliError::Usage(message)) if message.contains("unknown command external")
793        ));
794        assert!(
795            command_output(["sw".to_string()])
796                .unwrap()
797                .contains("Usage:")
798        );
799        assert!(
800            command_output(["sw".to_string(), "--version".to_string()])
801                .unwrap()
802                .contains("starweaver-agent-sdk")
803        );
804        assert!(
805            command_output(["sw".to_string(), "-V".to_string()])
806                .unwrap()
807                .contains("starweaver-agent-sdk")
808        );
809    }
810
811    const fn dry_run_options() -> UpdateOptions {
812        UpdateOptions {
813            dry_run: true,
814            force: false,
815        }
816    }
817}