rho-coding-agent 1.18.0

A lightweight agent harness inspired by Pi
Documentation
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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! `/login claude-code` and `/logout claude-code` for the external Claude Code runtime.
//!
//! This path never touches Rho's credential store. Claude Code owns the
//! sign-in, stores the token, and remains the source of truth after handoff.

use anyhow::Context;
use ratatui::DefaultTerminal;

use crate::claude_runtime::{
    auth::{self, ClaudeAuthError, ClaudeAuthStatus},
    executable,
};

use super::{
    external_editor, App, ComposerMode, Entry, InlineChoice, InlineChoiceModal, InlineChoiceOption,
    InlineChoicePending,
};

/// Stable `/login` and `/logout` target for the Claude Code subscription runtime.
pub(super) const CLAUDE_CODE_TARGET: &str = "claude-code";

pub(super) const RELAY_LOGIN_VALUE: &str = "continue";
pub(super) const KEEP_LOGIN_VALUE: &str = "keep";
pub(super) const CONFIRM_LOGOUT_VALUE: &str = "confirm";
pub(super) const CANCEL_LOGOUT_VALUE: &str = "cancel";

/// Login methods that are not Rho provider credentials.
///
/// The picker renders whatever it is handed; which group Claude Code belongs to
/// is this feature's policy, not the picker's.
pub(super) const EXTERNAL_LOGIN_METHODS: &[ExternalLoginMethod] = &[ExternalLoginMethod {
    // Claude Code is an Anthropic-family runtime for delegation, not a separate
    // top-level provider group. Keep it beside the Anthropic API key method.
    group_id: "anthropic",
    value: CLAUDE_CODE_TARGET,
    label: "Claude Code (delegation only)",
    detail: "External Claude binary subscription, not Anthropic API billing. \
Credentials are managed by Claude Code, not Rho.",
}];

/// One login method backed by an external runtime rather than a Rho credential.
pub(super) struct ExternalLoginMethod {
    /// Login group this method is offered under.
    pub(super) group_id: &'static str,
    /// Picker value, which is also the `/login` argument.
    pub(super) value: &'static str,
    pub(super) label: &'static str,
    pub(super) detail: &'static str,
}

/// What a `/login` or `/logout` argument names.
///
/// Parsed once at each command or picker boundary so the provider flows never
/// re-sniff for the external runtime.
pub(super) enum SignInTarget {
    /// Claude Code, whose credential the `claude` binary owns.
    ClaudeCode,
    /// A Rho provider credential.
    Provider(String),
}

impl SignInTarget {
    pub(super) fn parse(value: &str) -> Self {
        let value = value.trim();
        if value.eq_ignore_ascii_case(CLAUDE_CODE_TARGET) {
            Self::ClaudeCode
        } else {
            Self::Provider(value.to_string())
        }
    }
}

/// Post-login status outcome recorded in the transcript.
#[derive(Clone, Debug, PartialEq, Eq)]
enum ClaudeLoginAuthOutcome {
    Complete { notice: String },
    Incomplete { message: String },
    Failed { message: String },
}

impl ClaudeLoginAuthOutcome {
    fn status_line(&self) -> &'static str {
        match self {
            Self::Complete { .. } => "claude code login complete",
            Self::Incomplete { .. } => "claude code login incomplete",
            Self::Failed { .. } => "claude code login failed",
        }
    }
}

impl App {
    pub(super) async fn execute_claude_code_login(
        &mut self,
        terminal: &mut DefaultTerminal,
    ) -> anyhow::Result<()> {
        match auth::query().await {
            Ok(status) if status.logged_in => {
                self.prompt_claude_code_relogin(status);
                Ok(())
            }
            Ok(_) | Err(ClaudeAuthError::BinaryMissing) => {
                // Missing binary still goes through the handoff path so the
                // user sees the ownership notice and a clear spawn failure.
                self.run_claude_code_login(terminal).await
            }
            Err(error) => {
                self.insert_entry(&Entry::Error(error.to_string()));
                self.status = "claude code login failed".into();
                Ok(())
            }
        }
    }

    pub(super) async fn submit_claude_code_relogin_choice(
        &mut self,
        choice: InlineChoice,
        terminal: &mut DefaultTerminal,
    ) -> anyhow::Result<()> {
        match choice.selected_value() {
            RELAY_LOGIN_VALUE => self.run_claude_code_login(terminal).await,
            _ => {
                self.status = "claude code login unchanged".into();
                Ok(())
            }
        }
    }

