1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
use agent_base::ReasoningEffort;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "phi",
about = "phi — General-purpose AI Agent CLI tool",
version,
long_about = "phi — Drive local dev tasks via natural language.\n\n\
Supports interactive mode and one-shot mode."
)]
pub struct CliArgs {
/// One-shot query (one-shot mode). If provided, runs the query and exits.
/// If omitted, enters interactive REPL mode.
#[arg(value_name = "QUERY")]
pub query: Option<String>,
#[command(subcommand)]
pub command: Option<SubCommand>,
// ── Output control ──
#[arg(long, value_enum, default_value = "terminal")]
pub format: OutputFormatArg,
/// Hide AI thinking process
#[arg(long, default_value = "false")]
pub no_thinking: bool,
/// Thinking token budget
#[arg(long)]
pub thinking_budget: Option<u64>,
/// Thinking effort (low/medium/high/xhigh)
#[arg(long, value_enum, default_value = "medium")]
pub thinking_effort: ReasoningEffortArg,
/// Hide tool argument details
#[arg(long, default_value = "false")]
pub no_tool_args: bool,
/// Disable terminal colors
#[arg(long, default_value = "false")]
pub no_color: bool,
// ── Approval control ──
/// Auto-approve all operations (skip confirmation)
#[arg(long, short = 'y', default_value = "false")]
pub auto_approve: bool,
// ── Session control ──
/// Session ID (for session persistence)
#[arg(long, env = "PHI_SESSION_ID")]
pub session_id: Option<String>,
// ── Model config ──
/// LLM model name
#[arg(long)]
pub model: Option<String>,
/// LLM API base URL
#[arg(long)]
pub base_url: Option<String>,
// ── Logging control ──
/// Log directory
#[arg(long, default_value = "~/.phi-agent")]
pub log_dir: String,
/// Log level
#[arg(long, default_value = "info")]
pub log_level: String,
/// Disable file logging
#[arg(long, default_value = "false")]
pub no_log: bool,
// ── Safety limits ──
/// Max tool calls per turn
#[arg(long)]
pub max_tool_calls: Option<usize>,
/// Max consecutive failures for the same tool
#[arg(long)]
pub max_failures: Option<usize>,
/// Max react-loop iterations for a single run (one user input). Default: 200.
#[arg(long, env = "PHI_MAX_TURNS")]
pub max_turns: Option<u32>,
// ── Tool config ──
/// Shell command timeout (milliseconds)
#[arg(long, default_value = "30000")]
pub shell_timeout_ms: u64,
// ── Browser config ──
/// Enable browser automation tools (launches headless Chrome)
#[arg(long, default_value = "false")]
pub enable_browser: bool,
/// Run browser in headed mode (visible window, useful for debugging)
#[arg(long, default_value = "false")]
pub headed: bool,
/// Connect to an existing Chrome instance via WebSocket (e.g., ws://localhost:9222)
#[arg(long)]
pub connect_ws: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormatArg {
/// Rich terminal output
Terminal,
/// One JSON object per line
Json,
/// No output
Quiet,
}
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum ReasoningEffortArg {
Low,
Medium,
High,
Xhigh,
}
impl From<ReasoningEffortArg> for ReasoningEffort {
fn from(arg: ReasoningEffortArg) -> Self {
match arg {
ReasoningEffortArg::Low => ReasoningEffort::Low,
ReasoningEffortArg::Medium => ReasoningEffort::Medium,
ReasoningEffortArg::High => ReasoningEffort::High,
ReasoningEffortArg::Xhigh => ReasoningEffort::XHigh,
}
}
}
#[derive(Subcommand, Debug)]
pub enum SubCommand {
/// Manage observability data.
Metrics {
#[command(subcommand)]
cmd: MetricsCmd,
},
/// Scaffold a new phi-agent project.
Init {
/// Project name
name: String,
/// Generate a single-shot example instead of REPL
#[arg(long)]
lib: bool,
},
/// Start the MCP server (stdio or HTTP JSON-RPC 2.0).
/// External orchestrators can call the `run` tool to delegate tasks.
/// Use --bridge for the legacy NDJSON protocol (Python/Node.js SDKs).
Serve {
/// Use HTTP transport (SSE streaming) on the given port.
/// Without this flag, stdio mode is used by default.
#[arg(long, value_name = "PORT")]
http: Option<u16>,
/// Use the legacy bridge protocol (NDJSON) instead of JSON-RPC 2.0 / MCP.
/// This is needed for the Python and Node.js SDKs.
#[arg(long, default_value = "false")]
bridge: bool,
},
/// Generate shell completion scripts.
Completions {
/// Shell to generate for (bash, zsh, fish, elvish, powershell).
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum MetricsSort {
Date,
Turns,
Chars,
Outcome,
}
#[derive(Subcommand, Debug)]
pub enum MetricsCmd {
/// List all sessions with token usage, cost, and outcome.
List {
/// Sort by: date (default), turns, chars, outcome.
#[arg(long, value_enum, default_value = "date")]
sort: MetricsSort,
},
/// Show detailed metrics for a specific session.
Show {
/// Session ID (e.g. "20260730_c52b4c91")
session_id: String,
},
/// Show the most recent session.
Last,
/// Export all session metrics as a JSON array.
Export {
/// Output file path (JSON). Defaults to stdout when omitted.
#[arg(long, short)]
output: Option<PathBuf>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// `ReasoningEffort` does not implement `PartialEq`, so the mapping is
/// asserted with exhaustive `matches!` checks instead.
fn assert_maps_to(arg: ReasoningEffortArg, expected: ReasoningEffort) {
let mapped = ReasoningEffort::from(arg);
assert!(
matches!(
(&mapped, &expected),
(ReasoningEffort::Low, ReasoningEffort::Low)
| (ReasoningEffort::Medium, ReasoningEffort::Medium)
| (ReasoningEffort::High, ReasoningEffort::High)
| (ReasoningEffort::XHigh, ReasoningEffort::XHigh)
),
"expected {expected:?}, got {mapped:?}"
);
}
#[test]
fn reasoning_effort_arg_maps_to_reasoning_effort() {
assert_maps_to(ReasoningEffortArg::Low, ReasoningEffort::Low);
assert_maps_to(ReasoningEffortArg::Medium, ReasoningEffort::Medium);
assert_maps_to(ReasoningEffortArg::High, ReasoningEffort::High);
// The variant is spelled `Xhigh` on the arg side and `XHigh` on the domain side.
assert_maps_to(ReasoningEffortArg::Xhigh, ReasoningEffort::XHigh);
}
#[test]
fn thinking_effort_flag_round_trips_to_reasoning_effort() {
for (flag, expected) in [
("low", ReasoningEffort::Low),
("medium", ReasoningEffort::Medium),
("high", ReasoningEffort::High),
("xhigh", ReasoningEffort::XHigh),
] {
let args = CliArgs::try_parse_from(["phi", "--thinking-effort", flag])
.unwrap_or_else(|err| panic!("--thinking-effort {flag} should parse: {err}"));
assert_maps_to(args.thinking_effort, expected);
}
assert!(CliArgs::command().get_arguments().any(|a| a.get_long() == Some("thinking-effort")));
}
}