From 04c82c59cb18d990a2b6f5c50906bf5378abce37 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Wed, 28 Oct 2026 10:14:32 +0100 Subject: [PATCH] Add account commands and si models si auth signup creates an account with a confirmed password prompt, si auth resend sends the confirmation email again (the hint in the unverified-email message already pointed at it), si auth reset-password emails reset instructions, and si auth delete-account asks you to type your email before deleting, or takes --yes. si models lists the models a session can use. The client crate gets reset_password and models. Co-Authored-By: Claude Opus 5.5 --- crates/cli/src/cli.rs | 24 +++++++++++ crates/cli/src/commands/auth.rs | 67 +++++++++++++++++++++++++++++ crates/cli/src/commands/mod.rs | 1 + crates/cli/src/commands/models.rs | 10 +++++ crates/cli/src/main.rs | 1 + crates/cli/src/ui/prompt.rs | 9 ++++ crates/cli/src/ui/render.rs | 29 ++++++++++++- crates/sigit-si-api/src/account.rs | 38 ++++++++++++++++ crates/sigit-si-api/src/models.rs | 15 +++++++ crates/sigit-si-api/src/sessions.rs | 23 +++++++++- 10 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 crates/cli/src/commands/models.rs diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index b7cafd6..13e608e 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -66,6 +66,9 @@ pub enum Command { #[command(subcommand)] Billing(BillingCommand), + /// List the models a session can use. + Models, + /// Open something on sigit.si in your browser. Browse(BrowseArgs), @@ -88,6 +91,27 @@ pub enum AuthCommand { Logout, /// Show who is currently signed in. Status, + /// Create a sigit.si account. A confirmation email follows. + Signup { + #[arg(long)] + email: Option, + }, + /// Send the confirmation email again. + Resend { + #[arg(long)] + email: Option, + }, + /// Email instructions for choosing a new password. + ResetPassword { + #[arg(long)] + email: Option, + }, + /// Permanently delete your account. + DeleteAccount { + /// Skip the typed confirmation. + #[arg(short, long)] + yes: bool, + }, /// Let git use your sigit.si sign-in for clones and fetches, by adding a /// credential helper to your global git config. SetupGit, diff --git a/crates/cli/src/commands/auth.rs b/crates/cli/src/commands/auth.rs index 161f6c4..6589adb 100644 --- a/crates/cli/src/commands/auth.rs +++ b/crates/cli/src/commands/auth.rs @@ -15,6 +15,10 @@ pub async fn run(ctx: &Ctx, command: AuthCommand) -> Result<()> { AuthCommand::Login { email } => login(ctx, email).await, AuthCommand::Logout => logout(ctx).await, AuthCommand::Status => status(ctx).await, + AuthCommand::Signup { email } => signup(ctx, email).await, + AuthCommand::Resend { email } => resend(ctx, email).await, + AuthCommand::ResetPassword { email } => reset_password(ctx, email).await, + AuthCommand::DeleteAccount { yes } => delete_account(ctx, yes).await, AuthCommand::SetupGit => setup_git(ctx), AuthCommand::GitCredential { operation } => git_credential(ctx, operation).await, } @@ -51,6 +55,69 @@ async fn git_credential(ctx: &Ctx, operation: GitCredentialOperation) -> Result< Ok(()) } +fn email_or_prompt(email: Option) -> Result { + match email { + Some(email) => Ok(email), + None => ui::prompt::text("Email"), + } +} + +async fn signup(ctx: &Ctx, email: Option) -> Result<()> { + let email = email_or_prompt(email)?; + let password = ui::prompt::new_password("Password")?; + + let result = ctx.client.sign_up(&email, &password).await?; + ui::emit(ctx.json, &result, |result| { + let message = result["message"] + .as_str() + .unwrap_or("Signed up. Check your email to confirm your account."); + ui::success(message); + ui::note("Once it is confirmed, run `si auth login`."); + }) +} + +async fn resend(ctx: &Ctx, email: Option) -> Result<()> { + let email = email_or_prompt(email)?; + ctx.client.resend_confirmation(&email).await?; + ui::success(format!( + "If {} has an account that is not confirmed yet, a new email is on its way.", + ui::highlight(&email) + )); + Ok(()) +} + +async fn reset_password(ctx: &Ctx, email: Option) -> Result<()> { + let email = email_or_prompt(email)?; + ctx.client.reset_password(&email).await?; + ui::success(format!( + "If {} has an account, reset instructions are on their way.", + ui::highlight(&email) + )); + Ok(()) +} + +async fn delete_account(ctx: &Ctx, yes: bool) -> Result<()> { + ctx.require_token()?; + + if !yes { + let user = ctx.client.user().await?; + ui::note(format!( + "This permanently deletes the account {} and everything in it.", + user.email + )); + let typed = ui::prompt::text(&format!("Type {} to confirm", user.email))?; + if !typed.trim().eq_ignore_ascii_case(&user.email) { + ui::note("Cancelled."); + return Ok(()); + } + } + + ctx.client.delete_account().await?; + config::clear_token(ctx.environment)?; + ui::success("Your account was deleted."); + Ok(()) +} + async fn login(ctx: &Ctx, email: Option) -> Result<()> { let email = match email { Some(email) => email, diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 4fd5019..1b4382e 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod code; pub mod hook; pub mod issue; pub mod me; +pub mod models; pub mod pr; pub mod repo; pub mod run; diff --git a/crates/cli/src/commands/models.rs b/crates/cli/src/commands/models.rs new file mode 100644 index 0000000..8bbc5ef --- /dev/null +++ b/crates/cli/src/commands/models.rs @@ -0,0 +1,10 @@ +use { + crate::{context::Ctx, ui}, + anyhow::Result, +}; + +/// The model list is public, so this works without signing in. +pub async fn run(ctx: &Ctx) -> Result<()> { + let models = ctx.client.models().await?; + ui::emit(ctx.json, &models, |models| ui::render::models(models)) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d79551b..7b7fd6a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -52,6 +52,7 @@ async fn run() -> Result<()> { Command::Hook(command) => commands::hook::run(&ctx, command).await, Command::Session(command) => commands::session::run(&ctx, command).await, Command::Billing(command) => commands::billing::run(&ctx, command).await, + Command::Models => commands::models::run(&ctx).await, Command::Browse(args) => commands::browse::run(&ctx, args).await, Command::Api(args) => commands::api::run(&ctx, args).await, // Takes `ctx` by value: the server outlives the dispatch, serving diff --git a/crates/cli/src/ui/prompt.rs b/crates/cli/src/ui/prompt.rs index 6318577..549fd4f 100644 --- a/crates/cli/src/ui/prompt.rs +++ b/crates/cli/src/ui/prompt.rs @@ -32,6 +32,15 @@ pub fn password(label: &str) -> Result { .interact()?) } +/// A new password, asked for twice. +pub fn new_password(label: &str) -> Result { + require_tty(label)?; + Ok(Password::with_theme(&ColorfulTheme::default()) + .with_prompt(label) + .with_confirmation("Repeat the password", "The passwords do not match.") + .interact()?) +} + /// A yes/no confirmation. Non-interactive callers should not reach this at /// all for destructive actions — check `--yes` first — but reads default to /// `default` here rather than erroring, since a TTY-less "confirm the details diff --git a/crates/cli/src/ui/render.rs b/crates/cli/src/ui/render.rs index 8c4db94..7cc133b 100644 --- a/crates/cli/src/ui/render.rs +++ b/crates/cli/src/ui/render.rs @@ -6,7 +6,8 @@ use { console::style, sigit_si_api::models::{ AgentRun, Billing, CloudSession, CodeHit, CombinedStatus, Comment, Hook, Issue, - IssueSummary, PullRequest, PullRequestSummary, Repository, RepositorySummary, Site, User, + IssueSummary, Model, PullRequest, PullRequestSummary, Repository, RepositorySummary, Site, + User, }, tabled::{builder::Builder, settings::Style}, }; @@ -493,6 +494,32 @@ pub fn site(site: &Site) { println!(); } +// ── Models ────────────────────────────────────────────────────────────────── + +pub fn models(models: &[Model]) { + let rows = models + .iter() + .map(|model| { + vec![ + model.id.clone(), + model + .display_name + .clone() + .unwrap_or_else(|| "—".to_string()), + model.capability.clone().unwrap_or_else(|| "—".to_string()), + if model.vision { "yes" } else { "no" }.to_string(), + if model.default { "default" } else { "" }.to_string(), + ] + }) + .collect(); + + table( + &["Model", "Name", "Capability", "Vision", ""], + rows, + "No models are available right now.", + ); +} + // ── Billing ───────────────────────────────────────────────────────────────── pub fn billing(billing: &Billing) { diff --git a/crates/sigit-si-api/src/account.rs b/crates/sigit-si-api/src/account.rs index 7a7795e..ad0459f 100644 --- a/crates/sigit-si-api/src/account.rs +++ b/crates/sigit-si-api/src/account.rs @@ -48,6 +48,18 @@ impl Client { .await } + /// `POST /api/v1/auth/password/reset` — emails reset instructions. The + /// server answers the same way whether or not the address has an account. + pub async fn reset_password(&self, email: &str) -> Result<()> { + self.send_empty(self.reset_password_request(email)).await + } + + fn reset_password_request(&self, email: &str) -> reqwest::RequestBuilder { + let body = serde_json::json!({ "email": email.trim().to_ascii_lowercase() }); + self.request(Method::POST, "auth/password/reset") + .json(&body) + } + /// `DELETE /api/v1/me` — permanently deletes the signed-in account. pub async fn delete_account(&self) -> Result<()> { self.delete("me").await @@ -69,3 +81,29 @@ pub fn incomplete_reason(status: u32) -> &'static str { _ => "Your account is not ready to sign in yet. Finish setting it up at https://sigit.si/auth.", } } + +#[cfg(test)] +mod tests { + use {super::*, crate::Environment}; + + #[test] + fn a_reset_posts_the_normalized_email() { + let client = Client::builder(Environment::Production) + .base_url("https://sigit.si") + .build() + .unwrap(); + let request = client + .reset_password_request(" Seto@Example.com ") + .build() + .unwrap(); + + assert_eq!(request.method(), Method::POST); + assert_eq!( + request.url().as_str(), + "https://sigit.si/api/v1/auth/password/reset" + ); + let body = request.body().and_then(|b| b.as_bytes()).unwrap(); + let body: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(body["email"], "seto@example.com"); + } +} diff --git a/crates/sigit-si-api/src/models.rs b/crates/sigit-si-api/src/models.rs index ab70f06..e89d98b 100644 --- a/crates/sigit-si-api/src/models.rs +++ b/crates/sigit-si-api/src/models.rs @@ -210,6 +210,21 @@ pub struct Site { pub bytes: i64, } +// ── Models ────────────────────────────────────────────────────────────────── + +/// One entry of `GET /api/v1/models`, the models you can pick for a session. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Model { + pub id: String, + pub display_name: Option, + pub capability: Option, + #[serde(default)] + pub vision: bool, + /// Set on the model the calling client uses when none is named. + #[serde(default)] + pub default: bool, +} + // ── MCP-backed resources ──────────────────────────────────────────────────── // // Issues, pull requests, and code search have no REST surface; they are served diff --git a/crates/sigit-si-api/src/sessions.rs b/crates/sigit-si-api/src/sessions.rs index 871f3de..abeb84c 100644 --- a/crates/sigit-si-api/src/sessions.rs +++ b/crates/sigit-si-api/src/sessions.rs @@ -3,7 +3,7 @@ use crate::{ client::Client, error::Result, - models::{AppendedMessage, CloudSession}, + models::{AppendedMessage, CloudSession, Model}, }; impl Client { @@ -37,6 +37,17 @@ impl Client { .await } + /// `GET /api/v1/models` — the models a session can use. The server lists + /// them in the OpenAI shape, `{"data": [...]}`; this unwraps it. + pub async fn models(&self) -> Result> { + #[derive(serde::Deserialize)] + struct Models { + data: Vec, + } + let models: Models = self.get("models").await?; + Ok(models.data) + } + /// `DELETE /api/v1/sessions/:id` pub async fn delete_session(&self, id: i64) -> Result<()> { self.delete(&format!("sessions/{id}")).await @@ -71,6 +82,16 @@ fn session_body(title: Option<&str>, model: Option<&str>) -> serde_json::Value { mod tests { use super::*; + #[test] + fn a_model_without_the_default_flag_is_not_the_default() { + let model: Model = serde_json::from_str( + r#"{"id":"m","object":"model","owned_by":"sigit","display_name":"M", + "capability":"fast","vision":false}"#, + ) + .unwrap(); + assert!(!model.default); + } + #[test] fn omitted_fields_stay_out_of_the_update_body() { let body = session_body(Some("Refactor auth"), None);