rho_coding_agent/cli.rs
1use std::{num::NonZeroUsize, path::PathBuf, time::Duration};
2
3use clap::{Parser, Subcommand, ValueEnum};
4
5use rho_providers::{credentials::CredentialStoreBackend, reasoning::ReasoningLevel};
6
7use crate::{
8 app::automation_protocol::parse_duration,
9 permission::{PermissionMode, PermissionModeParseError},
10};
11
12fn parse_permission_mode(value: &str) -> Result<PermissionMode, PermissionModeParseError> {
13 value.parse()
14}
15
16fn parse_credential_store_backend(value: &str) -> Result<CredentialStoreBackend, String> {
17 CredentialStoreBackend::parse(value).map_err(|error| error.to_string())
18}
19
20fn parse_auth_profile(value: &str) -> Result<String, String> {
21 let profiles = rho_providers::auth_profiles();
22 if profiles.contains(&value) {
23 return Ok(value.to_string());
24 }
25 if rho_providers::provider::is_custom_provider_api_key_auth(value) {
26 return Ok(value.to_string());
27 }
28 Err(format!(
29 "invalid value '{value}' for '--auth'; expected one of: {}",
30 profiles.join(", ")
31 ))
32}
33
34/// Output contract used by a non-interactive `rho run` invocation.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
36pub enum OutputFormat {
37 /// Print only the authoritative final assistant answer.
38 #[default]
39 Text,
40 /// Stream independently versioned JSON Lines events.
41 Jsonl,
42}
43
44/// Output contract for workflow plans and snapshots.
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
46pub enum WorkflowDocumentFormat {
47 /// Print a human-readable document.
48 #[default]
49 Text,
50 /// Print one JSON document.
51 Json,
52}
53
54/// Output contract for workflow execution.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
56pub enum WorkflowRunFormat {
57 /// Print human-readable state changes.
58 Text,
59 /// Stream versioned JSON Lines events.
60 Jsonl,
61}
62
63#[derive(Parser, Debug)]
64#[command(name = "rho")]
65pub struct Cli {
66 #[arg(long)]
67 pub provider: Option<String>,
68 #[arg(long)]
69 pub model: Option<String>,
70 #[arg(long)]
71 pub config: Option<PathBuf>,
72 #[arg(long, value_parser = parse_auth_profile)]
73 pub auth: Option<String>,
74 /// Do not send rho's system prompt, including AGENTS.md and skill context.
75 #[arg(long)]
76 pub no_system_prompt: bool,
77 /// Do not expose any tools to the model.
78 #[arg(long)]
79 pub no_tools: bool,
80 /// Do not expose the delegated-agent tools (agent/agents) to the model.
81 #[arg(long, global = true)]
82 pub no_subagents: bool,
83 /// Select the agent definition used for this session or automation run.
84 #[arg(long, global = true, value_name = "ID")]
85 pub agent: Option<String>,
86 /// Override reasoning level: off, minimal, low, medium, high, xhigh, or max.
87 #[arg(long)]
88 pub reasoning: Option<ReasoningLevel>,
89 /// Override permission mode: bypass, auto, allow_edits, plan, or supervised.
90 #[arg(long, value_name = "MODE", value_parser = parse_permission_mode)]
91 pub(crate) permission_mode: Option<PermissionMode>,
92 /// Persist --provider/--model/--auth/--reasoning overrides to the config file.
93 ///
94 /// Without this flag, those overrides apply only to the current invocation.
95 #[arg(long)]
96 pub save: bool,
97 /// Resume an existing session by UUID or UUID prefix. Omit the ID to choose from a picker.
98 #[arg(short = 'R', long, value_name = "ID", num_args = 0..=1)]
99 pub resume: Option<Option<String>>,
100 #[command(subcommand)]
101 pub command: Option<Command>,
102}
103
104#[derive(Subcommand, Debug)]
105pub enum Command {
106 /// Run one non-interactive automation prompt and print the final answer.
107 Run {
108 /// Read additional prompt text from stdin.
109 ///
110 /// Required when stdin is a pipe or redirected file. Without this flag,
111 /// redirected stdin is rejected so prompt text is not silently dropped.
112 #[arg(long)]
113 stdin: bool,
114 /// Write a structured status/result file (JSON) that is updated during
115 /// the run and finalized on exit. With `--output text` (the default),
116 /// progress and streamed assistant text go to stdout and the run ends
117 /// with a completion marker; the result file is the durable final
118 /// answer. With `--output jsonl`, stdout stays the JSONL event stream.
119 #[arg(long, value_name = "PATH")]
120 output_file: Option<PathBuf>,
121 /// Select plain final-answer output or a JSON Lines event stream.
122 #[arg(long, value_enum, default_value_t)]
123 output: OutputFormat,
124 /// Override the model-step budget for this run.
125 #[arg(long, value_name = "N")]
126 max_steps: Option<NonZeroUsize>,
127 /// Cancel the run after this wall-clock duration.
128 #[arg(long, value_name = "DURATION", value_parser = parse_duration)]
129 timeout: Option<Duration>,
130 /// Prompt text to send to the agent.
131 #[arg(value_name = "PROMPT", num_args = 0..)]
132 prompt: Vec<String>,
133 },
134 /// Watch a delegated agent run in a read-only TUI.
135 Attach {
136 /// Delegated run ID shown when the agent was started.
137 ///
138 /// Omit to pick from subagents in this directory.
139 #[arg(value_name = "ID")]
140 id: Option<String>,
141 },
142 /// Log in to a provider from a browser or device-code flow.
143 Login {
144 /// Provider to authenticate, for example openai-codex or github-copilot.
145 #[arg(value_name = "PROVIDER")]
146 provider: String,
147 /// Use device-code login instead of opening a local browser callback.
148 #[arg(long)]
149 device_auth: bool,
150 },
151 /// Configure or probe provider credential storage.
152 CredentialStore {
153 #[command(subcommand)]
154 command: CredentialStoreCommand,
155 },
156 /// Update rho using the detected installation method.
157 Update,
158 /// List, rename, or delete saved sessions.
159 Sessions {
160 #[command(subcommand)]
161 command: SessionsCommand,
162 },
163 /// Inspect configured Model Context Protocol servers.
164 Mcp {
165 #[command(subcommand)]
166 command: McpCommand,
167 },
168 /// List, inspect, install, and activate local Agent Plugin packages.
169 Plugins {
170 #[command(subcommand)]
171 command: PluginsCommand,
172 },
173 /// Validate, plan, run, and inspect deterministic workflows.
174 Workflow {
175 #[command(subcommand)]
176 command: WorkflowCommand,
177 },
178 /// Serve the Agent Client Protocol over stdio for editor/host integration.
179 Acp,
180 /// Internal supervised workflow planner worker. Not a public command.
181 #[command(name = "__workflow_planner_worker", hide = true)]
182 WorkflowPlannerWorker,
183}
184
185/// Argv entry for the internal supervised planner worker process.
186pub const WORKFLOW_PLANNER_WORKER_COMMAND: &str = "__workflow_planner_worker";
187
188#[derive(Subcommand, Debug)]
189pub enum WorkflowCommand {
190 /// List saved workflow plans and runs for the current workspace.
191 List {
192 /// Include only saved plans.
193 #[arg(long, conflicts_with = "runs")]
194 plans: bool,
195 /// Include only runs.
196 #[arg(long, conflicts_with = "plans")]
197 runs: bool,
198 /// Limit how many rows to print per section.
199 #[arg(long, value_name = "N")]
200 limit: Option<NonZeroUsize>,
201 /// Print one JSON document instead of text rows.
202 #[arg(long)]
203 json: bool,
204 },
205 /// Validate a Starlark workflow without creating durable state.
206 Validate {
207 /// Workflow entry file under the current workspace.
208 #[arg(value_name = "FILE")]
209 file: PathBuf,
210 /// Supply one non-secret workflow input as KEY=JSON. May be repeated.
211 #[arg(long, value_name = "KEY=JSON")]
212 input: Vec<String>,
213 },
214 /// Validate, freeze, and persist an immutable workflow plan.
215 Plan {
216 /// Workflow entry file under the current workspace.
217 #[arg(value_name = "FILE")]
218 file: PathBuf,
219 /// Supply one non-secret workflow input as KEY=JSON. May be repeated.
220 #[arg(long, value_name = "KEY=JSON")]
221 input: Vec<String>,
222 /// Select human-readable or machine-readable plan output.
223 #[arg(long, value_enum, default_value_t)]
224 output: WorkflowDocumentFormat,
225 },
226 /// Create and run a workflow from an immutable plan.
227 Run {
228 /// Full plan UUID or unique UUID prefix.
229 #[arg(value_name = "PLAN_ID")]
230 plan_id: String,
231 /// Confirm the exact plan digest without an interactive prompt.
232 #[arg(long)]
233 yes: bool,
234 /// Select text or JSON Lines instead of the workflow TUI.
235 #[arg(long, value_enum)]
236 output: Option<WorkflowRunFormat>,
237 },
238 /// Read one durable workflow run snapshot.
239 Status {
240 /// Full run UUID or unique UUID prefix.
241 #[arg(value_name = "RUN_ID")]
242 run_id: String,
243 /// Select human-readable or machine-readable snapshot output.
244 #[arg(long, value_enum, default_value_t)]
245 output: WorkflowDocumentFormat,
246 },
247 /// Request cancellation of a durable workflow run.
248 Cancel {
249 /// Full run UUID or unique UUID prefix.
250 #[arg(value_name = "RUN_ID")]
251 run_id: String,
252 },
253 /// Resume a durable workflow run from its frozen graph.
254 Resume {
255 /// Full run UUID or unique UUID prefix.
256 #[arg(value_name = "RUN_ID")]
257 run_id: String,
258 /// Confirm the frozen graph without an interactive prompt.
259 #[arg(long)]
260 yes: bool,
261 /// Confirm that no prior process remains and relaunch uncertain attempts.
262 #[arg(long)]
263 recover_uncertain: bool,
264 /// Select text or JSON Lines instead of the workflow TUI.
265 #[arg(long, value_enum)]
266 output: Option<WorkflowRunFormat>,
267 },
268}
269
270#[derive(Subcommand, Debug)]
271pub enum SessionsCommand {
272 /// List saved sessions for the current workspace (or all projects).
273 List {
274 /// Include sessions from every workspace, not only the current directory.
275 #[arg(long)]
276 all_projects: bool,
277 /// Case-insensitive filter over id, title, and first user message.
278 #[arg(long, short = 'q', value_name = "TEXT")]
279 search: Option<String>,
280 /// Limit how many sessions to print.
281 #[arg(long, value_name = "N")]
282 limit: Option<NonZeroUsize>,
283 /// Print one JSON document instead of text rows.
284 #[arg(long)]
285 json: bool,
286 },
287 /// Export a saved session transcript by UUID or UUID prefix.
288 Export {
289 /// Session UUID or unique prefix.
290 #[arg(value_name = "ID")]
291 id_prefix: String,
292 /// Output path. Omit to write under ~/.rho/exports/.
293 #[arg(long, short = 'o', value_name = "PATH")]
294 output: Option<PathBuf>,
295 /// Explicit format. When omitted, the path extension selects html, md, or json.
296 #[arg(long, value_enum)]
297 format: Option<crate::export::ExportFormat>,
298 /// Overwrite an existing file.
299 #[arg(long)]
300 force: bool,
301 },
302 /// Delete one or more sessions by UUID or UUID prefix.
303 Rm {
304 /// Session UUID or unique prefix. May be repeated.
305 #[arg(value_name = "ID", required = true, num_args = 1..)]
306 ids: Vec<String>,
307 /// Delete even when a parent-linked run is still non-terminal.
308 ///
309 /// Use only for stale Starting/Running artifacts left after a crash.
310 #[arg(long)]
311 force: bool,
312 /// Skip the confirmation prompt for cross-project deletes.
313 #[arg(short = 'y', long)]
314 yes: bool,
315 },
316 /// Delete sessions whose workspace directories no longer exist.
317 Cleanup {
318 /// Delete even when a parent-linked run is still non-terminal.
319 ///
320 /// Use only for stale Starting/Running artifacts left after a crash.
321 #[arg(long)]
322 force: bool,
323 /// Skip the confirmation prompt.
324 #[arg(short = 'y', long)]
325 yes: bool,
326 },
327 /// Rename a session by UUID or UUID prefix.
328 Rename {
329 /// Session UUID or unique prefix.
330 #[arg(value_name = "ID")]
331 id_prefix: String,
332 /// New session title. Multiple words are joined with spaces.
333 #[arg(value_name = "TITLE", required = true, num_args = 1.., trailing_var_arg = true)]
334 title: Vec<String>,
335 },
336}
337
338#[derive(Subcommand, Debug)]
339pub enum McpCommand {
340 /// List configured MCP servers from the selected config and plugins.
341 List {
342 /// Print one JSON document instead of text rows.
343 #[arg(long)]
344 json: bool,
345 /// Start enabled servers and report live connection status.
346 #[arg(long)]
347 connect: bool,
348 },
349 /// Show one MCP server by identity.
350 Show {
351 /// Server table key from `[mcp.servers.<id>]`.
352 #[arg(value_name = "ID")]
353 id: String,
354 /// Print one JSON document instead of text.
355 #[arg(long)]
356 json: bool,
357 /// Start enabled servers and report live connection status.
358 #[arg(long)]
359 connect: bool,
360 },
361}
362
363/// Target scope for plugin install and link.
364#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
365pub enum PluginsScope {
366 /// User root: `~/.agents/plugins`.
367 #[default]
368 User,
369 /// Project root: `<repository>/.agents/plugins`.
370 Project,
371}
372
373#[derive(Subcommand, Debug)]
374pub enum PluginsCommand {
375 /// List discovered Agent Plugin packages.
376 List {
377 /// Print one JSON document instead of text rows.
378 #[arg(long)]
379 json: bool,
380 },
381 /// Inspect one plugin by package name without executing package code.
382 Inspect {
383 /// Plugin package name from `plugin.json`.
384 #[arg(value_name = "NAME")]
385 name: String,
386 /// Print one JSON document instead of text.
387 #[arg(long)]
388 json: bool,
389 },
390 /// Copy a local plugin package into a managed plugins root.
391 Install {
392 /// Path to a directory that contains `plugin.json`.
393 #[arg(value_name = "PATH")]
394 path: PathBuf,
395 /// Install under the user or project plugins root.
396 #[arg(long, value_enum, default_value_t = PluginsScope::User)]
397 scope: PluginsScope,
398 /// Replace an existing package at the destination.
399 #[arg(long)]
400 force: bool,
401 },
402 /// Symlink a local plugin package into a managed plugins root.
403 Link {
404 /// Path to a directory that contains `plugin.json`.
405 #[arg(value_name = "PATH")]
406 path: PathBuf,
407 /// Link under the user or project plugins root.
408 #[arg(long, value_enum, default_value_t = PluginsScope::User)]
409 scope: PluginsScope,
410 /// Replace an existing package at the destination.
411 #[arg(long)]
412 force: bool,
413 },
414 /// Enable a discovered plugin for new sessions.
415 Enable {
416 /// Plugin package name from `plugin.json`.
417 #[arg(value_name = "NAME")]
418 name: String,
419 },
420 /// Disable a discovered plugin without deleting package files.
421 Disable {
422 /// Plugin package name from `plugin.json`.
423 #[arg(value_name = "NAME")]
424 name: String,
425 },
426 /// Remove an installed or linked package from a managed plugins root.
427 Remove {
428 /// Plugin package name from `plugin.json`.
429 #[arg(value_name = "NAME")]
430 name: String,
431 /// Skip the confirmation prompt.
432 #[arg(short = 'y', long)]
433 yes: bool,
434 },
435}
436
437#[derive(Subcommand, Debug)]
438pub enum CredentialStoreCommand {
439 /// Test a credential backend by writing and deleting a temporary secret.
440 Probe {
441 /// Backend to test: os or file (`auto` is accepted as an alias for os).
442 #[arg(
443 value_name = "BACKEND",
444 default_value = "os",
445 value_parser = parse_credential_store_backend
446 )]
447 backend: CredentialStoreBackend,
448 },
449 /// Show the configured credential backend (unset, os, or file).
450 Status,
451 /// Save the credential backend used by future rho processes.
452 Set {
453 /// Backend to use: os or file (`auto` is accepted as an alias for os).
454 #[arg(value_name = "BACKEND", value_parser = parse_credential_store_backend)]
455 backend: CredentialStoreBackend,
456 },
457}
458
459#[cfg(test)]
460#[path = "cli_tests.rs"]
461mod tests;