Skip to main content

pi_codes/
cli.rs

1//! Builder for `pi` invocations — assembles argv for `--mode json`
2//! one-shots and `--mode rpc` servers without hand-rolled string lists.
3
4use std::ffi::OsString;
5use std::path::PathBuf;
6
7/// Output mode (`--mode`).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum Mode {
10    /// Interactive/plain text (the CLI default; rarely useful from an SDK).
11    Text,
12    /// One-shot JSONL event stream on stdout.
13    #[default]
14    Json,
15    /// Headless stdin/stdout command protocol ([`crate::rpc`]).
16    Rpc,
17}
18
19impl Mode {
20    fn as_str(self) -> &'static str {
21        match self {
22            Mode::Text => "text",
23            Mode::Json => "json",
24            Mode::Rpc => "rpc",
25        }
26    }
27}
28
29/// Typed argv builder for the `pi` CLI.
30///
31/// ```
32/// use pi_codes::cli::{Mode, PiCliBuilder};
33/// let args = PiCliBuilder::new()
34///     .mode(Mode::Rpc)
35///     .no_session(true)
36///     .assembled_args();
37/// assert_eq!(args[0], "--mode");
38/// ```
39#[derive(Debug, Clone, Default)]
40pub struct PiCliBuilder {
41    mode: Mode,
42    provider: Option<String>,
43    model: Option<String>,
44    api_key: Option<String>,
45    system_prompt: Option<String>,
46    session: Option<String>,
47    session_dir: Option<PathBuf>,
48    session_id: Option<String>,
49    fork: Option<String>,
50    continue_session: bool,
51    no_session: bool,
52    name: Option<String>,
53    no_tools: bool,
54    tools: Option<String>,
55    exclude_tools: Option<String>,
56    thinking: Option<String>,
57    print: bool,
58    approve: bool,
59    no_approve: bool,
60    offline: bool,
61    working_directory: Option<PathBuf>,
62    extra_args: Vec<OsString>,
63    prompt: Option<String>,
64}
65
66impl PiCliBuilder {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    pub fn mode(mut self, mode: Mode) -> Self {
72        self.mode = mode;
73        self
74    }
75
76    pub fn provider(mut self, provider: impl Into<String>) -> Self {
77        self.provider = Some(provider.into());
78        self
79    }
80
81    pub fn model(mut self, model: impl Into<String>) -> Self {
82        self.model = Some(model.into());
83        self
84    }
85
86    /// API key for the provider. Prefer environment variables — argv is
87    /// visible to other processes; this exists for parity with the CLI.
88    pub fn api_key(mut self, key: impl Into<String>) -> Self {
89        self.api_key = Some(key.into());
90        self
91    }
92
93    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
94        self.system_prompt = Some(prompt.into());
95        self
96    }
97
98    /// `--session <path|id>` — use a specific session file or partial id.
99    pub fn session(mut self, session: impl Into<String>) -> Self {
100        self.session = Some(session.into());
101        self
102    }
103
104    pub fn session_dir(mut self, dir: impl Into<PathBuf>) -> Self {
105        self.session_dir = Some(dir.into());
106        self
107    }
108
109    /// `--session-id <id>` — exact project session id, created if missing.
110    pub fn session_id(mut self, id: impl Into<String>) -> Self {
111        self.session_id = Some(id.into());
112        self
113    }
114
115    /// `--fork <path|id>` — fork a session into a new one.
116    pub fn fork(mut self, source: impl Into<String>) -> Self {
117        self.fork = Some(source.into());
118        self
119    }
120
121    /// `--continue` — continue the previous session.
122    pub fn continue_session(mut self, enabled: bool) -> Self {
123        self.continue_session = enabled;
124        self
125    }
126
127    /// `--no-session` — ephemeral run, nothing persisted.
128    pub fn no_session(mut self, enabled: bool) -> Self {
129        self.no_session = enabled;
130        self
131    }
132
133    /// `--name` — session display name.
134    pub fn name(mut self, name: impl Into<String>) -> Self {
135        self.name = Some(name.into());
136        self
137    }
138
139    /// `--no-tools` — disable all tools.
140    pub fn no_tools(mut self, enabled: bool) -> Self {
141        self.no_tools = enabled;
142        self
143    }
144
145    /// `--tools` — comma-separated tool allowlist.
146    pub fn tools(mut self, tools: impl Into<String>) -> Self {
147        self.tools = Some(tools.into());
148        self
149    }
150
151    /// `--exclude-tools` — comma-separated tool denylist.
152    pub fn exclude_tools(mut self, tools: impl Into<String>) -> Self {
153        self.exclude_tools = Some(tools.into());
154        self
155    }
156
157    /// `--thinking` — off, minimal, low, medium, high, xhigh, max.
158    pub fn thinking(mut self, level: impl Into<String>) -> Self {
159        self.thinking = Some(level.into());
160        self
161    }
162
163    /// `--print` — non-interactive: process the prompt and exit.
164    pub fn print(mut self, enabled: bool) -> Self {
165        self.print = enabled;
166        self
167    }
168
169    /// `--approve` — trust project-local files (extensions, AGENTS.md)
170    /// for this run. Note pi has NO tool-approval gate: read/bash/edit/
171    /// write always run without prompting; this flag is only about
172    /// trusting local config.
173    pub fn approve(mut self, enabled: bool) -> Self {
174        self.approve = enabled;
175        self
176    }
177
178    /// `--no-approve` — ignore project-local files for this run.
179    pub fn no_approve(mut self, enabled: bool) -> Self {
180        self.no_approve = enabled;
181        self
182    }
183
184    /// `--offline` — disable startup network operations.
185    pub fn offline(mut self, enabled: bool) -> Self {
186        self.offline = enabled;
187        self
188    }
189
190    /// Working directory for the spawned process (not a pi flag — pi's
191    /// tools operate relative to its cwd). Used by
192    /// [`crate::client_async::PiRpcClient::spawn`].
193    pub fn working_directory(mut self, dir: impl Into<PathBuf>) -> Self {
194        self.working_directory = Some(dir.into());
195        self
196    }
197
198    /// The configured working directory, if any.
199    pub fn get_working_directory(&self) -> Option<&std::path::Path> {
200        self.working_directory.as_deref()
201    }
202
203    /// Escape hatch: raw args appended after every typed flag, before
204    /// the positional prompt.
205    pub fn extra_args<I, S>(mut self, args: I) -> Self
206    where
207        I: IntoIterator<Item = S>,
208        S: Into<OsString>,
209    {
210        self.extra_args.extend(args.into_iter().map(Into::into));
211        self
212    }
213
214    /// Positional prompt (used with `--mode json` one-shots).
215    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
216        self.prompt = Some(prompt.into());
217        self
218    }
219
220    /// The full argv (binary not included) — pure and testable.
221    pub fn assembled_args(&self) -> Vec<OsString> {
222        let mut a: Vec<OsString> = vec!["--mode".into(), self.mode.as_str().into()];
223        let mut flag = |name: &str, v: &Option<String>| {
224            if let Some(v) = v {
225                a.push(name.into());
226                a.push(v.into());
227            }
228        };
229        flag("--provider", &self.provider);
230        flag("--model", &self.model);
231        flag("--api-key", &self.api_key);
232        flag("--system-prompt", &self.system_prompt);
233        flag("--session", &self.session);
234        flag("--session-id", &self.session_id);
235        flag("--fork", &self.fork);
236        flag("--name", &self.name);
237        flag("--tools", &self.tools);
238        flag("--exclude-tools", &self.exclude_tools);
239        flag("--thinking", &self.thinking);
240        if let Some(dir) = &self.session_dir {
241            a.push("--session-dir".into());
242            a.push(dir.into());
243        }
244        if self.continue_session {
245            a.push("--continue".into());
246        }
247        if self.no_session {
248            a.push("--no-session".into());
249        }
250        if self.no_tools {
251            a.push("--no-tools".into());
252        }
253        if self.print {
254            a.push("--print".into());
255        }
256        if self.approve {
257            a.push("--approve".into());
258        }
259        if self.no_approve {
260            a.push("--no-approve".into());
261        }
262        if self.offline {
263            a.push("--offline".into());
264        }
265        a.extend(self.extra_args.iter().cloned());
266        if let Some(p) = &self.prompt {
267            a.push(p.into());
268        }
269        a
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn assembled_args_order_flags_then_extras_then_prompt() {
279        let args = PiCliBuilder::new()
280            .mode(Mode::Json)
281            .provider("anthropic")
282            .no_session(true)
283            .extra_args(["--verbose"])
284            .prompt("hello")
285            .assembled_args();
286        let s: Vec<String> = args
287            .iter()
288            .map(|a| a.to_string_lossy().into_owned())
289            .collect();
290        assert_eq!(s[0..2], ["--mode", "json"]);
291        assert!(s.contains(&"--no-session".to_string()));
292        let vi = s.iter().position(|x| x == "--verbose").unwrap();
293        assert_eq!(s.last().unwrap(), "hello");
294        assert!(vi < s.len() - 1, "extras precede the positional prompt");
295    }
296
297    #[test]
298    fn rpc_mode_default_has_no_prompt() {
299        let args = PiCliBuilder::new().mode(Mode::Rpc).assembled_args();
300        assert_eq!(args.len(), 2);
301        assert_eq!(args[1], "rpc");
302    }
303}