Skip to main content

aiw/
cli.rs

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    /// Launch agy in an isolated Fence sandbox for a workspace
29    Agy(AgyCommand),
30    /// Forget a workspace
31    Forget(ForgetCommand),
32    /// Manage aiw configuration
33    Config(ConfigCommand),
34    /// List all available workspaces
35    #[command(alias = "ls")]
36    List(ListCommand),
37    /// Generate shell completion script
38    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(&current_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 to generate completions for
70    shell: ShellChoice,
71}
72
73#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
74pub enum ShellChoice {
75    /// Bourne Again SHell (bash)
76    Bash,
77    /// Elvish shell
78    Elvish,
79    /// Friendly Interactive SHell (fish)
80    Fish,
81    /// PowerShell
82    Powershell,
83    /// Z SHell (zsh)
84    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(&current_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    /// Workspace name under .workspaces/<workspace-name>
130    #[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(&current_dir, &self.workspace_name)?;
138        workspace.forget()?;
139        Ok(())
140    }
141}
142
143#[derive(clap::Args, Debug, PartialEq, Eq)]
144struct AgyCommand {
145    /// Workspace name under .workspaces/<workspace-name>
146    #[arg(add = clap_complete::engine::ArgValueCompleter::new(complete_workspaces))]
147    workspace_name: String,
148
149    /// Print generated Fence command without executing
150    #[arg(long)]
151    dry_run: bool,
152
153    /// Extra arguments forwarded directly to agy
154    #[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(&current_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    /// Initialize fence.jsonc configuration files
221    Init(ConfigInitArgs),
222}
223
224#[derive(clap::Args, Debug, PartialEq, Eq)]
225struct ConfigInitArgs {
226    /// Target configuration to initialize: project (default) or user
227    #[command(subcommand)]
228    target: Option<ConfigInitSubcommand>,
229
230    /// Overwrite existing configuration files
231    #[arg(long, short, global = true)]
232    force: bool,
233}
234
235#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
236enum ConfigInitSubcommand {
237    /// Initialize project configuration (fence.jsonc) [default]
238    Project(ConfigInitProjectSubcommand),
239    /// Initialize user configuration (~/.config/aiw/fence.jsonc)
240    User(ConfigInitUserSubcommand),
241}
242
243#[derive(clap::Args, Debug, Clone, PartialEq, Eq)]
244struct ConfigInitProjectSubcommand {
245    /// Overwrite existing configuration files
246    #[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(&current_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    /// Overwrite existing configuration files
279    #[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}