Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand All @@ -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<String>,
},
/// Send the confirmation email again.
Resend {
#[arg(long)]
email: Option<String>,
},
/// Email instructions for choosing a new password.
ResetPassword {
#[arg(long)]
email: Option<String>,
},
/// 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,
Expand Down
67 changes: 67 additions & 0 deletions crates/cli/src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -51,6 +55,69 @@ async fn git_credential(ctx: &Ctx, operation: GitCredentialOperation) -> Result<
Ok(())
}

fn email_or_prompt(email: Option<String>) -> Result<String> {
match email {
Some(email) => Ok(email),
None => ui::prompt::text("Email"),
}
}

async fn signup(ctx: &Ctx, email: Option<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> Result<()> {
let email = match email {
Some(email) => email,
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions crates/cli/src/commands/models.rs
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions crates/cli/src/ui/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ pub fn password(label: &str) -> Result<String> {
.interact()?)
}

/// A new password, asked for twice.
pub fn new_password(label: &str) -> Result<String> {
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
Expand Down
29 changes: 28 additions & 1 deletion crates/cli/src/ui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions crates/sigit-si-api/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(" [email protected] ")
.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"], "[email protected]");
}
}
15 changes: 15 additions & 0 deletions crates/sigit-si-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub capability: Option<String>,
#[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
Expand Down
23 changes: 22 additions & 1 deletion crates/sigit-si-api/src/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::{
client::Client,
error::Result,
models::{AppendedMessage, CloudSession},
models::{AppendedMessage, CloudSession, Model},
};

impl Client {
Expand Down Expand Up @@ -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<Vec<Model>> {
#[derive(serde::Deserialize)]
struct Models {
data: Vec<Model>,
}
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
Expand Down Expand Up @@ -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);
Expand Down
Loading