main
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 1 | //! Account access for siGit Code Cloud, surfaced as the `/login`, `/logout`, |
| 2 | //! and `/whoami` slash commands in both the TUI and ACP sessions. |
| 3 | //! |
| 4 | //! These functions authenticate against the siGit account API and store a |
| 5 | //! session token locally. The token is the credential used for siGit Code Cloud |
| 6 | //! requests. They perform no console I/O, so each slash surface can render the |
| 7 | //! returned message however it likes. |
| 8 | //! |
| 9 | //! Base URL: `$SIGIT_API_URL`, else `https://sigit.si`. |
| 10 | |
| 11 | use serde::Deserialize; |
| 12 | |
| 13 | use crate::credentials::{self, Credentials}; |
| 14 | |
| 15 | /// Default account API host. Override with `SIGIT_API_URL` (dev: `http://localhost:8088`). |
| 16 | const DEFAULT_API_URL: &str = "https://sigit.si"; |
| 17 | |
| 18 | fn api_base() -> String { |
| 19 | std::env::var("SIGIT_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()) |
| 20 | } |
| 21 | |
| 22 | // Sign-in returns an `AccountStatus`, one of: |
| 23 | // "NotFound" (a bare JSON string) |
| 24 | // {"Ready":{"access_token":"…"}} |
| 25 | // {"Incomplete":{"status":<u32>}} |
| 26 | // Failures return {"error_code":<i32>,"message":"…"}. Parsed from a |
| 27 | // `serde_json::Value` rather than a struct because of the bare-string variant. |
| 28 | |
| 29 | #[derive(Debug, Deserialize)] |
| 30 | struct MeResponse { |
| 31 | #[serde(default)] |
| 32 | email: Option<String>, |
| 33 | } |
| 34 | |
| 35 | /// Authenticate with email and password, storing the session token on success. |
| 36 | /// Returns the signed-in email, or a human-readable error message. |
| 37 | pub async fn authenticate(email: &str, password: &str) -> Result<String, String> { |
| 38 | let email = email.trim(); |
| 39 | if email.is_empty() || password.is_empty() { |
| 40 | return Err("email and password are required".to_string()); |
| 41 | } |
| 42 | |
| 43 | let url = format!("{}/api/v1/auth/sign_in", api_base().trim_end_matches('/')); |
| 44 | let response = reqwest::Client::new() |
| 45 | .post(&url) |
| 46 | .json(&serde_json::json!({ "email": email, "password": password })) |
| 47 | .send() |
| 48 | .await |
| 49 | .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?; |
| 50 | |
| 51 | let status = response.status(); |
| 52 | let body: serde_json::Value = response |
| 53 | .json() |
| 54 | .await |
| 55 | .map_err(|error| format!("unexpected response from siGit Code Cloud: {error}"))?; |
| 56 | |
| 57 | if status.is_success() { |
| 58 | // AccountStatus::Ready |
| 59 | if let Some(token) = body |
| 60 | .get("Ready") |
| 61 | .and_then(|ready| ready.get("access_token")) |
| 62 | .and_then(|token| token.as_str()) |
| 63 | .filter(|token| !token.trim().is_empty()) |
| 64 | { |
| 65 | credentials::store(&Credentials { |
| 66 | access_token: token.to_string(), |
| 67 | email: Some(email.to_string()), |
| 68 | })?; |
| 69 | return Ok(email.to_string()); |
| 70 | } |
| 71 | // AccountStatus::Incomplete |
| 72 | if body.get("Incomplete").is_some() { |
| 73 | return Err( |
| 74 | "your account is not verified yet. Check your email to confirm it, then sign in again." |
| 75 | .to_string(), |
| 76 | ); |
| 77 | } |
| 78 | // AccountStatus::NotFound (a bare JSON string) |
| 79 | if body.as_str() == Some("NotFound") { |
| 80 | return Err(format!("no siGit account found for {email}.")); |
| 81 | } |
| 82 | return Err("unexpected sign-in response from siGit Code Cloud".to_string()); |
| 83 | } |
| 84 | |
| 85 | // ErrorResponse { error_code, message } |
| 86 | let message = body |
| 87 | .get("message") |
| 88 | .and_then(|message| message.as_str()) |
| 89 | .unwrap_or("sign-in failed"); |
| 90 | Err(format!("sign-in failed: {message}")) |
| 91 | } |
| 92 | |
| 93 | /// Verify that a usable session exists, confirming the stored token against the |
| 94 | /// account API. Returns the signed-in email on success, or a human-readable |
| 95 | /// reason it is not usable. Used by the ACP `authenticate` handler to answer the |
| 96 | /// editor's auth gate. |
| 97 | pub async fn verify_session() -> Result<String, String> { |
| 98 | let Some(creds) = credentials::load() else { |
| 99 | return Err("not signed in".to_string()); |
| 100 | }; |
| 101 | |
| 102 | let url = format!("{}/api/v1/me", api_base().trim_end_matches('/')); |
| 103 | let response = reqwest::Client::new() |
| 104 | .get(&url) |
| 105 | .bearer_auth(&creds.access_token) |
| 106 | .send() |
| 107 | .await |
| 108 | .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?; |
| 109 | |
| 110 | if response.status().is_success() { |
| 111 | let email = response |
| 112 | .json::<MeResponse>() |
| 113 | .await |
| 114 | .ok() |
| 115 | .and_then(|me| me.email) |
| 116 | .or(creds.email) |
| 117 | .unwrap_or_else(|| "(unknown)".to_string()); |
| 118 | Ok(email) |
| 119 | } else { |
| 120 | Err(format!( |
| 121 | "session expired (HTTP {})", |
| 122 | response.status().as_u16() |
| 123 | )) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /// Run an interactive sign-in against the terminal, prompting for email and a |
| 128 | /// hidden password. Used by `sigit login`, which the editor launches in an |
| 129 | /// embedded terminal for ACP terminal-based authentication. Returns the signed-in |
| 130 | /// email or a human-readable error. |
| 131 | pub async fn interactive_login() -> Result<String, String> { |
| 132 | use std::io::{Write, stdin, stdout}; |
| 133 | |
| 134 | print!("siGit Code email: "); |
| 135 | stdout().flush().ok(); |
| 136 | let mut email = String::new(); |
| 137 | stdin() |
| 138 | .read_line(&mut email) |
| 139 | .map_err(|error| format!("could not read email: {error}"))?; |
| 140 | |
| 141 | let password = rpassword::prompt_password("siGit Code password: ") |
| 142 | .map_err(|error| format!("could not read password: {error}"))?; |
| 143 | |
| 144 | authenticate(email.trim(), &password).await |
| 145 | } |
| 146 | |
| 147 | /// Clear the local session, notifying the server best-effort. Returns a message |
| 148 | /// suitable for display. |
| 149 | pub async fn end_session() -> String { |
| 150 | if let Some(token) = credentials::load_token() { |
| 151 | let url = format!("{}/api/v1/auth/sign_out", api_base().trim_end_matches('/')); |
| 152 | // A failed server call must not block local sign-out. |
| 153 | let _ = reqwest::Client::new() |
| 154 | .delete(&url) |
| 155 | .bearer_auth(&token) |
| 156 | .send() |
| 157 | .await; |
| 158 | } |
| 159 | if credentials::clear() { |
| 160 | "Signed out of siGit Code Cloud.".to_string() |
| 161 | } else { |
| 162 | "Not signed in.".to_string() |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | /// One-line description of the current session, verifying the token if reachable. |
| 167 | pub async fn status_line() -> String { |
| 168 | let Some(creds) = credentials::load() else { |
| 169 | return "Not signed in. Use `/login <email> <password>` to use siGit Code Cloud." |
| 170 | .to_string(); |
| 171 | }; |
| 172 | |
| 173 | let url = format!("{}/api/v1/me", api_base().trim_end_matches('/')); |
| 174 | match reqwest::Client::new() |
| 175 | .get(&url) |
| 176 | .bearer_auth(&creds.access_token) |
| 177 | .send() |
| 178 | .await |
| 179 | { |
| 180 | Ok(response) if response.status().is_success() => { |
| 181 | let email = response |
| 182 | .json::<MeResponse>() |
| 183 | .await |
| 184 | .ok() |
| 185 | .and_then(|me| me.email) |
| 186 | .or(creds.email) |
| 187 | .unwrap_or_else(|| "(unknown)".to_string()); |
| 188 | format!("Signed in to siGit Code Cloud as {email}.") |
| 189 | } |
| 190 | Ok(response) => format!( |
| 191 | "Session may be expired (HTTP {}). Use `/login` again.", |
| 192 | response.status().as_u16() |
| 193 | ), |
| 194 | Err(_) => { |
| 195 | let email = creds.email.unwrap_or_else(|| "(unknown)".to_string()); |
| 196 | format!("Signed in as {email} (could not reach siGit Code Cloud to verify).") |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /// Split a `/login` argument into `(email, password)`. The password is the rest |
| 202 | /// of the line after the first whitespace, so it may contain spaces. |
| 203 | pub fn parse_login_args(arg: &str) -> Option<(String, String)> { |
| 204 | let mut parts = arg.trim().splitn(2, char::is_whitespace); |
| 205 | let email = parts.next().unwrap_or("").trim(); |
| 206 | let password = parts.next().unwrap_or("").trim(); |
| 207 | if email.is_empty() || password.is_empty() { |
| 208 | None |
| 209 | } else { |
| 210 | Some((email.to_string(), password.to_string())) |
| 211 | } |
| 212 | } |