Skip to main content

starweaver_cli/
lib.rs

1#![allow(clippy::missing_errors_doc)]
2//! CLI-first local product surface for Starweaver.
3
4mod 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
31/// Run the CLI from process arguments.
32pub fn run_from_env() -> CliResult<()> {
33    run(env::args())
34}
35
36/// Run the CLI from an argument iterator.
37pub 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
48/// Return command output for tests and host integrations.
49pub 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}