@setoelkahfi / sigit / commits / b0e7b7a

Wire ACP auth, cloud tiers, and slash commands for the Zed panel

Make the editor (ACP) path reach parity with the TUI for siGit Code Cloud. - handle_authenticate verifies the stored session against sigit.si /api/v1/me instead of being a no-op, so the panel's auth button reports status (and the Agent auth method's description points to /login). - Account verbs work as both ACP slash commands and `sigit login|logout|whoami` shell subcommands; add account::verify_session and interactive_login. - Advertise slash commands via AvailableCommandsUpdate on session create/load/fork — without this Zed rejects /login, /models, etc. client-side. - Show siGit Code Cloud tiers in the panel model picker and /models list; selecting a tier hot-swaps the backend (sign-in gated) via shared switch_to_cloud_tier / reset_to_local_backend helpers. - Route the ACP prompt loop through InferenceBackend (was calling the onde ChatEngine directly) so cloud tiers actually run; skip local model load when the active backend is remote, and carry the project cwd into the cloud prompt. - Fix /models cloud section rendering (blank line so items 9+ form an ordered list under CommonMark). Deps: enable agent-client-protocol unstable_auth_methods; add rpassword. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 24, 2026 at 15:21 UTC b0e7b7acf7ae3c68e0311b8f311d60785e6b16af
4 files changed +318 -40
Cargo.lock
+22
index c66ff1e..18f2211 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4586,6 +4586,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rubato" version = "0.16.2" @@ -5202,6 +5223,7 @@ dependencies = [ "ratatui", "regex", "reqwest 0.12.28", + "rpassword", "serde", "serde_json", "tokio",
Cargo.toml
+2 -1
index f9b0e97..cd5c85e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ path = "src/main.rs" [dependencies] # ACP protocol SDK -agent-client-protocol = { version = "0.13", features = ["unstable_session_fork", "unstable_session_additional_directories"] } +agent-client-protocol = { version = "0.13", features = ["unstable_session_fork", "unstable_session_additional_directories", "unstable_auth_methods"] } # Onde Inference engine (local LLM) # onde = { path = "../onde" } @@ -45,3 +45,4 @@ async-trait = "0.1" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } uuid = { version = "1", features = ["v4"] } +rpassword = "7"
src/account.rs
+54
index 9fd9f04..9faa6e8 100644 --- a/src/account.rs +++ b/src/account.rs @@ -90,6 +90,60 @@ pub async fn authenticate(email: &str, password: &str) -> Result<String, String> Err(format!("sign-in failed: {message}")) } +/// Verify that a usable session exists, confirming the stored token against the +/// account API. Returns the signed-in email on success, or a human-readable +/// reason it is not usable. Used by the ACP `authenticate` handler to answer the +/// editor's auth gate. +pub async fn verify_session() -> Result<String, String> { + let Some(creds) = credentials::load() else { + return Err("not signed in".to_string()); + }; + + let url = format!("{}/api/v1/me", api_base().trim_end_matches('/')); + let response = reqwest::Client::new() + .get(&url) + .bearer_auth(&creds.access_token) + .send() + .await + .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?; + + if response.status().is_success() { + let email = response + .json::<MeResponse>() + .await + .ok() + .and_then(|me| me.email) + .or(creds.email) + .unwrap_or_else(|| "(unknown)".to_string()); + Ok(email) + } else { + Err(format!( + "session expired (HTTP {})", + response.status().as_u16() + )) + } +} + +/// Run an interactive sign-in against the terminal, prompting for email and a +/// hidden password. Used by `sigit login`, which the editor launches in an +/// embedded terminal for ACP terminal-based authentication. Returns the signed-in +/// email or a human-readable error. +pub async fn interactive_login() -> Result<String, String> { + use std::io::{Write, stdin, stdout}; + + print!("siGit Code email: "); + stdout().flush().ok(); + let mut email = String::new(); + stdin() + .read_line(&mut email) + .map_err(|error| format!("could not read email: {error}"))?; + + let password = rpassword::prompt_password("siGit Code password: ") + .map_err(|error| format!("could not read password: {error}"))?; + + authenticate(email.trim(), &password).await +} + /// Clear the local session, notifying the server best-effort. Returns a message /// suitable for display. pub async fn end_session() -> String {
src/main.rs
+240 -39
index 36fd4a2..180d265 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ use onde::inference::SamplingConfig; use agent_client_protocol::schema::{ AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, + AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, UnstructuredCommandInput, CancelNotification, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest, @@ -56,12 +57,11 @@ use agent_client_protocol::schema::{ ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, }; use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder}; -use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult}; +use onde::inference::{ChatEngine, GgufModelConfig}; -// These back the interactive client (`run_interactive`), which is `#[cfg(unix)]`; -// the import is unused on non-Unix targets that run ACP-only. -#[cfg_attr(not(unix), allow(unused_imports))] -use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend}; +use crate::backend::{ + InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec, +}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -210,10 +210,15 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str { /// cap tool-call loops so a confused model can't spin forever const MAX_TOOL_ROUNDS: usize = 10; -fn agent_tools_as_onde() -> Vec<ToolDefinition> { +/// Shown when a siGit Code Cloud tier is selected without a signed-in account. +const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \ + `/login <email> <password>` (or the Authenticate button), then pick the tier again. \ + Create an account at https://sigit.si."; + +fn agent_tools_as_specs() -> Vec<ToolSpec> { tools::all_tools() .into_iter() - .map(|t| ToolDefinition { + .map(|t| ToolSpec { name: t.name.to_string(), description: t.description.to_string(), parameters_schema: t.parameters_schema.to_string(), @@ -271,6 +276,9 @@ fn initialize_meta() -> Meta { struct SiGitAgent { engine: Arc<ChatEngine>, + /// The active inference backend. `LocalBackend` by default; swapped to an + /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel. + backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>, /// cwd from the editor — tool calls run here, not where the process started session_cwd: std::sync::Mutex<Option<PathBuf>>, current_model: std::sync::Mutex<GgufModelConfig>, @@ -299,8 +307,11 @@ impl SiGitAgent { ) -> Self { let startup_model_name = initial_model.display_name.clone(); let startup_model_id = initial_model.model_id.clone(); + let backend: Arc<dyn InferenceBackend> = + Arc::new(LocalBackend::new(Arc::clone(&engine))); Self { engine, + backend: tokio::sync::Mutex::new(backend), session_cwd: std::sync::Mutex::new(None), current_model: std::sync::Mutex::new(initial_model), model_ready, @@ -567,6 +578,35 @@ impl SiGitAgent { cx.send_notification(SessionNotification::new(session_id, update)) } + /// Advertise siGit's slash commands to the client. Editors like Zed parse + /// `/`-prefixed input and only forward commands they've been told about, so + /// without this `/login`, `/models`, etc. are rejected client-side. + fn advertise_commands(&self, cx: &ConnectionTo<Client>, session_id: SessionId) { + let with_hint = |name: &str, desc: &str, hint: &str| { + AvailableCommand::new(name, desc).input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new(hint), + )) + }; + let commands = vec![ + AvailableCommand::new("help", "Show available commands"), + AvailableCommand::new("models", "List available models") + .input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new("model number to switch to (optional)"), + )), + with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"), + AvailableCommand::new("logout", "Sign out of siGit Code Cloud"), + AvailableCommand::new("whoami", "Show the signed-in account"), + AvailableCommand::new("clear", "Wipe the conversation history"), + AvailableCommand::new("status", "Show engine status"), + ]; + self.send_tool_call_update( + cx, + session_id, + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)), + ) + .ok(); + } + async fn switch_model_by_id( &self, model_id: &str, @@ -669,15 +709,23 @@ impl SiGitAgent { ) -> agent_client_protocol::Result<InitializeResponse> { log::info!("initialize"); + // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors + // like Zed advertise terminal-auth capability but don't actually spawn the + // login terminal for *custom* ACP agents, so the button is a silent no-op. + // With an Agent method, clicking calls `authenticate`, which returns either + // confirmation (already signed in via `/login`) or a message telling the + // user to run `/login <email> <password>` — so the button does something. + let auth_methods = vec![AuthMethod::Agent( + AuthMethodAgent::new("sigit", "Sign in to siGit Code") + .description("Sign in with `/login <email> <password>` in the message box."), + )]; + Ok(InitializeResponse::new(ProtocolVersion::V1) .agent_info( Implementation::new("sigit", env!("CARGO_PKG_VERSION")) .title("siGit Code - AI Coding Agent"), ) - .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new( - "sigit", - "siGit Code", - ))]) + .auth_methods(auth_methods) .agent_capabilities( AgentCapabilities::default() .load_session(true) @@ -690,14 +738,32 @@ impl SiGitAgent { async fn handle_authenticate( &self, - _req: AuthenticateRequest, + req: AuthenticateRequest, ) -> agent_client_protocol::Result<AuthenticateResponse> { - log::info!("authenticate"); - Ok(AuthenticateResponse::default()) + log::info!("authenticate: method={}", req.method_id.0); + + // Confirm the stored token works. The button can't collect a password, + // so an unsigned-in user is pointed at the `/login` slash command; a user + // already signed in via `/login` gets the gate cleared. + match account::verify_session().await { + Ok(email) => { + log::info!("authenticate: verified session for {email}"); + Ok(AuthenticateResponse::default()) + } + Err(reason) => Err(agent_client_protocol::Error::new( + -32000, + format!( + "Not signed in to siGit Code Cloud ({reason}). \ + Sign in with `/login <email> <password>` in the message box, \ + or create an account at https://sigit.si." + ), + )), + } } async fn handle_load_session( &self, + cx: &ConnectionTo<Client>, args: LoadSessionRequest, ) -> agent_client_protocol::Result<LoadSessionResponse> { log::info!( @@ -739,11 +805,14 @@ impl SiGitAgent { build_model_config_options(&guard) }; + self.advertise_commands(cx, args.session_id.clone()); + Ok(LoadSessionResponse::new().config_options(config_options)) } async fn handle_fork_session( &self, + cx: &ConnectionTo<Client>, args: ForkSessionRequest, ) -> agent_client_protocol::Result<ForkSessionResponse> { let new_id = SessionId::new(uuid::Uuid::new_v4().to_string()); @@ -784,11 +853,14 @@ impl SiGitAgent { build_model_config_options(&guard) }; + self.advertise_commands(cx, new_id.clone()); + Ok(ForkSessionResponse::new(new_id).config_options(config_options)) } async fn handle_new_session( &self, + cx: &ConnectionTo<Client>, args: NewSessionRequest, ) -> agent_client_protocol::Result<NewSessionResponse> { let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); @@ -827,6 +899,8 @@ impl SiGitAgent { build_model_config_options(&guard) }; + self.advertise_commands(cx, session_id.clone()); + Ok(NewSessionResponse::new(session_id).config_options(config_options)) } @@ -984,20 +1058,25 @@ impl SiGitAgent { user_text.chars().take(80).collect::<String>() ); - // load the default ACP model lazily so initialize/session/new stay clean - // for registry validation and editor startup. - self.start_startup_model_load_if_needed(); - self.await_model_ready(cx, &session_id).await?; + // The active backend drives the turn. Snapshot it once so a mid-turn + // model switch doesn't split the conversation across backends. + let backend = self.backend.lock().await.clone(); + + // Only on-device inference needs a local model in memory. Cloud tiers run + // over the network, so skip the lazy load and the readiness wait for them. + if !backend.is_remote() { + self.start_startup_model_load_if_needed(); + self.await_model_ready(cx, &session_id).await?; + } // ── tool-calling loop ──────────────────────────────────────────── // send message → execute any tool calls → feed results back // repeat up to MAX_TOOL_ROUNDS, then force a text reply - let onde_tools = agent_tools_as_onde(); + let tools = agent_tools_as_specs(); - let mut result = self - .engine - .send_message_with_tools(&user_text, &onde_tools) + let mut result = backend + .send_message_with_tools(&user_text, &tools) .await .map_err(|error| { log::error!("send_message_with_tools failed: {error}"); @@ -1020,28 +1099,27 @@ impl SiGitAgent { for tc in &result.tool_calls { log::info!( " → {}({})", - tc.function_name, + tc.name, tc.arguments.chars().take(120).collect::<String>() ); - let output = tools::execute_tool(&tc.function_name, &tc.arguments).await; + let output = tools::execute_tool(&tc.name, &tc.arguments).await; log::info!(" ← {} chars", output.len()); - tool_results.push(ToolResult { + tool_results.push(BackendToolResult { tool_call_id: tc.id.clone(), content: output, }); } let next_tools = if round < MAX_TOOL_ROUNDS { - Some(onde_tools.as_slice()) + Some(tools.as_slice()) } else { None // last round: force text }; - result = self - .engine + result = backend .send_tool_results(tool_results, next_tools) .await .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?; @@ -1085,6 +1163,57 @@ impl SiGitAgent { Ok(()) } + /// Swap the active backend to a siGit Code Cloud tier and reflect it as the + /// current model so the picker shows it selected. Returns the tier's display + /// name on success, or `None` when no account is signed in (caller prompts + /// for login). Shared by the panel picker and the `/models` slash command. + async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> { + let cfg = crate::provider::cloud_tier_provider(tier)?; + let mut system_prompt = system_prompt_for_model(true).to_string(); + // Mirror the cwd guidance the local engine gets at session load, so the + // cloud model also uses absolute paths under the editor's project root. + if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) { + system_prompt.push_str(&format!( + "\n\nThe user's project working directory is {}. \ + Always use absolute paths under this directory for all file \ + and directory operations.", + cwd.display() + )); + } + let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new( + cfg.base_url, + cfg.api_key, + cfg.model, + Some(system_prompt), + )); + *self.backend.lock().await = cloud_backend; + + let cloud_config = GgufModelConfig { + model_id: format!("sigit-cloud:{tier}"), + files: Vec::new(), + tok_model_id: None, + display_name: cfg.display_name.clone(), + approx_memory: "Cloud".to_string(), + chat_template: None, + }; + { + let mut guard = self.current_model.lock().unwrap(); + *guard = cloud_config; + } + + log::info!("switched to cloud tier {tier}"); + Some(cfg.display_name) + } + + /// Route inference back on-device. Used after leaving a cloud tier for a + /// local model. The `LocalBackend` reads the live `engine`, so this just + /// repoints the active backend. + async fn reset_to_local_backend(&self) { + let local_backend: Arc<dyn InferenceBackend> = + Arc::new(LocalBackend::new(Arc::clone(&self.engine))); + *self.backend.lock().await = local_backend; + } + async fn handle_set_session_config_option( &self, cx: &ConnectionTo<Client>, @@ -1129,6 +1258,20 @@ impl SiGitAgent { } } + // ── siGit Code Cloud tier: no local load; sign-in gated ───────────── + if let Some(tier) = model_id.strip_prefix("sigit-cloud:") { + let message = match self.switch_to_cloud_tier(tier).await { + Some(display_name) => format!("Switched to {display_name}."), + None => CLOUD_LOGIN_PROMPT.to_string(), + }; + self.send_assistant_message(cx, args.session_id.clone(), message) + .ok(); + + let current = self.current_model.lock().unwrap().clone(); + let config_options = build_model_config_options(&current); + return Ok(SetSessionConfigOptionResponse::new(config_options)); + } + let needs_download = models::local_picker_items() .into_iter() .find(|item| item.config.model_id == model_id) @@ -1331,6 +1474,9 @@ impl SiGitAgent { match switch_result { Ok(new_config) => { + // Route inference back on-device (in case we were on a cloud tier). + self.reset_to_local_backend().await; + let completion_title = if needs_download { format!("✓ {} downloaded and loaded", new_config.display_name) } else { @@ -1389,7 +1535,9 @@ impl SiGitAgent { const MODEL_CONFIG_ID: &str = "sigit-model"; fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> { - let items = models::local_picker_items(); + // The full list, including the siGit Code Cloud tiers, so the panel picker + // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection. + let items = models::build_model_picker_items(); let options: Vec<SessionConfigSelectOption> = items .iter() @@ -1404,7 +1552,9 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon desc_parts.push("↓ download on select".to_string()); } let description = desc_parts.join(" - "); - let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded { + let source_badge = if item.cloud_tier.is_some() { + " [☁ siGit Code Cloud]" + } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded { " [↓ Onde]" } else { match item.source_label.as_str() { @@ -1431,7 +1581,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon vec![ SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options) .category(SessionConfigOptionCategory::Model) - .description("Select the local LLM model for inference"), + .description("Select an on-device model or a siGit Code Cloud tier"), ] } @@ -1485,7 +1635,7 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { } fn format_models_list(current_model: &GgufModelConfig) -> String { - let items = models::local_picker_items(); + let items = models::build_model_picker_items(); if items.is_empty() { return "No local models found. siGit will use the platform default model.".to_string(); } @@ -1497,6 +1647,7 @@ fn format_models_list(current_model: &GgufModelConfig) -> String { let source_key = match item.source_label.as_str() { "Onde" => "Onde", "HuggingFace" => "HuggingFace", + "siGit Code Cloud" => "Cloud", _ => "Fallback", }; @@ -1507,9 +1658,15 @@ fn format_models_list(current_model: &GgufModelConfig) -> String { let section = match source_key { "Onde" => "Onde Inference", "HuggingFace" => "Hugging Face cache", + "Cloud" => "siGit Code Cloud", _ => "Fallback", }; lines.push(section.to_string()); + // Blank line so the following "N." items render as an ordered list. + // CommonMark only lets an ordered list interrupt a paragraph when it + // starts at 1, so without this the cloud section (items 9+) would be + // absorbed into the header paragraph. + lines.push(String::new()); last_source = Some(source_key); } @@ -1532,6 +1689,7 @@ fn format_models_list(current_model: &GgufModelConfig) -> String { let source = match source_key { "Onde" => " [Onde]", "HuggingFace" => " [HuggingFace]", + "Cloud" => " [☁ Cloud]", _ => " [default]", }; @@ -1602,7 +1760,7 @@ async fn exec_slash_acp( .ok(); } SlashCommand::Models(Some(number)) => { - let items = models::local_picker_items(); + let items = models::build_model_picker_items(); let index = number.saturating_sub(1); match items.get(index).cloned() { None => { @@ -1614,6 +1772,15 @@ async fn exec_slash_acp( ) .ok(); } + Some(model) if model.cloud_tier.is_some() => { + // siGit Code Cloud tier: swap backend, sign-in gated. + let tier = model.cloud_tier.clone().unwrap_or_default(); + let message = match agent.switch_to_cloud_tier(&tier).await { + Some(display_name) => format!("Switched to {display_name}."), + None => CLOUD_LOGIN_PROMPT.to_string(), + }; + agent.send_assistant_message(cx, session_id, message).ok(); + } Some(model) => { if model.cache_health == setup::ModelCacheHealth::Incomplete { agent @@ -1640,6 +1807,7 @@ async fn exec_slash_acp( match agent.switch_model_by_id(&model.config.model_id).await { Ok(new_config) => { + agent.reset_to_local_backend().await; agent.engine.clear_history().await; agent .send_assistant_message( @@ -1672,6 +1840,7 @@ async fn exec_slash_acp( .ok(); let switched = agent.switch_model_by_id(&model.config.model_id).await?; + agent.reset_to_local_backend().await; agent.engine.clear_history().await; agent @@ -2040,8 +2209,8 @@ async fn run_acp_server() -> anyhow::Result<()> { .on_receive_request( { let state = Arc::clone(&state); - async move |req: LoadSessionRequest, responder, _cx: ConnectionTo<Client>| { - handle_response(responder, state.handle_load_session(req).await) + async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_load_session(&cx, req).await) } }, agent_client_protocol::on_receive_request!(), @@ -2049,8 +2218,8 @@ async fn run_acp_server() -> anyhow::Result<()> { .on_receive_request( { let state = Arc::clone(&state); - async move |req: ForkSessionRequest, responder, _cx: ConnectionTo<Client>| { - handle_response(responder, state.handle_fork_session(req).await) + async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_fork_session(&cx, req).await) } }, agent_client_protocol::on_receive_request!(), @@ -2058,8 +2227,8 @@ async fn run_acp_server() -> anyhow::Result<()> { .on_receive_request( { let state = Arc::clone(&state); - async move |req: NewSessionRequest, responder, _cx: ConnectionTo<Client>| { - handle_response(responder, state.handle_new_session(req).await) + async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| { + handle_response(responder, state.handle_new_session(&cx, req).await) } }, agent_client_protocol::on_receive_request!(), @@ -2108,6 +2277,38 @@ async fn run_acp_server() -> anyhow::Result<()> { #[tokio::main] async fn main() -> anyhow::Result<()> { + // Account subcommands. The editor launches `sigit login` in an embedded + // terminal for ACP terminal-based authentication; the same verbs are handy + // directly from a shell. These must be handled before the TTY/ACP split. + if let Some(verb) = std::env::args().nth(1) { + match verb.as_str() { + "login" => { + init_logging(true); + match account::interactive_login().await { + Ok(email) => { + println!("Signed in to siGit Code Cloud as {email}."); + return Ok(()); + } + Err(error) => { + eprintln!("Login failed: {error}"); + std::process::exit(1); + } + } + } + "logout" => { + init_logging(true); + println!("{}", account::end_session().await); + return Ok(()); + } + "whoami" => { + init_logging(true); + println!("{}", account::status_line().await); + return Ok(()); + } + _ => {} + } + } + let is_tty = std::io::stdin().is_terminal(); if is_tty {