    pub(super) fn prompt_claude_code_logout(&mut self) {
        let choice = InlineChoice::new(
            "Sign out of Claude Code everywhere?",
            auth::logout_confirm_description(),
            vec![
                InlineChoiceOption::available(
                    CANCEL_LOGOUT_VALUE,
                    '1',
                    "Cancel",
                    "Leave Claude Code signed in",
                )
                .with_alternate_shortcut('c'),
                InlineChoiceOption::available(
                    CONFIRM_LOGOUT_VALUE,
                    '2',
                    "Sign out everywhere",
                    "Run claude auth logout for this machine",
                )
                .with_alternate_shortcut('s'),
            ],
        )
        .expect("claude code logout choice has available options");
        self.input_ui
            .set_composer(ComposerMode::InlineChoice(InlineChoiceModal {
                choice,
                pending: InlineChoicePending::ClaudeCodeLogout,
            }));
        self.status = "confirm claude code logout".into();
    }

    pub(super) async fn submit_claude_code_logout_choice(
        &mut self,
        choice: InlineChoice,
    ) -> anyhow::Result<()> {
        match choice.selected_value() {
            CONFIRM_LOGOUT_VALUE => self.run_claude_code_logout().await,
            _ => {
                self.status = "claude code logout cancelled".into();
                Ok(())
            }
        }
    }

    pub(super) async fn execute_claude_code_logout(&mut self) -> anyhow::Result<()> {
        self.prompt_claude_code_logout();
        Ok(())
    }

    async fn run_claude_code_logout(&mut self) -> anyhow::Result<()> {
        // Always run logout, then treat a fresh status query as the truth.
        // Child failure is extra detail only.
        let logout_result = auth::logout().await;
        if let Err(ClaudeAuthError::BinaryMissing) = &logout_result {
            self.insert_entry(&Entry::Error(ClaudeAuthError::BinaryMissing.to_string()));
            self.status = "claude code logout failed".into();
            return Ok(());
        }

        match auth::query().await {
            Ok(status) if !status.logged_in => {
                let mut notice =
                    "claude code: signed out everywhere the claude binary is used".to_string();
                if let Err(error) = &logout_result {
                    notice.push_str(&format!(
                        "\nclaude auth logout also reported: {}",
                        error.sanitized_detail()
                    ));
                }
                self.insert_entry(&Entry::Notice(notice));
                self.status = "claude code logout complete".into();
            }
            Ok(status) => {
                let mut message = format!(
                    "claude auth logout finished, but status still reports signed in ({})",
                    status.describe()
                );
                if let Err(error) = &logout_result {
                    message.push_str(&format!("\nchild detail: {}", error.sanitized_detail()));
                }
                self.insert_entry(&Entry::Error(message));
                self.status = "claude code logout incomplete".into();
            }
            Err(error) => {
                let mut message =
                    format!("claude auth logout finished, but status could not be read: {error}");
                if let Err(child) = &logout_result {
                    message.push_str(&format!("\nchild detail: {}", child.sanitized_detail()));
                } else if let Some(excerpt) = error.stderr_excerpt() {
                    message.push_str(&format!("\nstderr: {excerpt}"));
                } else {
                    message.push_str(&format!("\ndetail: {}", error.sanitized_detail()));
                }
                self.insert_entry(&Entry::Error(message));
                self.status = "claude code logout incomplete".into();
            }
        }
        Ok(())
    }

    fn prompt_claude_code_relogin(&mut self, status: ClaudeAuthStatus) {
        let choice = InlineChoice::new(
            "Claude Code is already signed in",
            format!(
                "{}\nContinue only if you want to re-run `claude auth login --claudeai`.",
                status.describe()
            ),
            vec![
                InlineChoiceOption::available(
                    KEEP_LOGIN_VALUE,
                    '1',
                    "Keep current sign-in",
                    "Leave Claude Code as it is",
                )
                .with_alternate_shortcut('k'),
                InlineChoiceOption::available(
                    RELAY_LOGIN_VALUE,
                    '2',
                    "Sign in again",
                    "Hand the terminal to claude auth login",
                )
                .with_alternate_shortcut('s'),
            ],
        )
        .expect("claude code relogin choice has available options");
        self.input_ui
            .set_composer(ComposerMode::InlineChoice(InlineChoiceModal {
                choice,
                pending: InlineChoicePending::ClaudeCodeRelogin,
            }));
        self.status = "claude code already signed in".into();
    }

