@@ -4,7 +4,11 @@ use std::path::Path;
44use std:: path:: PathBuf ;
55use std:: process:: Command as ProcessCommand ;
66
7- use rexos:: { config:: RexosConfig , memory:: MemoryStore , paths:: RexosPaths } ;
7+ use rexos:: {
8+ config:: { ProviderKind , RexosConfig } ,
9+ memory:: MemoryStore ,
10+ paths:: RexosPaths ,
11+ } ;
812
913mod doctor;
1014
@@ -41,6 +45,21 @@ struct Cli {
4145enum Command {
4246 /// Initialize ~/.rexos (config + database)
4347 Init ,
48+ /// One-command onboarding check (init + config + doctor + optional first task)
49+ Onboard {
50+ /// Workspace directory for the first verification run
51+ #[ arg( long, default_value = "rexos-onboard-demo" ) ]
52+ workspace : PathBuf ,
53+ /// Prompt for the first verification run
54+ #[ arg( long, default_value = "Create hello.txt with the word hi" ) ]
55+ prompt : String ,
56+ /// Skip running the first agent task and only run setup checks
57+ #[ arg( long) ]
58+ skip_agent : bool ,
59+ /// Timeout for doctor probes (milliseconds)
60+ #[ arg( long, default_value_t = 1500 ) ]
61+ timeout_ms : u64 ,
62+ } ,
4463 /// Diagnose common setup issues (config, providers, browser, tooling)
4564 Doctor {
4665 /// Print JSON output (machine-readable)
@@ -254,6 +273,93 @@ async fn main() -> anyhow::Result<()> {
254273 MemoryStore :: open_or_create ( & paths) ?;
255274 println ! ( "Initialized {}" , paths. base_dir. display( ) ) ;
256275 }
276+ Command :: Onboard {
277+ workspace,
278+ prompt,
279+ skip_agent,
280+ timeout_ms,
281+ } => {
282+ let paths = RexosPaths :: discover ( ) ?;
283+ paths. ensure_dirs ( ) ?;
284+ RexosConfig :: ensure_default ( & paths) ?;
285+ MemoryStore :: open_or_create ( & paths) ?;
286+ println ! ( "Initialized {}" , paths. base_dir. display( ) ) ;
287+
288+ let report = validate_config ( & paths) ;
289+ if !report. valid {
290+ println ! ( "config invalid: {}" , report. config_path) ;
291+ for err in & report. errors {
292+ println ! ( "- {err}" ) ;
293+ }
294+ std:: process:: exit ( 1 ) ;
295+ }
296+ println ! ( "config valid: {}" , report. config_path) ;
297+
298+ let doctor_report = doctor:: run_doctor ( doctor:: DoctorOptions {
299+ paths : paths. clone ( ) ,
300+ timeout : std:: time:: Duration :: from_millis ( timeout_ms) ,
301+ } )
302+ . await ?;
303+ println ! ( "{}" , doctor_report. to_text( ) ) ;
304+ if doctor_report. summary . error > 0 {
305+ std:: process:: exit ( 1 ) ;
306+ }
307+
308+ std:: fs:: create_dir_all ( & workspace)
309+ . with_context ( || format ! ( "create workspace: {}" , workspace. display( ) ) ) ?;
310+ println ! ( "workspace ready: {}" , workspace. display( ) ) ;
311+
312+ if skip_agent {
313+ println ! ( "onboard done (skipped first agent run)" ) ;
314+ return Ok ( ( ) ) ;
315+ }
316+
317+ let cfg = RexosConfig :: load ( & paths) ?;
318+ let mut cfg = cfg;
319+ if cfg. router . coding . provider . trim ( ) == "ollama" {
320+ let maybe_ollama = cfg. providers . get ( "ollama" ) . cloned ( ) ;
321+ if let Some ( ollama) = maybe_ollama {
322+ if ollama. kind == ProviderKind :: OpenAiCompatible {
323+ if let Ok ( models) =
324+ fetch_openai_compat_models ( & ollama. base_url , timeout_ms) . await
325+ {
326+ if let Some ( selected) =
327+ select_onboard_model ( & ollama. default_model , & models)
328+ {
329+ if selected != ollama. default_model {
330+ if let Some ( p) = cfg. providers . get_mut ( "ollama" ) {
331+ p. default_model = selected. clone ( ) ;
332+ }
333+ println ! (
334+ "onboard: ollama default model '{}' not available, using '{}'" ,
335+ ollama. default_model, selected
336+ ) ;
337+ }
338+ }
339+ }
340+ }
341+ }
342+ }
343+
344+ let memory = MemoryStore :: open_or_create ( & paths) ?;
345+ let llms = rexos:: llm:: registry:: LlmRegistry :: from_config ( & cfg) ?;
346+ let router = rexos:: router:: ModelRouter :: new ( cfg. router ) ;
347+ let agent = rexos:: agent:: AgentRuntime :: new ( memory, llms, router) ;
348+
349+ let session_id = rexos:: harness:: resolve_session_id ( & workspace) ?;
350+ let out = agent
351+ . run_session (
352+ workspace. clone ( ) ,
353+ & session_id,
354+ None ,
355+ & prompt,
356+ rexos:: router:: TaskKind :: Coding ,
357+ )
358+ . await ?;
359+ println ! ( "{out}" ) ;
360+ eprintln ! ( "[rexos] session_id={session_id}" ) ;
361+ println ! ( "onboard done (first agent run completed)" ) ;
362+ }
257363 Command :: Doctor {
258364 json,
259365 strict,
@@ -574,6 +680,63 @@ fn validate_config(paths: &RexosPaths) -> ConfigValidationReport {
574680 }
575681}
576682
683+ fn select_onboard_model ( preferred : & str , available : & [ String ] ) -> Option < String > {
684+ if available. is_empty ( ) {
685+ return None ;
686+ }
687+ let preferred = preferred. trim ( ) ;
688+ if !preferred. is_empty ( ) {
689+ if let Some ( hit) = available
690+ . iter ( )
691+ . find ( |m| m. trim ( ) . eq_ignore_ascii_case ( preferred) )
692+ {
693+ return Some ( hit. clone ( ) ) ;
694+ }
695+ }
696+
697+ if let Some ( chat_like) = available. iter ( ) . find ( |m| {
698+ let lower = m. to_ascii_lowercase ( ) ;
699+ !lower. contains ( "embed" )
700+ } ) {
701+ return Some ( chat_like. clone ( ) ) ;
702+ }
703+ Some ( available[ 0 ] . clone ( ) )
704+ }
705+
706+ async fn fetch_openai_compat_models ( base_url : & str , timeout_ms : u64 ) -> anyhow:: Result < Vec < String > > {
707+ let endpoint = format ! ( "{}/models" , base_url. trim_end_matches( '/' ) ) ;
708+ let client = reqwest:: Client :: builder ( )
709+ . timeout ( std:: time:: Duration :: from_millis ( timeout_ms. max ( 500 ) ) )
710+ . build ( )
711+ . context ( "build model probe http client" ) ?;
712+ let res = client. get ( & endpoint) . send ( ) . await ?;
713+ if !res. status ( ) . is_success ( ) {
714+ anyhow:: bail!( "GET {endpoint} -> {}" , res. status( ) ) ;
715+ }
716+ let v: serde_json:: Value = res. json ( ) . await ?;
717+ let mut out = Vec :: new ( ) ;
718+ if let Some ( arr) = v. get ( "data" ) . and_then ( |x| x. as_array ( ) ) {
719+ for item in arr {
720+ if let Some ( id) = item. get ( "id" ) . and_then ( |x| x. as_str ( ) ) {
721+ let id = id. trim ( ) ;
722+ if !id. is_empty ( ) {
723+ out. push ( id. to_string ( ) ) ;
724+ continue ;
725+ }
726+ }
727+ if let Some ( name) = item. get ( "name" ) . and_then ( |x| x. as_str ( ) ) {
728+ let name = name. trim ( ) ;
729+ if !name. is_empty ( ) {
730+ out. push ( name. to_string ( ) ) ;
731+ }
732+ }
733+ }
734+ }
735+ out. sort ( ) ;
736+ out. dedup ( ) ;
737+ Ok ( out)
738+ }
739+
577740fn parse_release_tag_version ( tag : & str ) -> Option < String > {
578741 let tag = tag. trim ( ) ;
579742 let version = tag. strip_prefix ( 'v' ) ?;
@@ -866,6 +1029,16 @@ mod tests {
8661029 ) ;
8671030 }
8681031
1032+ #[ test]
1033+ fn cli_parses_onboard_subcommand ( ) {
1034+ let parsed =
1035+ Cli :: try_parse_from ( [ "rexos" , "onboard" , "--workspace" , "rexos-onboard-demo" ] ) ;
1036+ assert ! (
1037+ parsed. is_ok( ) ,
1038+ "expected `rexos onboard` to parse, got: {parsed:?}"
1039+ ) ;
1040+ }
1041+
8691042 #[ test]
8701043 fn release_metadata_check_passes_when_versions_match ( ) {
8711044 let cargo = r#"
@@ -933,4 +1106,32 @@ edition = "2021"
9331106 "expected parse error, got {report:?}"
9341107 ) ;
9351108 }
1109+
1110+ #[ test]
1111+ fn select_onboard_model_prefers_configured_when_available ( ) {
1112+ let selected = select_onboard_model (
1113+ "llama3.2" ,
1114+ & [ "qwen3:4b" . to_string ( ) , "llama3.2" . to_string ( ) ] ,
1115+ ) ;
1116+ assert_eq ! ( selected. as_deref( ) , Some ( "llama3.2" ) ) ;
1117+ }
1118+
1119+ #[ test]
1120+ fn select_onboard_model_falls_back_to_first_non_embedding ( ) {
1121+ let selected = select_onboard_model (
1122+ "llama3.2" ,
1123+ & [
1124+ "nomic-embed-text:latest" . to_string ( ) ,
1125+ "qwen3:4b" . to_string ( ) ,
1126+ ] ,
1127+ ) ;
1128+ assert_eq ! ( selected. as_deref( ) , Some ( "qwen3:4b" ) ) ;
1129+ }
1130+
1131+ #[ test]
1132+ fn select_onboard_model_uses_first_when_only_embedding_exists ( ) {
1133+ let selected =
1134+ select_onboard_model ( "llama3.2" , & [ "nomic-embed-text:latest" . to_string ( ) ] ) ;
1135+ assert_eq ! ( selected. as_deref( ) , Some ( "nomic-embed-text:latest" ) ) ;
1136+ }
9361137}
0 commit comments