Skip to main content

double_o/
init_cmd.rs

1//! `oo init` argument parsing and dispatch.
2//!
3//! Split out of `commands.rs` to keep that module under the 500-line cap.
4
5use crate::error::Error;
6use crate::init::InitFormat;
7use crate::{init, init_pi};
8
9/// Resolved mode for `oo init`: the legacy `--format` value or the new
10/// `--agent` target (issue #171).
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum InitMode {
13    /// `--format claude`/`generic` (or plain `oo init`) — behaviour unchanged.
14    Format(InitFormat),
15    /// `--agent pi [--global]` — install the pi extension.
16    Pi {
17        /// Install user-wide instead of per project.
18        global: bool,
19    },
20    /// `--agent claude-code [--global]` — install the Claude Code hook (issue #172).
21    ClaudeCode {
22        /// Install user-wide instead of per project.
23        global: bool,
24    },
25}
26
27/// Agents supported by `oo init --agent`. Keep in sync with the match in
28/// [`parse_init_mode`]; `init_args_tests` checks every entry parses.
29pub const SUPPORTED_AGENTS: &[&str] = &["pi", "claude-code"];
30
31/// A `--format` occurrence: given bare (no value follows), or with a value.
32#[derive(Debug, PartialEq, Eq)]
33enum FormatArg {
34    Bare,
35    Value(String),
36}
37
38/// Parse the trailing args of `oo init` into an [`InitMode`].
39///
40/// Single pass over the args. Every flag may appear at most once: a repeated
41/// `--agent`, `--format` or `--global` is a parse-time error, so no earlier
42/// value is silently overridden. `--agent` and `--format` are mutually
43/// exclusive. `--global` requires `--agent`. `--agent` and `--format` need a
44/// value, and a following dash-token is never consumed as that value. Values
45/// are validated before anything is written; an unknown option or stray
46/// positional is also an error. Plain `oo init` (no flags) selects the Claude
47/// format.
48pub(crate) fn parse_init_mode(args: &[String]) -> Result<InitMode, String> {
49    let supported = SUPPORTED_AGENTS.join(", ");
50    let mut agent: Option<String> = None;
51    let mut global = false;
52    let mut format: Option<FormatArg> = None;
53    let mut iter = args.iter().peekable();
54    while let Some(arg) = iter.next() {
55        match arg.as_str() {
56            "--agent" => {
57                if agent.is_some() {
58                    return Err("--agent given more than once".to_string());
59                }
60                let Some(value) = iter.next().filter(|v| !v.starts_with('-')) else {
61                    // No value, or the next token is itself a flag (e.g.
62                    // `--agent --global`) — report it as missing.
63                    return Err(format!(
64                        "--agent requires a value; supported agents: {supported}"
65                    ));
66                };
67                agent = Some(value.clone());
68            }
69            "--global" => {
70                if global {
71                    return Err("--global given more than once".to_string());
72                }
73                global = true;
74            }
75            "--format" => {
76                if format.is_some() {
77                    return Err("--format given more than once".to_string());
78                }
79                format = Some(match iter.peek() {
80                    Some(v) if !v.starts_with('-') => {
81                        let value = (*v).clone();
82                        iter.next();
83                        FormatArg::Value(value)
84                    }
85                    _ => FormatArg::Bare,
86                });
87            }
88            other if other.starts_with('-') => {
89                return Err(format!(
90                    "init: unknown option '{other}' (try: oo init --help)"
91                ));
92            }
93            other => {
94                return Err(format!(
95                    "init: unexpected argument '{other}' (try: oo init --help)"
96                ));
97            }
98        }
99    }
100    let Some(agent) = agent else {
101        // `--global` only makes sense with `--agent`: there is no legacy
102        // global install.
103        if global {
104            return Err("--global requires --agent".to_string());
105        }
106        return match format {
107            None => Ok(InitMode::Format(InitFormat::Claude)),
108            Some(FormatArg::Bare) => {
109                Err("--format requires a value; supported formats: claude, generic".to_string())
110            }
111            Some(FormatArg::Value(value)) => parse_init_format(&value).map(InitMode::Format),
112        };
113    };
114    if format.is_some() {
115        // Mutual exclusion regardless of flag order or value.
116        return Err("--agent and --format cannot be used together".to_string());
117    }
118    match agent.as_str() {
119        "pi" => Ok(InitMode::Pi { global }),
120        "claude-code" => Ok(InitMode::ClaudeCode { global }),
121        _ => Err(format!(
122            "unknown --agent value '{agent}'; supported agents: {supported}"
123        )),
124    }
125}
126
127/// Map a `--format <value>` to an [`InitFormat`].
128///
129/// Recognised values: `claude`, `generic`. Anything else is an error, so a
130/// typo is rejected instead of silently writing `.claude/hooks.json`.
131fn parse_init_format(value: &str) -> Result<InitFormat, String> {
132    match value {
133        "generic" => Ok(InitFormat::Generic),
134        "claude" => Ok(InitFormat::Claude),
135        other => Err(format!(
136            "oo init: unsupported format '{other}' (supported: claude, generic)"
137        )),
138    }
139}
140
141/// Run the install selected by `mode`; returns the process exit code.
142pub fn cmd_init(mode: InitMode) -> i32 {
143    match &mode {
144        InitMode::Format(format) => match init::run(*format) {
145            Ok(()) => 0,
146            Err(e) => {
147                eprintln!("oo: {e}");
148                1
149            }
150        },
151        InitMode::Pi { global } => {
152            let Ok(cwd) = std::env::current_dir() else {
153                eprintln!("oo: cannot determine working directory");
154                return 1;
155            };
156            match init_pi::run(&cwd, *global) {
157                Ok(()) => 0,
158                Err(e) => {
159                    eprintln!("oo: {e}");
160                    1
161                }
162            }
163        }
164        InitMode::ClaudeCode { global } => match std::env::current_dir().map_err(Error::from) {
165            Err(e) => {
166                eprintln!("oo: {e}");
167                1
168            }
169            Ok(cwd) => match crate::hook::claude_settings_path(&cwd, *global) {
170                Err(e) => {
171                    eprintln!("oo: {e}");
172                    1
173                }
174                Ok(path) => match crate::hook::merge_oo_hook(&path) {
175                    Ok(_) => {
176                        println!(
177                            "Installed `oo hook claude` PreToolUse hook in {}",
178                            path.display()
179                        );
180                        println!("Uninstall: remove the `oo hook claude` entry from that file.");
181                        0
182                    }
183                    Err(e) => {
184                        eprintln!("oo: {e}");
185                        1
186                    }
187                },
188            },
189        },
190    }
191}