1#![allow(clippy::missing_errors_doc)]
2mod args;
5mod client_state;
6mod clipboard;
7mod config;
8mod environment;
9mod error;
10pub mod launcher;
11mod local_store;
12mod oauth;
13mod profiles;
14mod prompt_input;
15mod rpc;
16mod runner;
17mod service;
18mod slash_commands;
19mod tui;
20mod update_check;
21
22use std::env;
23
24pub use args::{Cli, CliCommand, OutputMode, SessionCommand};
25pub use config::{CliConfig, ConfigResolver};
26pub use error::{CliError, CliResult};
27pub use local_store::{LocalStore, TrimReport};
28pub use service::CliService;
29pub use slash_commands::SlashCommandDefinition;
30
31pub fn run_from_env() -> CliResult<()> {
33 run(env::args())
34}
35
36pub fn run(args: impl IntoIterator<Item = String>) -> CliResult<()> {
38 let cli = args::parse(args)?;
39 let config = ConfigResolver::default().resolve(&cli)?;
40 if matches!(cli.command, Some(CliCommand::Rpc(_))) {
41 return rpc::run_stdio(&config);
42 }
43 let output = command_output_from_parts(cli, config)?;
44 print!("{output}");
45 Ok(())
46}
47
48pub fn command_output(args: impl IntoIterator<Item = String>) -> CliResult<String> {
50 let cli = args::parse(args)?;
51 let config = ConfigResolver::default().resolve(&cli)?;
52 command_output_from_parts(cli, config)
53}
54
55fn command_output_from_parts(cli: Cli, config: CliConfig) -> CliResult<String> {
56 let show_update_hint = should_show_update_hint(&cli, &config);
57 if show_update_hint {
58 update_check::spawn_update_check_if_due(&config);
59 }
60 let hint = show_update_hint.then(|| update_check::update_hint(&config));
61 let service = CliService::open(config)?;
62 let mut output = service.execute(cli)?;
63 if let Some(Some(hint)) = hint {
64 output.push_str(&hint);
65 }
66 Ok(output)
67}
68
69const fn should_show_update_hint(cli: &Cli, config: &CliConfig) -> bool {
70 matches!(config.default_output, OutputMode::Text | OutputMode::Silent)
71 && matches!(
72 &cli.command,
73 None | Some(
74 CliCommand::Version
75 | CliCommand::Diagnostics
76 | CliCommand::ReplayCheck
77 | CliCommand::Run(_),
78 )
79 )
80}
81
82#[cfg(test)]
83mod tests {
84 #![allow(clippy::unwrap_used)]
85
86 use std::{ffi::OsString, io, path::Path};
87
88 use super::*;
89
90 fn output(root: &Path, raw_args: &[&str]) -> CliResult<String> {
91 let mut command_args = vec!["starweaver-cli".to_string()];
92 command_args.extend(raw_args.iter().map(|arg| (*arg).to_string()));
93 let cli = args::parse(command_args)?;
94 let config = ConfigResolver::for_tests(root).resolve(&cli)?;
95 CliService::open(config)?.execute(cli)
96 }
97
98 #[test]
99 #[allow(clippy::too_many_lines)]
100 fn args_and_error_helpers_cover_edge_branches() {
101 let run = args::RunCommand {
102 prompt: Some(" explicit ".to_string()),
103 prompt_parts: vec!["ignored".to_string()],
104 continue_session: false,
105 session: None,
106 new_session: false,
107 run: None,
108 branch_from: None,
109 profile: None,
110 output: None,
111 hitl: None,
112 worker: None,
113 worker_label: None,
114 worktree: None,
115 worktree_name: None,
116 branch: None,
117 session_affinity_id: None,
118 };
119 assert_eq!(run.prompt_text().unwrap(), " explicit ");
120
121 let joined = args::RunCommand {
122 prompt: None,
123 prompt_parts: vec!["hello".to_string(), "world".to_string()],
124 continue_session: false,
125 session: None,
126 new_session: false,
127 run: None,
128 branch_from: None,
129 profile: None,
130 output: None,
131 hitl: None,
132 worker: None,
133 worker_label: None,
134 worktree: None,
135 worktree_name: None,
136 branch: None,
137 session_affinity_id: None,
138 };
139 assert_eq!(joined.prompt_text().unwrap(), "hello world");
140
141 let empty = args::RunCommand {
142 prompt: Some(" ".to_string()),
143 prompt_parts: Vec::new(),
144 continue_session: false,
145 session: None,
146 new_session: false,
147 run: None,
148 branch_from: None,
149 profile: None,
150 output: None,
151 hitl: None,
152 worker: None,
153 worker_label: None,
154 worktree: None,
155 worktree_name: None,
156 branch: None,
157 session_affinity_id: None,
158 };
159 assert!(
160 matches!(empty.prompt_text(), Err(CliError::Usage(message)) if message.contains("run -p"))
161 );
162
163 let parsed = args::parse_os([
164 OsString::from("starweaver-cli"),
165 OsString::from("run"),
166 OsString::from("hello"),
167 ])
168 .unwrap();
169 assert!(matches!(parsed.command, Some(args::CliCommand::Run(_))));
170
171 let parsed = args::parse_os([
172 OsString::from("starweaver-cli"),
173 OsString::from("-p"),
174 OsString::from("hello"),
175 OsString::from("-s"),
176 OsString::from("session_test"),
177 OsString::from("--profile"),
178 OsString::from("coding"),
179 OsString::from("--worker"),
180 OsString::from("off"),
181 OsString::from("--worktree"),
182 OsString::from("feature"),
183 OsString::from("--branch"),
184 OsString::from("feature/work"),
185 ])
186 .unwrap();
187 assert_eq!(parsed.session.as_deref(), Some("session_test"));
188 assert_eq!(parsed.profile.as_deref(), Some("coding"));
189 assert_eq!(parsed.worker.as_deref(), Some("off"));
190 assert_eq!(parsed.worktree.as_deref(), Some("feature"));
191 assert_eq!(parsed.branch.as_deref(), Some("feature/work"));
192
193 let parsed = args::parse_os([
194 OsString::from("starweaver-cli"),
195 OsString::from("-p"),
196 OsString::from("hello"),
197 OsString::from("--worker"),
198 OsString::from("-w"),
199 OsString::from("--worker-label"),
200 OsString::from("executor"),
201 OsString::from("--worktree-name"),
202 OsString::from("feature"),
203 ])
204 .unwrap();
205 assert_eq!(parsed.worker.as_deref(), Some("true"));
206 assert_eq!(parsed.worker_label.as_deref(), Some("executor"));
207 assert_eq!(parsed.worktree.as_deref(), Some("true"));
208 assert_eq!(parsed.worktree_name.as_deref(), Some("feature"));
209
210 let parse_error =
211 args::parse_os([OsString::from("starweaver-cli"), OsString::from("--bad")]);
212 assert!(
213 matches!(parse_error, Err(CliError::Usage(message)) if message.contains("unexpected argument"))
214 );
215
216 assert!(format!(
217 "{}",
218 CliError::from(serde_json::from_str::<serde_json::Value>("{").unwrap_err())
219 )
220 .contains("serialization error"));
221 assert!(format!(
222 "{}",
223 CliError::from(toml::from_str::<toml::Value>("=").unwrap_err())
224 )
225 .contains("configuration error"));
226 assert!(format!(
227 "{}",
228 CliError::from(toml::to_string(&f64::NAN).unwrap_err())
229 )
230 .contains("configuration error"));
231 let io_error = error::io_error(
232 "/tmp/missing",
233 io::Error::new(io::ErrorKind::NotFound, "gone"),
234 );
235 assert!(format!("{io_error}").contains("filesystem error at /tmp/missing"));
236 }
237
238 #[test]
239 fn version_and_diagnostics_work() {
240 let temp = tempfile::tempdir().unwrap();
241 assert_eq!(
242 output(temp.path(), &["version"]).unwrap(),
243 "starweaver-agent-sdk\n"
244 );
245 let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
246 assert!(diagnostics.contains("sdk=starweaver-agent-sdk"));
247 assert!(diagnostics.contains("database_path="));
248 assert!(diagnostics.contains("model_profiles="));
249 assert!(diagnostics.contains("wal=true"));
250 }
251
252 #[test]
253 fn config_model_profiles_work() {
254 let temp = tempfile::tempdir().unwrap();
255 let global = temp.path().join("global");
256 std::fs::create_dir_all(&global).unwrap();
257 std::fs::write(
258 global.join("config.toml"),
259 r#"
260[general]
261model = "homelab@openai-responses:gpt-5.5"
262model_settings = "openai_responses_high"
263model_cfg = "gpt5_270k"
264
265[model_profiles.codex-subs]
266label = "Codex Subs"
267model = "oauth@codex:gpt-5.5"
268model_settings = "openai_responses_high"
269model_cfg = "gpt5_270k"
270
271[providers.homelab]
272base_url = "https://gateway.example/v1"
273max_tokens_parameter = "omit"
274
275[oauth_refresh]
276enabled = true
277interval_seconds = 42
278failure_retry_seconds = 7
279refresh_on_startup = false
280
281[env]
282HOMELAB_API_KEY = "test-key"
283"#,
284 )
285 .unwrap();
286 let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
287 assert!(diagnostics.contains("profile=default_model"));
288 assert!(diagnostics.contains("model_profiles=1"));
289 assert_eq!(
290 output(
291 temp.path(),
292 &["config", "get", "providers.homelab.max_tokens_parameter"]
293 )
294 .unwrap(),
295 "omit\n"
296 );
297 assert_eq!(
298 output(
299 temp.path(),
300 &["config", "get", "oauth_refresh.interval_seconds"]
301 )
302 .unwrap(),
303 "42\n"
304 );
305 assert_eq!(
306 output(
307 temp.path(),
308 &["config", "get", "oauth_refresh.refresh_on_startup"]
309 )
310 .unwrap(),
311 "false\n"
312 );
313 let profiles = output(temp.path(), &["profile", "list"]).unwrap();
314 assert!(profiles.contains("default_model"));
315 assert!(profiles.contains("codex-subs"));
316 let default_profile = output(temp.path(), &["profile", "show", "default_model"]).unwrap();
317 assert!(default_profile.contains("model_id: homelab@openai-responses:gpt-5.5"));
318 assert!(default_profile.contains("settings_preset: openai_responses_high"));
319 assert!(default_profile.contains("config_preset: gpt5_270k"));
320 assert!(default_profile.contains("# source: config"));
321 }
322
323 #[test]
324 fn configured_slash_commands_layer_aliases_and_redact_unmapped_metadata() {
325 let temp = tempfile::tempdir().unwrap();
326 let global = temp.path().join("global");
327 let project = temp.path().join("project/.starweaver");
328 std::fs::create_dir_all(&global).unwrap();
329 std::fs::create_dir_all(&project).unwrap();
330 std::fs::write(
331 global.join("config.toml"),
332 r#"
333[commands.review]
334description = "Global review"
335aliases = ["rv", "bad alias", "model"]
336prompt = "global secret prompt"
337
338[commands.other]
339aliases = ["review"]
340prompt = "Other command"
341"#,
342 )
343 .unwrap();
344 std::fs::write(
345 project.join("config.toml"),
346 r#"
347[commands.review]
348description = "Project review"
349aliases = ["pr"]
350prompt = "Project review prompt"
351
352[commands.bad_name]
353prompt = "ignored because underscore is valid"
354
355[commands."bad name"]
356prompt = "ignored invalid name"
357"#,
358 )
359 .unwrap();
360
361 let cli = args::parse(["starweaver-cli".to_string(), "diagnostics".to_string()]).unwrap();
362 let config = ConfigResolver::for_tests(temp.path())
363 .resolve(&cli)
364 .unwrap();
365 let review = config.slash_commands.get("review").unwrap();
366 assert_eq!(review.prompt, "Project review prompt");
367 assert_eq!(review.aliases, vec!["pr".to_string()]);
368 assert!(config.slash_commands.contains_key("pr"));
369 assert!(!config.slash_commands.contains_key("rv"));
370 assert!(!config.slash_commands.contains_key("bad alias"));
371 assert!(!config.slash_commands.contains_key("model"));
372 assert!(config.slash_commands.contains_key("bad_name"));
373 assert!(!config.slash_commands.contains_key("bad name"));
374 let unmapped = output(temp.path(), &["config", "get", "metadata.unmapped"]).unwrap();
375 assert!(!unmapped.contains("global secret prompt"));
376 assert!(!unmapped.contains("Project review prompt"));
377 assert!(!unmapped.contains("commands"));
378 }
379
380 #[test]
381 fn configured_subagent_inherits_profile_model() {
382 let temp = tempfile::tempdir().unwrap();
383 let global = temp.path().join("global");
384 let project = temp.path().join("project/.starweaver");
385 std::fs::create_dir_all(global.join("subagents")).unwrap();
386 std::fs::write(
387 global.join("config.toml"),
388 r#"
389[general]
390model = "local_echo"
391
392[subagents]
393dirs = ["subagents"]
394"#,
395 )
396 .unwrap();
397 std::fs::write(
398 global.join("subagents/helper.md"),
399 r"---
400name: helper
401description: Helper subagent
402model: inherit
403---
404You are a helper.
405",
406 )
407 .unwrap();
408
409 let cli = args::parse([
410 "starweaver-cli".to_string(),
411 "-p".to_string(),
412 "hello".to_string(),
413 "--profile".to_string(),
414 "default_model".to_string(),
415 ])
416 .unwrap();
417 let config = ConfigResolver::for_tests(temp.path())
418 .resolve(&cli)
419 .unwrap();
420 assert_eq!(config.project_dir, project);
421 let profile = crate::profiles::resolve_profile(&config, Some("default_model")).unwrap();
422 let agent = profile.build_agent().unwrap();
423 let tools = agent.tools().names();
424 assert!(tools.contains(&"delegate".to_string()));
425 assert!(tools.contains(&"subagent_info".to_string()));
426
427 let run = output(
428 temp.path(),
429 &[
430 "-p",
431 "hello",
432 "--profile",
433 "default_model",
434 "--output",
435 "silent",
436 ],
437 )
438 .unwrap();
439 assert!(run.contains("status=completed"));
440 }
441
442 #[test]
443 fn headless_run_expands_configured_slash_commands() {
444 let temp = tempfile::tempdir().unwrap();
445 let global = temp.path().join("global");
446 std::fs::create_dir_all(&global).unwrap();
447 std::fs::write(
448 global.join("config.toml"),
449 r#"
450[general]
451model = "local_echo"
452
453[commands.review]
454description = "Review the current changes"
455aliases = ["rv"]
456prompt = "Review carefully."
457"#,
458 )
459 .unwrap();
460
461 let run = output(
462 temp.path(),
463 &[
464 "-p",
465 "/rv staged diff",
466 "--profile",
467 "default_model",
468 "--output",
469 "text",
470 ],
471 )
472 .unwrap();
473 assert!(run.contains("local echo: Review carefully."));
474 assert!(run.contains("User instruction: staged diff"));
475
476 let sessions = output(temp.path(), &["session", "list"]).unwrap();
477 let session: serde_json::Value =
478 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
479 let session_id = session["session_id"].as_str().unwrap();
480 let cli = args::parse([
481 "starweaver-cli".to_string(),
482 "session".to_string(),
483 "list".to_string(),
484 ])
485 .unwrap();
486 let config = ConfigResolver::for_tests(temp.path())
487 .resolve(&cli)
488 .unwrap();
489 let store = LocalStore::open(&config).unwrap();
490 let run_id = session["head_run_id"].as_str().unwrap();
491 let run_record = store.load_run(session_id, run_id).unwrap();
492 let run_value = serde_json::to_value(&run_record).unwrap();
493 assert_eq!(
494 run_value["input"][0]["text"],
495 "Review carefully.\n\nUser instruction: staged diff"
496 );
497 assert_eq!(run_value["metadata"]["cli.slash_command.name"], "review");
498 assert_eq!(run_value["metadata"]["cli.slash_command.invoked"], "rv");
499 }
500
501 #[test]
502 fn headless_run_creates_session_and_run() {
503 let temp = tempfile::tempdir().unwrap();
504 let first = output(temp.path(), &["-p", "hello", "--output", "display-jsonl"]).unwrap();
505 let first_message: serde_json::Value =
506 serde_json::from_str(first.lines().next().unwrap()).unwrap();
507 assert_eq!(first_message["schema"], "starweaver.display.v1");
508 assert_eq!(first_message["type"], "RUN_QUEUED");
509 let agui_temp = tempfile::tempdir().unwrap();
510 let agui = output(agui_temp.path(), &["-p", "hello", "--output", "agui-jsonl"]).unwrap();
511 let agui_events = agui
512 .lines()
513 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
514 .collect::<Vec<_>>();
515 assert!(agui_events
516 .iter()
517 .any(|event| event["type"] == "RUN_STARTED"));
518 assert!(agui_events
519 .iter()
520 .any(|event| event["type"] == "TEXT_MESSAGE_CHUNK"));
521 assert!(agui_events
522 .iter()
523 .any(|event| event["type"] == "RUN_FINISHED"));
524 let sessions = output(temp.path(), &["session", "list"]).unwrap();
525 assert!(sessions.contains("session_"));
526 let value: serde_json::Value =
527 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
528 assert_eq!(value["run_count"], 1);
529 assert_eq!(value["head_success_run_id"], value["head_run_id"]);
530 }
531
532 #[test]
533 fn continue_appends_run_under_existing_session() {
534 let temp = tempfile::tempdir().unwrap();
535 output(temp.path(), &["-p", "one"]).unwrap();
536 output(temp.path(), &["-p", "two", "--continue"]).unwrap();
537 let sessions = output(temp.path(), &["session", "list"]).unwrap();
538 let value: serde_json::Value =
539 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
540 assert_eq!(value["run_count"], 2);
541 let session_id = value["session_id"].as_str().unwrap();
542 let show = output(temp.path(), &["session", "show", session_id]).unwrap();
543 assert_eq!(show.lines().count(), 3);
544 }
545
546 #[test]
547 fn replay_and_trim_work() {
548 let temp = tempfile::tempdir().unwrap();
549 output(temp.path(), &["-p", "one"]).unwrap();
550 output(temp.path(), &["-p", "two", "--continue"]).unwrap();
551 output(temp.path(), &["-p", "three", "--continue"]).unwrap();
552 let sessions = output(temp.path(), &["session", "list"]).unwrap();
553 let session: serde_json::Value =
554 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
555 let session_id = session["session_id"].as_str().unwrap();
556 let replay = output(temp.path(), &["session", "replay", session_id]).unwrap();
557 assert!(replay.contains("RUN_FINISHED"));
558 let dry = output(
559 temp.path(),
560 &[
561 "session",
562 "trim",
563 "--session",
564 session_id,
565 "--keep-runs",
566 "1",
567 "--dry-run",
568 ],
569 )
570 .unwrap();
571 let report: serde_json::Value = serde_json::from_str(dry.trim()).unwrap();
572 assert_eq!(report["runs_to_trim"], 2);
573 output(
574 temp.path(),
575 &[
576 "session",
577 "trim",
578 "--session",
579 session_id,
580 "--keep-runs",
581 "1",
582 ],
583 )
584 .unwrap();
585 let show = output(temp.path(), &["session", "show", session_id]).unwrap();
586 assert_eq!(show.lines().count(), 2);
587 }
588}