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 Auth {
22 #[command(subcommand)]
23 command: AuthCommands,
24 },
25 Server {
27 #[command(subcommand)]
28 command: ServerCommands,
29 },
30 File {
32 #[command(subcommand)]
33 command: FileCommands,
34 },
35 Completions { shell: clap_complete::Shell },
37}
38
39#[derive(Subcommand)]
40pub enum AuthCommands {
41 Login,
43 Logout,
45}
46
47#[derive(Subcommand)]
48pub enum ServerCommands {
49 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 #[arg(long = "high-ram")]
62 high_ram: bool,
63
64 #[arg(long, short = 'k')]
66 keepalive: bool,
67 },
68 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 #[arg(long, short = 'k')]
84 keepalive: bool,
85 },
86 Ls {
88 #[arg(long, short = 'a')]
90 available: bool,
91 },
92 Rm {
94 #[arg(long)]
95 name: Option<String>,
96 },
97 Shell {
99 #[arg(long)]
100 name: Option<String>,
101 },
102 Info {
104 #[arg(long)]
105 name: Option<String>,
106 },
107 Ps {
109 #[arg(long)]
110 name: Option<String>,
111 #[arg(long, default_value_t = 1000)]
113 interval: u64,
114 },
115 Run {
126 #[arg(long)]
127 name: Option<String>,
128
129 #[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 {
139 #[arg(long)]
140 name: Option<String>,
141 src: String,
142 dest: Option<String>,
143 },
144 Ls {
151 #[arg(long)]
152 name: Option<String>,
153
154 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
156 args: Vec<String>,
157 },
158 Cp {
164 #[arg(long)]
165 name: Option<String>,
166
167 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
169 args: Vec<String>,
170 },
171 Rm {
177 #[arg(long)]
178 name: Option<String>,
179
180 #[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}