Skip to main content

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;