1use std::path::Path;
2
3use crate::config::{ConfigInitError, ConfigInitializer};
4use crate::direnv::Direnv;
5use crate::herdr::{Herdr, HerdrError};
6use crate::sandbox::{SandboxBuilder, SandboxError};
7use crate::vcs::Vcs;
8use crate::workspace::{Workspace, WorkspaceError};
9use clap::{Parser, Subcommand};
10use clap_complete::engine::CompletionCandidate;
11
12#[derive(Parser, Debug)]
13#[command(name = "aiw", about = "AI Workspace Launcher")]
14pub struct Cli {
15 #[command(subcommand)]
16 command: Commands,
17}
18
19impl Cli {
20 pub fn run() -> Result<(), AppError> {
21 let cli = Self::parse();
22 cli.command.run()
23 }
24}
25
26#[derive(Subcommand, Debug, PartialEq, Eq)]
27enum Commands {
28 Agy(AgyCommand),
30 Forget(ForgetCommand),
32 Config(ConfigCommand),
34 #[command(alias = "ls")]
36 List(ListCommand),
37 Completion(CompletionCommand),
39}
40
41impl Commands {
42 fn run(self) -> Result<(), AppError> {
43 match self {
44 Commands::Agy(cmd) => cmd.run(),
45 Commands::Forget(cmd) => cmd.run(),
46 Commands::Config(cmd) => cmd.run(),
47 Commands::List(cmd) => cmd.run(),
48 Commands::Completion(cmd) => cmd.run(),
49 }
50 }
51}
52
53#[derive(clap::Args, Debug, PartialEq, Eq)]
54struct ListCommand {}
55
56impl ListCommand {
57 fn run(self) -> Result<(), AppError> {
58 let current_dir = std::env::current_dir()?;
59 let workspaces = Workspace::list_from_dir(¤t_dir)?;
60 for ws in workspaces {
61 println!("{}", ws.name());
62 }
63 Ok(())
64 }
65}
66
67#[derive(clap::Args, Debug, PartialEq, Eq)]
68struct CompletionCommand {
69 shell: ShellChoice,
71}
72
73#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
74pub enum ShellChoice {
75 Bash,
77 Elvish,
79 Fish,
81 Powershell,
83 Zsh,
85}
86
87impl CompletionCommand {
88 fn run(self) -> Result<(), AppError> {
89 use clap_complete::env::{Bash, Elvish, EnvCompleter, Fish, Powershell, Zsh};
90 let mut stdout = std::io::stdout();
91 match self.shell {
92 ShellChoice::Bash => {
93 Bash.write_registration("COMPLETE", "aiw", "aiw", "aiw", &mut stdout)?
94 }
95 ShellChoice::Elvish => {
96 Elvish.write_registration("COMPLETE", "aiw", "aiw", "aiw", &mut stdout)?
97 }
98 ShellChoice::Fish => {
99 Fish.write_registration("COMPLETE", "aiw", "aiw", "aiw", &mut stdout)?
100 }
101 ShellChoice::Powershell => {
102 Powershell.write_registration("COMPLETE", "aiw", "aiw", "aiw", &mut stdout)?
103 }
104 ShellChoice::Zsh => {
105 Zsh.write_registration("COMPLETE", "aiw", "aiw", "aiw", &mut stdout)?
106 }
107 }
108 Ok(())
109 }
110}
111
112fn complete_workspaces(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
113 let current_str = current.to_str().unwrap_or("");
114 let Ok(current_dir) = std::env::current_dir() else {
115 return Vec::new();
116 };
117 let Ok(workspaces) = Workspace::list_from_dir(¤t_dir) else {
118 return Vec::new();
119 };
120 workspaces
121 .into_iter()
122 .filter(|ws| ws.name().starts_with(current_str))
123 .map(|ws| CompletionCandidate::new(ws.name()))
124 .collect()
125}
126
127#[derive(clap::Args, Debug, PartialEq, Eq)]
128struct ForgetCommand {
129 #[arg(add = clap_complete::engine::ArgValueCompleter::new(complete_workspaces))]
131 workspace_name: String,
132}
133
134impl ForgetCommand {
135 fn run(self) -> Result<(), AppError> {
136 let current_dir = std::env::current_dir()?;
137 let workspace = Workspace::from_dir(¤t_dir, &self.workspace_name)?;
138 workspace.forget()?;
139 Ok(())
140 }
141}
142
143#[derive(clap::Args, Debug, PartialEq, Eq)]
144struct AgyCommand {
145 #[arg(add = clap_complete::engine::ArgValueCompleter::new(complete_workspaces))]
147 workspace_name: String,
148
149 #[arg(long)]
151 dry_run: bool,
152
153 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
155 extra_args: Vec<String>,
156}
157
158impl AgyCommand {
159 fn build_payload_command(mut self) -> Vec<String> {
160 let mut cmd = vec!["agy".to_string()];
161 if !self
162 .extra_args
163 .iter()
164 .any(|a| a == "--dangerously-skip-permissions")
165 {
166 cmd.push("--dangerously-skip-permissions".to_string());
167 }
168 cmd.append(&mut self.extra_args);
169 cmd
170 }
171
172 fn run(self) -> Result<(), AppError> {
173 let current_dir = std::env::current_dir()?;
174 let workspace = Workspace::from_dir(¤t_dir, &self.workspace_name)?;
175 let repo_root = workspace.repo_root();
176
177 let direnv = Direnv::new(repo_root);
178 let is_new_workspace = workspace.ensure()?;
179
180 if is_new_workspace {
181 direnv.allow_workspace(workspace.path());
182 }
183
184 let dry_run = self.dry_run;
185 let workspace_name = self.workspace_name.clone();
186 let payload = self.build_payload_command();
187 let builder =
188 SandboxBuilder::for_workspace(&workspace, &payload).with_direnv(direnv.is_allowed());
189
190 if dry_run {
191 println!("{builder}");
192 Ok(())
193 } else if let Some(herdr) = Herdr::from_env() {
194 let cmd = format!("{builder}");
195 herdr.run_command(&workspace_name, &cmd, "agy", Some(workspace.path()))?;
196 Ok(())
197 } else {
198 builder.run()?;
199 Ok(())
200 }
201 }
202}
203
204#[derive(clap::Args, Debug, PartialEq, Eq)]
205struct ConfigCommand {
206 #[command(subcommand)]
207 command: ConfigSubcommands,
208}
209
210impl ConfigCommand {
211 fn run(self) -> Result<(), AppError> {
212 match self.command {
213 ConfigSubcommands::Init(args) => args.run(),
214 }
215 }
216}
217
218#[derive(Subcommand, Debug, PartialEq, Eq)]
219enum ConfigSubcommands {
220 Init(ConfigInitArgs),
222}
223
224#[derive(clap::Args, Debug, PartialEq, Eq)]
225struct ConfigInitArgs {
226 #[command(subcommand)]
228 target: Option<ConfigInitSubcommand>,
229
230 #[arg(long, short, global = true)]
232 force: bool,
233}
234
235#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
236enum ConfigInitSubcommand {
237 Project(ConfigInitProjectSubcommand),
239 User(ConfigInitUserSubcommand),
241}
242
243#[derive(clap::Args, Debug, Clone, PartialEq, Eq)]
244struct ConfigInitProjectSubcommand {
245 #[arg(long, short)]
247 force: bool,
248}
249
250fn warn_already_exists(kind: &str, path: &Path) {
251 eprintln!(
252 "Warning: {kind} configuration already exists: {}. No files written. Use --force to overwrite existing configurations.",
253 path.display()
254 );
255}
256
257impl ConfigInitProjectSubcommand {
258 fn run(self, global_force: bool) -> Result<(), AppError> {
259 let force = self.force || global_force;
260 let current_dir = std::env::current_dir()?;
261 let project_dir = Vcs::from_path(¤t_dir)
262 .map(|v| v.repo_root().to_path_buf())
263 .unwrap_or(current_dir);
264
265 match ConfigInitializer::init_project_config(&project_dir, force) {
266 Ok(_) => Ok(()),
267 Err(ConfigInitError::AlreadyExists(path)) => {
268 warn_already_exists("Project", &path);
269 Ok(())
270 }
271 Err(err) => Err(err.into()),
272 }
273 }
274}
275
276#[derive(clap::Args, Debug, Clone, PartialEq, Eq)]
277struct ConfigInitUserSubcommand {
278 #[arg(long, short)]
280 force: bool,
281}
282
283impl ConfigInitUserSubcommand {
284 fn run(self, global_force: bool) -> Result<(), AppError> {
285 let force = self.force || global_force;
286 match ConfigInitializer::init_user_config(force) {
287 Ok(_) => Ok(()),
288 Err(ConfigInitError::AlreadyExists(path)) => {
289 warn_already_exists("User", &path);
290 Ok(())
291 }
292 Err(err) => Err(err.into()),
293 }
294 }
295}
296
297impl ConfigInitArgs {
298 fn run(self) -> Result<(), AppError> {
299 let force = self.force;
300 match self.target {
301 Some(ConfigInitSubcommand::User(cmd)) => cmd.run(force),
302 Some(ConfigInitSubcommand::Project(cmd)) => cmd.run(force),
303 None => ConfigInitProjectSubcommand { force }.run(force),
304 }
305 }
306}
307
308#[derive(Debug, thiserror::Error)]
309pub enum AppError {
310 #[error("{0}")]
311 Workspace(#[from] WorkspaceError),
312 #[error("{0}")]
313 Sandbox(#[from] SandboxError),
314 #[error("{0}")]
315 Herdr(#[from] HerdrError),
316 #[error("{0}")]
317 Config(#[from] ConfigInitError),
318 #[error("{0}")]
319 Io(#[from] std::io::Error),
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use googletest::prelude::*;
326
327 type TestResult = Result<(), Box<dyn std::error::Error>>;
328
329 #[googletest::test]
330 fn parse_agy_subcommand_minimal_succeeds() -> TestResult {
331 let cli = Cli::try_parse_from(["aiw", "agy", "my-workspace"])?;
332 if let Commands::Agy(cmd) = cli.command {
333 expect_that!(&cmd.workspace_name, eq("my-workspace"));
334 expect_that!(cmd.dry_run, is_false());
335 expect_that!(&cmd.extra_args, is_empty());
336 } else {
337 expect_that!(false, is_true());
338 }
339 Ok(())
340 }
341
342 #[googletest::test]
343 fn parse_agy_subcommand_with_dry_run_flag_succeeds() -> TestResult {
344 let cli = Cli::try_parse_from(["aiw", "agy", "my-workspace", "--dry-run"])?;
345 if let Commands::Agy(cmd) = cli.command {
346 expect_that!(&cmd.workspace_name, eq("my-workspace"));
347 expect_that!(cmd.dry_run, is_true());
348 expect_that!(&cmd.extra_args, is_empty());
349 } else {
350 expect_that!(false, is_true());
351 }
352 Ok(())
353 }
354
355 #[googletest::test]
356 fn parse_agy_subcommand_with_dry_run_and_extra_args_succeeds() -> TestResult {
357 let cli = Cli::try_parse_from([
358 "aiw",
359 "agy",
360 "feature-1",
361 "--dry-run",
362 "--",
363 "--model",
364 "gemini-2.5",
365 ])?;
366 if let Commands::Agy(cmd) = cli.command {
367 expect_that!(&cmd.workspace_name, eq("feature-1"));
368 expect_that!(cmd.dry_run, is_true());
369 expect_that!(
370 &cmd.extra_args,
371 elements_are![eq("--model"), eq("gemini-2.5")]
372 );
373 } else {
374 expect_that!(false, is_true());
375 }
376 Ok(())
377 }
378
379 #[googletest::test]
380 fn parse_missing_subcommand_fails_with_missing_subcommand_kind() {
381 let res = Cli::try_parse_from(["aiw"]);
382 expect_that!(
383 res,
384 err(predicate(|err: &clap::Error| {
385 err.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
386 }))
387 );
388 }
389
390 #[googletest::test]
391 fn parse_missing_workspace_name_fails_with_missing_required_argument_kind() {
392 let res = Cli::try_parse_from(["aiw", "agy"]);
393 expect_that!(
394 res,
395 err(predicate(|err: &clap::Error| {
396 err.kind() == clap::error::ErrorKind::MissingRequiredArgument
397 }))
398 );
399 }
400
401 #[googletest::test]
402 fn parse_forget_subcommand_succeeds() -> TestResult {
403 let cli = Cli::try_parse_from(["aiw", "forget", "old-ws"])?;
404 if let Commands::Forget(cmd) = cli.command {
405 expect_that!(&cmd.workspace_name, eq("old-ws"));
406 } else {
407 expect_that!(false, is_true());
408 }
409 Ok(())
410 }
411
412 #[googletest::test]
413 fn parse_forget_missing_workspace_name_fails() {
414 let res = Cli::try_parse_from(["aiw", "forget"]);
415 expect_that!(
416 res,
417 err(predicate(|err: &clap::Error| {
418 err.kind() == clap::error::ErrorKind::MissingRequiredArgument
419 }))
420 );
421 }
422
423 #[googletest::test]
424 fn parse_config_init_default_succeeds() -> TestResult {
425 let cli = Cli::try_parse_from(["aiw", "config", "init"])?;
426 if let Commands::Config(cmd) = cli.command {
427 let ConfigSubcommands::Init(args) = cmd.command;
428 expect_that!(args.force, is_false());
429 expect_that!(args.target, none());
430 } else {
431 expect_that!(false, is_true());
432 }
433 Ok(())
434 }
435
436 #[googletest::test]
437 fn parse_config_init_user_subcommand_succeeds() -> TestResult {
438 let cli = Cli::try_parse_from(["aiw", "config", "init", "user"])?;
439 if let Commands::Config(cmd) = cli.command {
440 let ConfigSubcommands::Init(args) = cmd.command;
441 expect_that!(args.force, is_false());
442 expect_that!(
443 args.target,
444 some(eq(&ConfigInitSubcommand::User(ConfigInitUserSubcommand {
445 force: false
446 })))
447 );
448 } else {
449 expect_that!(false, is_true());
450 }
451 Ok(())
452 }
453
454 #[googletest::test]
455 fn parse_config_init_project_with_force_succeeds() -> TestResult {
456 let cli = Cli::try_parse_from(["aiw", "config", "init", "--force", "project"])?;
457 if let Commands::Config(cmd) = cli.command {
458 let ConfigSubcommands::Init(args) = cmd.command;
459 expect_that!(args.force, is_true());
460 expect_that!(
461 args.target,
462 some(eq(&ConfigInitSubcommand::Project(
463 ConfigInitProjectSubcommand { force: true }
464 )))
465 );
466 } else {
467 expect_that!(false, is_true());
468 }
469 Ok(())
470 }
471
472 #[googletest::test]
473 fn parse_config_init_project_subcommand_with_own_force_succeeds() -> TestResult {
474 let cli = Cli::try_parse_from(["aiw", "config", "init", "project", "-f"])?;
475 if let Commands::Config(cmd) = cli.command {
476 let ConfigSubcommands::Init(args) = cmd.command;
477 expect_that!(
478 args.target,
479 some(eq(&ConfigInitSubcommand::Project(
480 ConfigInitProjectSubcommand { force: true }
481 )))
482 );
483 } else {
484 expect_that!(false, is_true());
485 }
486 Ok(())
487 }
488
489 #[googletest::test]
490 fn parse_list_subcommand_succeeds() -> TestResult {
491 let cli = Cli::try_parse_from(["aiw", "list"])?;
492 expect_that!(&cli.command, eq(&Commands::List(ListCommand {})));
493 Ok(())
494 }
495
496 #[googletest::test]
497 fn parse_list_subcommand_alias_ls_succeeds() -> TestResult {
498 let cli = Cli::try_parse_from(["aiw", "ls"])?;
499 expect_that!(&cli.command, eq(&Commands::List(ListCommand {})));
500 Ok(())
501 }
502
503 #[googletest::test]
504 fn parse_completion_subcommand_succeeds_for_all_shells() -> TestResult {
505 let shells = [
506 ("bash", ShellChoice::Bash),
507 ("elvish", ShellChoice::Elvish),
508 ("fish", ShellChoice::Fish),
509 ("powershell", ShellChoice::Powershell),
510 ("zsh", ShellChoice::Zsh),
511 ];
512 for (name, expected) in shells {
513 let cli = Cli::try_parse_from(["aiw", "completion", name])?;
514 expect_that!(
515 &cli.command,
516 eq(&Commands::Completion(CompletionCommand { shell: expected }))
517 );
518 }
519 Ok(())
520 }
521
522 #[googletest::test]
523 fn parse_completion_without_shell_fails() {
524 let cli = Cli::try_parse_from(["aiw", "completion"]);
525 expect_that!(cli, err(anything()));
526 }
527
528 #[googletest::test]
529 fn parse_completion_invalid_shell_fails() {
530 let cli = Cli::try_parse_from(["aiw", "completion", "invalid_shell"]);
531 expect_that!(cli, err(anything()));
532 }
533}