Skip to main content

colab_cli/
cli.rs

1use clap::{Parser, Subcommand};
2
3#[derive(Parser)]
4#[command(
5    name = "colab-cli",
6    about = "Google Colab from the terminal",
7    version,
8    disable_help_subcommand = true
9)]
10pub struct Cli {
11    #[arg(long, short, global = true, env = "COLAB_QUIET")]
12    pub quiet: bool,
13
14    #[command(subcommand)]
15    pub command: Commands,
16}
17
18#[derive(Subcommand)]
19pub enum Commands {
20    /// Authentication
21    Auth {
22        #[command(subcommand)]
23        command: AuthCommands,
24    },
25    /// Server lifecycle and access
26    Server {
27        #[command(subcommand)]
28        command: ServerCommands,
29    },
30    /// Remote file operations
31    File {
32        #[command(subcommand)]
33        command: FileCommands,
34    },
35    /// Generate shell completions
36    Completions { shell: clap_complete::Shell },
37}
38
39#[derive(Subcommand)]
40pub enum AuthCommands {
41    /// Sign in to Google (opens browser)
42    Login,
43    /// Sign out and clear stored credentials
44    Logout,
45}
46
47#[derive(Subcommand)]
48pub enum ServerCommands {
49    /// Assign a new Colab server (interactive if no flags given)
50    Assign {
51        #[arg(long, value_parser = parse_variant)]
52        variant: Option<crate::client::api::Variant>,
53
54        #[arg(long, short)]
55        accelerator: Option<String>,
56
57        #[arg(long)]
58        name: Option<String>,
59
60        /// Request a high-memory machine shape
61        #[arg(long = "high-ram")]
62        high_ram: bool,
63
64        /// Keep the server alive indefinitely (pings + auto-refresh tokens)
65        #[arg(long, short = 'k')]
66        keepalive: bool,
67    },
68    /// Reconfigure an existing server (variant / accelerator / shape)
69    Reconfigure {
70        #[arg(long)]
71        name: Option<String>,
72
73        #[arg(long, value_parser = parse_variant)]
74        variant: Option<crate::client::api::Variant>,
75
76        #[arg(long, short)]
77        accelerator: Option<String>,
78
79        #[arg(long = "high-ram")]
80        high_ram: bool,
81
82        /// Keep the server alive indefinitely after reconfigure (pings + token refresh)
83        #[arg(long, short = 'k')]
84        keepalive: bool,
85    },
86    /// List assigned servers, or available accelerators with `--available`
87    Ls {
88        /// Show available accelerator choices with CCU/hr rates instead
89        #[arg(long, short = 'a')]
90        available: bool,
91    },
92    /// Remove an assigned server
93    Rm {
94        #[arg(long)]
95        name: Option<String>,
96    },
97    /// Open an interactive shell on a server
98    Shell {
99        #[arg(long)]
100        name: Option<String>,
101    },
102    /// Show server and account info
103    Info {
104        #[arg(long)]
105        name: Option<String>,
106    },
107    /// Realtime system stats (CPU / RAM / disk / GPU) for a server
108    Ps {
109        #[arg(long)]
110        name: Option<String>,
111        /// Refresh interval in milliseconds
112        #[arg(long, default_value_t = 1000)]
113        interval: u64,
114    },
115    /// Run an arbitrary command on the assigned server (passthrough)
116    ///
117    /// The command and its args are sent to the runtime verbatim. Stdout/stderr
118    /// stream back to the local terminal as the remote process produces them,
119    /// and the remote exit status is propagated as this command's exit code.
120    ///
121    /// Examples:
122    ///     colab-cli server run --name "Colab CPU" python -V
123    ///     colab-cli server run ls -la /content
124    ///     colab-cli server run bash -lc 'echo hi && uname -a'
125    Run {
126        #[arg(long)]
127        name: Option<String>,
128
129        /// Command and arguments to execute on the remote runtime.
130        #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
131        command: Vec<String>,
132    },
133}
134
135#[derive(Subcommand)]
136pub enum FileCommands {
137    /// Upload a local file to the runtime
138    Upload {
139        #[arg(long)]
140        name: Option<String>,
141        src: String,
142        dest: Option<String>,
143    },
144    /// List files on the runtime (passes args through to remote `ls`)
145    ///
146    /// Examples:
147    ///     colab-cli file ls
148    ///     colab-cli file ls -lah /content
149    ///     colab-cli file ls --name "Colab CPU" -a /tmp
150    Ls {
151        #[arg(long)]
152        name: Option<String>,
153
154        /// Args forwarded to the remote `ls`. Defaults to `-lah /content`.
155        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
156        args: Vec<String>,
157    },
158    /// Copy files on the runtime (passes args through to remote `cp`)
159    ///
160    /// Examples:
161    ///     colab-cli file cp /content/foo /content/bar
162    ///     colab-cli file cp -r /content/dir /content/dir2
163    Cp {
164        #[arg(long)]
165        name: Option<String>,
166
167        /// Args forwarded to the remote `cp`.
168        #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
169        args: Vec<String>,
170    },
171    /// Remove files on the runtime (passes args through to remote `rm`)
172    ///
173    /// Examples:
174    ///     colab-cli file rm /content/foo.txt
175    ///     colab-cli file rm -rf /content/junk
176    Rm {
177        #[arg(long)]
178        name: Option<String>,
179
180        /// Args forwarded to the remote `rm`.
181        #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
182        args: Vec<String>,
183    },
184}
185
186fn parse_variant(s: &str) -> std::result::Result<crate::client::api::Variant, String> {
187    match s.to_ascii_lowercase().as_str() {
188        "cpu" | "default" => Ok(crate::client::api::Variant::Cpu),
189        "gpu" => Ok(crate::client::api::Variant::Gpu),
190        "tpu" => Ok(crate::client::api::Variant::Tpu),
191        other => Err(format!(
192            "unknown variant '{other}' — expected cpu, gpu, or tpu"
193        )),
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::client::api::Variant;
201    use clap::CommandFactory;
202
203    #[test]
204    fn cli_is_valid() {
205        Cli::command().debug_assert();
206    }
207
208    #[test]
209    fn parse_variant_accepts_canonical_forms() {
210        assert_eq!(parse_variant("cpu").unwrap(), Variant::Cpu);
211        assert_eq!(parse_variant("CPU").unwrap(), Variant::Cpu);
212        assert_eq!(parse_variant("default").unwrap(), Variant::Cpu);
213        assert_eq!(parse_variant("gpu").unwrap(), Variant::Gpu);
214        assert_eq!(parse_variant("GPU").unwrap(), Variant::Gpu);
215        assert_eq!(parse_variant("tpu").unwrap(), Variant::Tpu);
216        assert_eq!(parse_variant("TPU").unwrap(), Variant::Tpu);
217    }
218
219    #[test]
220    fn parse_variant_rejects_garbage() {
221        assert!(parse_variant("fpga").is_err());
222        assert!(parse_variant("").is_err());
223    }
224
225    #[test]
226    fn help_subcommand_is_disabled() {
227        let Err(err) = Cli::try_parse_from(["colab-cli", "help"]) else {
228            panic!("`colab-cli help` should not parse");
229        };
230        assert!(matches!(
231            err.kind(),
232            clap::error::ErrorKind::InvalidSubcommand | clap::error::ErrorKind::UnknownArgument
233        ));
234    }
235
236    #[test]
237    fn ls_available_flag_parses() {
238        let cli = Cli::try_parse_from(["colab-cli", "server", "ls", "--available"]).unwrap();
239        if let Commands::Server {
240            command: ServerCommands::Ls { available },
241        } = cli.command
242        {
243            assert!(available);
244        } else {
245            panic!("expected ls");
246        }
247    }
248
249    #[test]
250    fn assign_keepalive_and_high_ram_flags_parse() {
251        let cli = Cli::try_parse_from([
252            "colab-cli",
253            "server",
254            "assign",
255            "-k",
256            "--high-ram",
257            "--variant",
258            "gpu",
259        ])
260        .unwrap();
261        if let Commands::Server {
262            command:
263                ServerCommands::Assign {
264                    keepalive,
265                    high_ram,
266                    variant,
267                    ..
268                },
269        } = cli.command
270        {
271            assert!(keepalive);
272            assert!(high_ram);
273            assert_eq!(variant, Some(Variant::Gpu));
274        } else {
275            panic!("expected assign");
276        }
277    }
278
279    #[test]
280    fn reconfigure_parses() {
281        let cli = Cli::try_parse_from([
282            "colab-cli",
283            "server",
284            "reconfigure",
285            "--name",
286            "box",
287            "--variant",
288            "gpu",
289            "-a",
290            "T4",
291            "--high-ram",
292            "-k",
293        ])
294        .unwrap();
295        if let Commands::Server {
296            command:
297                ServerCommands::Reconfigure {
298                    name,
299                    variant,
300                    accelerator,
301                    high_ram,
302                    keepalive,
303                },
304        } = cli.command
305        {
306            assert_eq!(name.as_deref(), Some("box"));
307            assert_eq!(variant, Some(Variant::Gpu));
308            assert_eq!(accelerator.as_deref(), Some("T4"));
309            assert!(high_ram);
310            assert!(keepalive);
311        } else {
312            panic!("expected reconfigure");
313        }
314    }
315
316    #[test]
317    fn no_standalone_keepalive_command() {
318        assert!(Cli::try_parse_from(["colab-cli", "server", "keepalive"]).is_err());
319    }
320
321    #[test]
322    fn no_standalone_accelerators_command() {
323        assert!(Cli::try_parse_from(["colab-cli", "server", "accelerators"]).is_err());
324    }
325
326    #[test]
327    fn ps_interval_parses() {
328        let cli = Cli::try_parse_from(["colab-cli", "server", "ps", "--interval", "250"]).unwrap();
329        if let Commands::Server {
330            command: ServerCommands::Ps { interval, .. },
331        } = cli.command
332        {
333            assert_eq!(interval, 250);
334        } else {
335            panic!("expected ps");
336        }
337    }
338}