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    #[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;