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::{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#[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 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}