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