    async fn run_claude_code_login(
        &mut self,
        terminal: &mut DefaultTerminal,
    ) -> anyhow::Result<()> {
        self.insert_entry(&Entry::Notice(auth::login_handoff_notice().into()));
        // Draw the ownership notice before leaving the alternate screen so the
        // handoff message is part of session history after resume.
        terminal.draw(|frame| self.draw(frame))?;

        let mut terminal_session = self
            .terminal_session
            .take()
            .context("terminal session is unavailable")?;
        let suspended_run = terminal_session
            .run_suspended(terminal, "Signing in to Claude Code…", || async move {
                let executable = executable::resolve().map_err(anyhow::Error::new)?;
                let mut command = executable
                    .try_command(auth::login_args().iter().copied())
                    .map_err(anyhow::Error::new)?;
                command
                    .stdin(std::process::Stdio::inherit())
                    .stdout(std::process::Stdio::inherit())
                    .stderr(std::process::Stdio::inherit());
                #[cfg(unix)]
                let _signal_guard =
                    external_editor::unix_suspended_child_signals::SuspendedChildSignalGuard::install(
                        &mut command,
                    )
                    .context("could not prepare claude login signal handling")?;
                let status = command.status().await.map_err(|source| {
                    if source.kind() == std::io::ErrorKind::NotFound {
                        anyhow::Error::new(ClaudeAuthError::BinaryMissing)
                    } else {
                        anyhow::Error::from(source).context("could not start claude auth login")
                    }
                })?;
                if !status.success() {
                    return Err(anyhow::anyhow!("claude auth login exited with {status}"));
                }
                Ok(())
            })
            .await;
        self.terminal_session = Some(terminal_session);

        // resume_result owns terminal lifecycle and must win first. Auth status
        // and child-exit presentation are only safe once the TUI is back.
        match resolve_claude_login_after_suspend(
            suspended_run.resume_result,
            suspended_run.operation_result,
            auth::query,
        )
        .await
        {
            ClaudeLoginAfterSuspend::ResumeFailed { error } => return Err(error),
            ClaudeLoginAfterSuspend::AuthResolved { outcome } => {
                self.record_claude_login_auth_outcome(&outcome);
            }
        }

        self.ctrl_c_streak = 0;
        self.input_ui.clear_paste_burst();
        Ok(())
    }

    fn record_claude_login_auth_outcome(&mut self, outcome: &ClaudeLoginAuthOutcome) {
        match outcome {
            ClaudeLoginAuthOutcome::Complete { notice } => {
                self.insert_entry(&Entry::Notice(notice.clone()));
            }
            ClaudeLoginAuthOutcome::Incomplete { message }
            | ClaudeLoginAuthOutcome::Failed { message } => {
                self.insert_entry(&Entry::Error(message.clone()));
            }
        }
        self.status = outcome.status_line().into();
    }
}

/// Result of post-suspend Claude login handling.
#[derive(Debug)]
enum ClaudeLoginAfterSuspend {
    ResumeFailed { error: anyhow::Error },
    AuthResolved { outcome: ClaudeLoginAuthOutcome },
}

/// Check terminal resume before any auth status work or child-result UI.
///
/// `query` is injected so unit tests can prove resume failures never call it,
/// while successful resume always re-queries regardless of child exit.
async fn resolve_claude_login_after_suspend<F, Fut>(
    resume_result: Result<(), anyhow::Error>,
    operation_result: Result<(), anyhow::Error>,
    query: F,
) -> ClaudeLoginAfterSuspend
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = Result<ClaudeAuthStatus, ClaudeAuthError>>,
{
    if let Err(resume_error) = resume_result {
        let error = match operation_result {
            Ok(()) => resume_error,
            Err(operation_error) => resume_error.context(format!(
                "claude auth login also failed: {operation_error:#}"
            )),
        };
        return ClaudeLoginAfterSuspend::ResumeFailed { error };
    }

    ClaudeLoginAfterSuspend::AuthResolved {
        outcome: resolve_claude_login_auth_outcome(operation_result, query).await,
    }
}

/// Map login child result plus a fresh auth status probe into UI state.
///
/// `query` is injected so unit tests can cover the state machine without
/// spawning `claude` or reading personal auth.
async fn resolve_claude_login_auth_outcome<F, Fut>(
    operation_result: Result<(), anyhow::Error>,
    query: F,
) -> ClaudeLoginAuthOutcome
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = Result<ClaudeAuthStatus, ClaudeAuthError>>,
{
    match operation_result {
        Ok(()) => match query().await {
            Ok(status) if status.logged_in => ClaudeLoginAuthOutcome::Complete {
                notice: status.describe_login_success(),
            },
            Ok(status) => ClaudeLoginAuthOutcome::Incomplete {
                message: format!(
                    "claude auth login finished, but status still reports signed out ({})",
                    status.describe()
                ),
            },
            Err(error) => ClaudeLoginAuthOutcome::Incomplete {
                message: format!(
                    "claude auth login finished, but status could not be read: {error}"
                ),
            },
        },
        Err(error) => {
            // Prefer a post-status check when the child failed; the user may
            // already be signed in from a previous attempt.
            match query().await {
                Ok(status) if status.logged_in => ClaudeLoginAuthOutcome::Complete {
                    notice: format!(
                        "claude auth login reported an error ({error:#}), but status shows signed in.\n{}",
                        status.describe_login_success()
                    ),
                },
                _ => ClaudeLoginAuthOutcome::Failed {
                    message: format!("claude code login failed: {error:#}"),
                },
            }
        }
    }
}

#[cfg(test)]
#[path = "claude_login_tests.rs"]
mod tests;