1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41pub(crate) struct UpdateOptions {
42 pub(crate) dry_run: bool,
44 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
75pub fn run_from_env() -> CliResult<()> {
77 run(env::args())
78}
79
80pub 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
98pub 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
293pub 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}