11// Decision: enable http, git, python by default for CLI users.
22// Provide --no-http, --no-git, --no-python to disable individually.
3+ // Decision: keep one-shot CLI on a current-thread runtime; reserve multi-thread
4+ // runtime for MCP only so cold-start work stays off the common path.
35
46//! Bashkit CLI - Command line interface for virtual bash execution
57//!
@@ -14,6 +16,7 @@ mod mcp;
1416use anyhow:: { Context , Result } ;
1517use clap:: { Parser , Subcommand } ;
1618use std:: path:: PathBuf ;
19+ use tokio:: runtime:: Builder ;
1720
1821/// Bashkit - Virtual bash interpreter
1922#[ derive( Parser , Debug ) ]
@@ -75,6 +78,21 @@ enum SubCmd {
7578 Mcp ,
7679}
7780
81+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
82+ enum CliMode {
83+ Mcp ,
84+ Command ,
85+ Script ,
86+ Interactive ,
87+ }
88+
89+ #[ derive( Debug ) ]
90+ struct RunOutput {
91+ stdout : String ,
92+ stderr : String ,
93+ exit_code : i32 ,
94+ }
95+
7896fn build_bash ( args : & Args ) -> bashkit:: Bash {
7997 let mut builder = bashkit:: Bash :: builder ( ) ;
8098
@@ -99,6 +117,18 @@ fn build_bash(args: &Args) -> bashkit::Bash {
99117 builder. build ( )
100118}
101119
120+ fn cli_mode ( args : & Args ) -> CliMode {
121+ if matches ! ( args. subcommand, Some ( SubCmd :: Mcp ) ) {
122+ CliMode :: Mcp
123+ } else if args. command . is_some ( ) {
124+ CliMode :: Command
125+ } else if args. script . is_some ( ) {
126+ CliMode :: Script
127+ } else {
128+ CliMode :: Interactive
129+ }
130+ }
131+
102132/// Parse mount specs (HOST_PATH or HOST_PATH:VFS_PATH) and apply to builder.
103133#[ cfg( feature = "realfs" ) ]
104134fn apply_real_mounts (
@@ -123,47 +153,69 @@ fn apply_real_mounts(
123153 builder
124154}
125155
126- #[ tokio:: main]
127- async fn main ( ) -> Result < ( ) > {
156+ fn main ( ) -> Result < ( ) > {
128157 let args = Args :: parse ( ) ;
129158
130- // Handle subcommands first
131- if let Some ( SubCmd :: Mcp ) = args. subcommand {
132- return mcp:: run ( ) . await ;
133- }
134-
135- let mut bash = build_bash ( & args) ;
136-
137- // Execute command string if provided
138- if let Some ( cmd) = args. command {
139- let result = bash. exec ( & cmd) . await . context ( "Failed to execute command" ) ?;
140- print ! ( "{}" , result. stdout) ;
141- if !result. stderr . is_empty ( ) {
142- eprint ! ( "{}" , result. stderr) ;
159+ match cli_mode ( & args) {
160+ CliMode :: Mcp => run_mcp ( ) ,
161+ CliMode :: Command | CliMode :: Script => {
162+ let output = run_oneshot ( args) ?;
163+ print ! ( "{}" , output. stdout) ;
164+ if !output. stderr . is_empty ( ) {
165+ eprint ! ( "{}" , output. stderr) ;
166+ }
167+ std:: process:: exit ( output. exit_code ) ;
143168 }
144- std:: process:: exit ( result. exit_code ) ;
145- }
146-
147- // Execute script file if provided
148- if let Some ( script_path) = args. script {
149- let script = std:: fs:: read_to_string ( & script_path)
150- . with_context ( || format ! ( "Failed to read script: {}" , script_path. display( ) ) ) ?;
151-
152- let result = bash
153- . exec ( & script)
154- . await
155- . context ( "Failed to execute script" ) ?;
156- print ! ( "{}" , result. stdout) ;
157- if !result. stderr . is_empty ( ) {
158- eprint ! ( "{}" , result. stderr) ;
169+ CliMode :: Interactive => {
170+ eprintln ! ( "bashkit: interactive mode not yet implemented" ) ;
171+ eprintln ! ( "Usage: bashkit -c 'command' or bashkit script.sh or bashkit mcp" ) ;
172+ std:: process:: exit ( 1 ) ;
159173 }
160- std:: process:: exit ( result. exit_code ) ;
161174 }
175+ }
176+
177+ fn run_mcp ( ) -> Result < ( ) > {
178+ Builder :: new_multi_thread ( )
179+ . enable_all ( )
180+ . build ( )
181+ . context ( "Failed to build MCP runtime" ) ?
182+ . block_on ( mcp:: run ( ) )
183+ }
162184
163- // Interactive REPL (not yet implemented)
164- eprintln ! ( "bashkit: interactive mode not yet implemented" ) ;
165- eprintln ! ( "Usage: bashkit -c 'command' or bashkit script.sh or bashkit mcp" ) ;
166- std:: process:: exit ( 1 ) ;
185+ fn run_oneshot ( args : Args ) -> Result < RunOutput > {
186+ Builder :: new_current_thread ( )
187+ . enable_all ( )
188+ . build ( )
189+ . context ( "Failed to build CLI runtime" ) ?
190+ . block_on ( async move {
191+ let mut bash = build_bash ( & args) ;
192+
193+ if let Some ( cmd) = args. command {
194+ let result = bash. exec ( & cmd) . await . context ( "Failed to execute command" ) ?;
195+ return Ok ( RunOutput {
196+ stdout : result. stdout ,
197+ stderr : result. stderr ,
198+ exit_code : result. exit_code ,
199+ } ) ;
200+ }
201+
202+ if let Some ( script_path) = args. script {
203+ let script = std:: fs:: read_to_string ( & script_path)
204+ . with_context ( || format ! ( "Failed to read script: {}" , script_path. display( ) ) ) ?;
205+
206+ let result = bash
207+ . exec ( & script)
208+ . await
209+ . context ( "Failed to execute script" ) ?;
210+ return Ok ( RunOutput {
211+ stdout : result. stdout ,
212+ stderr : result. stderr ,
213+ exit_code : result. exit_code ,
214+ } ) ;
215+ }
216+
217+ unreachable ! ( "run_oneshot called for non-executable mode" ) ;
218+ } )
167219}
168220
169221#[ cfg( test) ]
@@ -194,6 +246,30 @@ mod tests {
194246 assert ! ( !args. no_python) ;
195247 }
196248
249+ #[ test]
250+ fn cli_mode_prefers_mcp ( ) {
251+ let args = Args :: parse_from ( [ "bashkit" , "mcp" ] ) ;
252+ assert_eq ! ( cli_mode( & args) , CliMode :: Mcp ) ;
253+ }
254+
255+ #[ test]
256+ fn cli_mode_detects_command ( ) {
257+ let args = Args :: parse_from ( [ "bashkit" , "-c" , "echo hi" ] ) ;
258+ assert_eq ! ( cli_mode( & args) , CliMode :: Command ) ;
259+ }
260+
261+ #[ test]
262+ fn cli_mode_detects_script ( ) {
263+ let args = Args :: parse_from ( [ "bashkit" , "script.sh" ] ) ;
264+ assert_eq ! ( cli_mode( & args) , CliMode :: Script ) ;
265+ }
266+
267+ #[ test]
268+ fn cli_mode_falls_back_to_interactive ( ) {
269+ let args = Args :: parse_from ( [ "bashkit" ] ) ;
270+ assert_eq ! ( cli_mode( & args) , CliMode :: Interactive ) ;
271+ }
272+
197273 #[ cfg( feature = "python" ) ]
198274 #[ tokio:: test]
199275 async fn python_enabled_by_default ( ) {
@@ -261,6 +337,15 @@ mod tests {
261337 assert_eq ! ( result. exit_code, 0 ) ;
262338 }
263339
340+ #[ test]
341+ fn run_oneshot_executes_command_on_current_thread_runtime ( ) {
342+ let args = Args :: parse_from ( [ "bashkit" , "--no-http" , "--no-git" , "-c" , "echo works" ] ) ;
343+ let output = run_oneshot ( args) . expect ( "run" ) ;
344+ assert_eq ! ( output. stdout, "works\n " ) ;
345+ assert_eq ! ( output. stderr, "" ) ;
346+ assert_eq ! ( output. exit_code, 0 ) ;
347+ }
348+
264349 #[ cfg( feature = "realfs" ) ]
265350 #[ test]
266351 fn parse_mount_flags ( ) {
0 commit comments