Add provider login management

This commit is contained in:
2026-06-25 12:45:29 -05:00
parent 2845529682
commit cb43470a6d
14 changed files with 990 additions and 14 deletions
Generated
+1 -1
View File
@@ -226,7 +226,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "cassady"
version = "0.2.8"
version = "0.2.9"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cassady"
version = "0.2.8"
version = "0.2.9"
edition = "2021"
description = "Cassady/Cass minimal terminal coding agent"
license = "MIT"
+6 -1
View File
@@ -34,9 +34,10 @@ Start Cassady in a project directory:
cass
```
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup explicitly:
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup or provider login explicitly:
```sh
cass login
cass setup
cass check
cass update --check
@@ -66,6 +67,8 @@ cass [--model MODEL] [--cwd PATH]
cass --resume <chat-id>
cass --resume
cass check
cass login
cass logout
cass setup
cass update
```
@@ -75,6 +78,8 @@ cass update
Common in-chat commands:
- `/branch` or `/restore`: open the branch/restore menu.
- `/login`: configure or update provider login settings.
- `/logout`: remove saved provider config and associated model entries.
- `/model <model>`: switch to a model from `~/.cass/models.json`.
- `/new`: create a new chat for the current directory.
- `/resume <chat>`: resume a saved chat for the current directory.
+32
View File
@@ -1,5 +1,37 @@
# Cassady (Cass) Roadmap
## v0.2.9 — Provider Login Management
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users can add or update OpenAI-compatible provider/model settings with `cass login` or `/login`, and remove saved providers and their associated models with `cass logout` or `/logout`. See `plans/V0_2_9_PROVIDER_LOGIN_MANAGEMENT_PLAN.md`.
### Login Commands
- [x] **Add login commands for provider setup.** Make `cass login` and `/login` open the provider setup flow so users can configure providers without remembering that setup is the underlying implementation.
- Reuse the existing provider catalog, model discovery, model capability prompts, and safe JSON writes.
- Keep `/login` idle-only and reload active provider/model config after the menu closes.
- [x] **Keep existing setup behavior intact.** Preserve `cass setup` and first-run setup while making login language feel natural for account/provider management.
- Direct `cass login` should save configuration and exit rather than unexpectedly starting a chat.
- Existing setup validation and missing API key guidance should continue to apply.
### Logout Commands
- [x] **Add a safe provider removal menu.** Make `cass logout` and `/logout` let users choose saved providers to remove from `providers.json`.
- Remove associated `models.json` entries for selected providers.
- Confirm the removal before writing changes.
- [x] **Repair active defaults after removal.** Ensure `config.json` never points at a provider/model that was just removed.
- Select a valid remaining provider/model when possible.
- Clear active provider/model defaults when no providers remain so the next startup offers login/setup.
### Documentation and Validation
- [x] **Document provider login and logout workflows.** Update command, configuration, and workflow docs with the new shell and in-chat commands.
- Clarify that logout removes Cassady provider config, not environment variables or external provider accounts.
- [x] **Test provider management behavior.** Cover provider/model removal, active default repair, local command parsing, and autocomplete.
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
## v0.2.8 — Conversation Branch and Restore
This release focuses on making conversation recovery safe and explorable. Pressing `Esc` twice while idle opens a branch/restore menu where users can browse prior user messages, assistant messages, and tool calls, create a new branch from a selected checkpoint, and optionally restore Cassady-tracked file edits without destroying the original conversation. See `plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md`.
+17 -1
View File
@@ -8,12 +8,14 @@ Cassady installs two equivalent binaries: `cass` and `cassady`. This page uses `
cass [OPTIONS]
cassady [OPTIONS]
cass check [OPTIONS]
cass login [OPTIONS]
cass logout [OPTIONS]
cass setup [OPTIONS]
cass update [OPTIONS]
cass --resume [CHAT_ID]
```
Run `cass --help`, `cass check --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
Run `cass --help`, `cass check --help`, `cass login --help`, `cass logout --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
## Startup behavior
@@ -58,6 +60,18 @@ Validates Cassady configuration under `~/.cass`:
Missing API keys for inactive providers are warnings. A missing active API key is an error and `cass check` exits with a non-zero status.
### `cass login`
Runs the provider login/configuration wizard. This is the same provider setup flow used by `cass setup`, framed for adding or updating saved OpenAI-compatible provider access. It can configure multiple providers, discover or manually enter models, update active defaults, and validate the saved files.
`cass login` edits Cassady files under `~/.cass`; it does not sign in through a browser or create provider accounts.
### `cass logout`
Opens an interactive menu for removing saved providers from Cassady config. Removing a provider also removes its associated `models.json` entries. If the active provider is removed, Cassady chooses a remaining provider/model when possible. If no providers remain, active defaults are cleared and the next chat startup will offer setup/login again.
`cass logout` does not delete environment variables, API keys stored elsewhere, or external provider accounts.
### `cass setup`
Runs the interactive setup wizard in a terminal. It configures OpenAI-compatible providers, API key environment-variable references, and first models. It updates `config.json`, `providers.json`, and `models.json` while preserving unrelated entries where possible.
@@ -93,6 +107,8 @@ The updater does not invoke `sudo` or administrator prompts. If the install dire
Type `/` to open command autocomplete.
- `/branch` or `/restore`: open the branch/restore menu for the current conversation family.
- `/login`: configure or update provider login settings, then reload active provider/model config.
- `/logout`: remove saved providers and their associated models, then reload active provider/model config when any remain.
- `/model <model>`: switch the model for future turns. Autocomplete lists models from `~/.cass/models.json`.
- `/new`: create a new chat for the current directory.
- `/resume <chat>`: resume a saved chat from the current directory. Autocomplete lists matching chats.
+6 -1
View File
@@ -21,6 +21,10 @@ cass setup
Cassady also offers setup automatically when `cass` cannot resolve a usable active provider, model, or API key before starting a chat.
For everyday provider management, `cass login` opens the same provider configuration flow with login-oriented wording. Inside an idle chat, `/login` temporarily opens that flow and reloads the active provider/model after it closes.
To remove saved provider configuration, run `cass logout` or type `/logout` while idle. Logout removes selected providers from `providers.json` and removes their associated entries from `models.json`. It does not remove environment variables, local shell profile exports, or external provider accounts.
The wizard uses keyboard prompts: `↑`/`↓` moves through choices, `Space` selects providers in the multi-select screen, and `Enter` submits. Text fields use the same prompt style instead of falling back to plain line input.
The wizard supports configuring multiple OpenAI-compatible providers at once. If more than one provider is configured, setup asks which one should be active first. If the selected API key environment variable is set, Cassady tries to fetch models from `GET {base_url}/models` and lets you choose one. If discovery fails, it offers a retry before falling back to manual model entry. If the API key is not set, setup asks for a model id manually.
@@ -169,6 +173,7 @@ cass
1. Edit one file at a time.
2. Keep provider ids and model provider references in sync.
3. Prefer API key env references over literal keys.
4. Run `cass check` before starting a chat.
4. Prefer `cass login` and `cass logout` for routine provider changes.
5. Run `cass check` before starting a chat.
Invalid JSON, unknown fields, duplicate ids, and missing provider/model links are reported by `cass check` with the file that failed.
+6
View File
@@ -21,6 +21,12 @@ The setup wizard offers these provider templates:
There is also a custom OpenAI-compatible option. Custom setup asks for provider name, provider id, base URL, API key environment variable, and first model id.
## Login and logout
Use `cass login` to add or update provider configuration from the shell. Inside an idle chat, `/login` opens the same flow and reloads the active provider/model afterward.
Use `cass logout` or `/logout` to remove saved provider entries from Cassady config. Logout also removes model metadata entries associated with the removed providers and repairs active defaults when other providers remain. It does not delete environment variables, shell profile exports, API keys stored elsewhere, or external provider accounts.
## Model discovery
When the selected API key environment variable is available, setup tries:
+28
View File
@@ -57,6 +57,34 @@ Run the smallest relevant Rust test for this change, then summarize the result.
When the approval prompt appears, press `y` to approve or `n`/`Esc` to deny. Shell commands run with `sh -c` from the launch cwd and default to a 30-second timeout unless the model requests another timeout.
## Manage provider login
Add or update provider configuration from the shell:
```sh
cass login
```
Inside an idle chat:
```text
/login
```
Remove saved provider configuration:
```sh
cass logout
```
Inside an idle chat:
```text
/logout
```
Logout removes selected providers from Cassady's config and removes their associated model entries. It does not delete environment variables or external provider accounts.
## Switch model
Inside a chat:
@@ -0,0 +1,135 @@
# v0.2.9 Provider Login Management Implementation Plan
## Goal
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users should be able to run `cass login` or type `/login` to configure one or more OpenAI-compatible providers, and use `cass logout` or `/logout` to remove saved providers and their model entries without hand-editing JSON files.
Success statement:
> A user can add, switch, and remove provider/model configuration from Cassady's normal command surfaces, then continue chatting with a valid active provider.
## Scope
### In scope
- Add `cass login` as an alias-style command for the existing setup wizard.
- Add `/login` inside the TUI, available only while idle.
- Add `cass logout` with an interactive provider removal menu.
- Add `/logout` inside the TUI, available only while idle.
- Remove provider definitions and their associated `models.json` entries together.
- Update `config.json` active defaults after removal so they do not point at missing providers or models.
- Reload active config after login/logout inside the TUI.
- Document the new commands in bundled command/config docs.
- Add focused unit tests for provider removal and local command parsing/autocomplete.
### Out of scope
- Browser OAuth or provider-hosted account login flows.
- Storing literal API keys from the wizard by default.
- Non-OpenAI-compatible provider protocols.
- Deleting shell environment variables or secrets outside `~/.cass`.
- Publishing, tagging, or preparing release artifacts.
## Context
Cassady already has most provider setup primitives:
- `src/setup.rs` contains the interactive provider/model setup wizard, provider catalog, model discovery, and JSON upsert helpers.
- `src/config.rs` owns `config.json`, `providers.json`, `models.json`, active provider/model resolution, and validation.
- `src/app.rs` owns top-level CLI dispatch plus in-chat slash command parsing and execution.
- `docs/commands.md`, `docs/configuration.md`, and `docs/workflows.md` document the existing `cass setup`, `cass check`, and `/model` behavior.
The existing setup wizard writes provider connection definitions to `providers.json`, model metadata to `models.json`, and active defaults to `config.json`. The new login command can reuse that flow. Logout needs a new inverse operation that edits all three files consistently.
## Design Principles
1. Reuse setup behavior instead of creating a second provider configuration path.
2. Keep removal explicit and reversible by avoiding broad file deletion and by preserving unrelated providers/models.
3. Never remove API keys from the user's shell or keychain; Cassady only edits its own config files.
4. Keep in-chat provider management idle-only, because active turns depend on a stable provider config.
## Design
### CLI commands
Add these subcommands:
```text
cass login
cass logout
```
`cass login` runs the same interactive wizard as `cass setup`, with text that frames the action as adding or updating provider login configuration. It may start a chat afterward when invoked from an otherwise normal chat startup path only if the existing setup outcome says it should; direct `cass login` should save config and exit.
`cass logout` opens a multi-select menu of saved providers:
```text
Remove saved providers
[ ] OpenAI openai · gpt-4.1
[ ] Groq groq · llama-3.3-70b-versatile
```
After confirmation, Cassady removes the selected providers from `providers.json` and removes `models.json` entries whose `provider` matches a removed provider id. If the active provider was removed, Cassady selects the first remaining provider and one of its models. If no providers remain, `default_provider`, `default_model`, and `default_reasoning_effort` are cleared so the next `cass` run offers setup.
### In-chat commands
Add slash commands:
```text
/login
/logout
```
Both commands are idle-only. Because the TUI uses the alternate screen and raw input mode, command execution should temporarily leave the TUI, run the existing menu-driven flow in the normal terminal, reload config, then re-enter the TUI and append a status block.
After `/login`, reload `Config` from disk and keep the current conversation open. If the active provider/model changed, future turns use the new provider and model. The status block should show the active provider and model.
After `/logout`, reload `Config` when a provider remains. If no provider remains or config cannot resolve, keep the chat open but append a clear status/error telling the user to run `/login` before sending another turn.
### Provider/model removal helper
Add reusable setup/config helpers:
- `configured_providers(root) -> Vec<ProviderLogoutCandidate>`
- `remove_providers(root, provider_ids) -> LogoutResult`
`LogoutResult` should include removed provider ids, removed model count, remaining provider count, and the new active provider/model when one exists. This keeps CLI output, TUI status, and tests deterministic.
## Implementation Steps
1. Add the v0.2.9 roadmap entry and this plan.
2. Add `Login` and `Logout` CLI variants and dispatch them from `app::run`.
3. Refactor setup mode text as needed so `cass login` can share the setup wizard.
4. Implement provider removal helpers in `src/setup.rs` using existing config structs.
5. Add menu-driven `setup::logout(root)` for CLI and TUI use.
6. Add `/login` and `/logout` to local command parsing, autocomplete, and idle command handling.
7. Add a small terminal leave/re-enter helper around blocking login/logout menus inside the TUI.
8. Update command/config/workflow docs.
9. Add focused tests for removal behavior and command parsing/autocomplete.
## Tests
- Removing one provider preserves unrelated providers and models.
- Removing the active provider chooses a valid remaining provider/model.
- Removing all providers clears active defaults.
- Removing an unknown provider id is rejected.
- `/login` and `/logout` parse only with no arguments.
- Command autocomplete lists `/login` and `/logout`.
- `cargo fmt` passes.
- `cargo test --locked --all-targets` passes when practical.
## Documentation
- Update `docs/commands.md` with `cass login`, `cass logout`, `/login`, and `/logout`.
- Update `docs/configuration.md` to point users toward login/logout for managed provider edits.
- Update `docs/workflows.md` with login/logout examples near model/provider workflows.
## Acceptance Criteria
- `cass login` opens the provider setup wizard and exits after saving direct login changes.
- `cass logout` removes selected providers and their models with confirmation.
- `/login` and `/logout` work from an idle chat and reload active provider config afterward.
- Removing the active provider never leaves `config.json` pointing at a missing provider/model.
- Existing `cass setup`, first-run setup, `cass check`, and `/model` behavior still work.
- `cargo fmt` and `cargo test --locked --all-targets` pass.
+267 -2
View File
@@ -1,5 +1,5 @@
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
use crate::cli::{self, Command};
use crate::cli::{self, Cli, Command};
use crate::config::{self, Config, ModelDefinition, ReasoningEffort};
use crate::conversation::{self, Conversation, Record};
use crate::prompt;
@@ -34,6 +34,16 @@ pub async fn run() -> Result<()> {
return Ok(());
}
if matches!(cli.command, Some(Command::Login)) {
let _ = crate::setup::run(&cli, crate::setup::SetupMode::Login).await?;
return Ok(());
}
if matches!(cli.command, Some(Command::Logout)) {
let _ = crate::setup::logout(&config::cass_root())?;
return Ok(());
}
if matches!(cli.command, Some(Command::Setup)) {
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Explicit).await?;
if !outcome.start_session {
@@ -84,7 +94,7 @@ pub async fn run() -> Result<()> {
(create_new_conversation(&config, &cwd)?, None)
};
run_tui(config, cwd, conversation, warning).await
run_tui(config, cwd, conversation, warning, cli).await
}
fn resolve_cwd(cwd: Option<PathBuf>) -> Result<PathBuf> {
@@ -117,6 +127,40 @@ fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> {
Ok(())
}
async fn run_login_menu_from_tui(
terminal: &mut terminal::CassTerminal,
cli: &Cli,
) -> Result<crate::setup::SetupOutcome> {
terminal::suspend(terminal)?;
let result = crate::setup::run(cli, crate::setup::SetupMode::Login).await;
let resume_result = terminal::resume(terminal);
match (result, resume_result) {
(Ok(outcome), Ok(())) => Ok(outcome),
(Err(err), Ok(())) => Err(err),
(Ok(_), Err(err)) => Err(err),
(Err(err), Err(resume_err)) => Err(err.context(format!(
"also failed to restore the chat screen: {resume_err}"
))),
}
}
fn run_logout_menu_from_tui(
terminal: &mut terminal::CassTerminal,
root: &Path,
) -> Result<crate::setup::LogoutResult> {
terminal::suspend(terminal)?;
let result = crate::setup::logout(root);
let resume_result = terminal::resume(terminal);
match (result, resume_result) {
(Ok(outcome), Ok(())) => Ok(outcome),
(Err(err), Ok(())) => Err(err),
(Ok(_), Err(err)) => Err(err),
(Err(err), Err(resume_err)) => Err(err.context(format!(
"also failed to restore the chat screen: {resume_err}"
))),
}
}
fn finalize_cancelled_turn(
config: &Config,
chat_id: &str,
@@ -186,6 +230,7 @@ async fn run_tui(
cwd: PathBuf,
mut conversation: Conversation,
warning: Option<String>,
cli: Cli,
) -> Result<()> {
let mut terminal = terminal::enter()?;
let mut transcript = Vec::new();
@@ -221,6 +266,7 @@ async fn run_tui(
let mut chat_id = conversation.id.clone();
let mut autofill_selected = 0usize;
let mut pending_approval: Option<PendingApproval> = None;
let mut provider_ready = true;
loop {
drain_agent_events(
@@ -672,6 +718,150 @@ async fn run_tui(
}
}
}
Ok(LocalCommand::Login) => {
if busy {
status = "login can be opened when idle".into();
} else {
input.clear();
autofill_selected = 0;
match run_login_menu_from_tui(&mut terminal, &cli).await
{
Ok(_) => match Config::load(&cli) {
Ok(updated) => {
config = updated;
reasoning_effort = config.reasoning_effort;
provider_ready = true;
let content = format!(
"active provider: {}\nactive model: {}",
config.provider_id, config.model
);
transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "login".into(),
content,
});
status = "login updated".into();
}
Err(err) => {
provider_ready = false;
status = format!(
"login saved with issues: {err}"
);
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "login".into(),
content: format!(
"Provider config could not be loaded: {err}"
),
});
}
},
Err(err) => {
status = format!("login cancelled: {err}");
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "login".into(),
content: err.to_string(),
});
}
}
if stick_to_bottom {
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
}
}
Ok(LocalCommand::Logout) => {
if busy {
status = "logout can be opened when idle".into();
} else {
input.clear();
autofill_selected = 0;
match run_logout_menu_from_tui(
&mut terminal,
&config.root,
) {
Ok(result) => {
if result.removed_provider_ids.is_empty() {
transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "logout".into(),
content: "logout cancelled".into(),
});
status = "logout cancelled".into();
} else if result.remaining_provider_count == 0 {
provider_ready = false;
transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "logout".into(),
content: format!(
"removed providers: {}\nremoved model entries: {}\nno providers remain; run /login before sending another message",
result.removed_provider_ids.join(", "),
result.removed_model_count
),
});
status = "no provider configured".into();
} else {
match Config::load(&cli) {
Ok(updated) => {
config = updated;
reasoning_effort =
config.reasoning_effort;
provider_ready = true;
transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "logout".into(),
content: format!(
"removed providers: {}\nremoved model entries: {}\nactive provider: {}\nactive model: {}",
result.removed_provider_ids.join(", "),
result.removed_model_count,
config.provider_id,
config.model
),
});
status = "logout updated".into();
}
Err(err) => {
provider_ready = false;
status = format!(
"logout saved with issues: {err}"
);
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "logout".into(),
content: format!(
"Provider config could not be loaded: {err}"
),
});
}
}
}
}
Err(err) => {
status = format!("logout cancelled: {err}");
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "logout".into(),
content: err.to_string(),
});
}
}
if stick_to_bottom {
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
}
}
Ok(LocalCommand::Status) => {
let content = chat_status(
&chat_id,
@@ -834,6 +1024,24 @@ async fn run_tui(
}
} else if busy {
status = "agent is still running".into();
} else if !provider_ready {
status = "run /login before sending a message".into();
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "provider".into(),
content:
"No active provider is configured. Run /login before sending another message."
.into(),
});
if stick_to_bottom {
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
} else {
let msg = input.trim_end().to_string();
current_turn_start_len = Some(conversation.records.len());
@@ -1677,6 +1885,8 @@ fn assistant_content_matches(a: &str, b: &str) -> bool {
#[derive(Debug, Clone, PartialEq, Eq)]
enum LocalCommand {
Branch,
Login,
Logout,
Model(String),
New,
Resume(String),
@@ -1697,6 +1907,18 @@ const COMMANDS: &[CommandSpec] = &[
description: "open branch/restore menu",
takes_value: false,
},
CommandSpec {
name: "login",
usage: "/login",
description: "configure provider login settings",
takes_value: false,
},
CommandSpec {
name: "logout",
usage: "/logout",
description: "remove saved providers and models",
takes_value: false,
},
CommandSpec {
name: "model",
usage: "/model <model>",
@@ -1953,6 +2175,18 @@ fn parse_local_command(input: &str) -> std::result::Result<LocalCommand, String>
}
Ok(LocalCommand::Branch)
}
"/login" => {
if parts.next().is_some() {
return Err("usage: /login".into());
}
Ok(LocalCommand::Login)
}
"/logout" => {
if parts.next().is_some() {
return Err("usage: /logout".into());
}
Ok(LocalCommand::Logout)
}
"/model" => {
let Some(model) = parts.next() else {
return Err("usage: /model <model>".into());
@@ -2396,12 +2630,43 @@ mod tests {
assert!(command_autofill("/new", 0).is_none());
}
#[test]
fn command_autofill_lists_login_and_logout_commands() {
let menu = command_autofill("/log", 0).unwrap();
let labels = menu
.items
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>();
assert_eq!(labels, vec!["/login", "/logout"]);
assert_eq!(menu.items[0].insert, "/login");
assert_eq!(menu.items[1].insert, "/logout");
}
#[test]
fn parse_local_command_accepts_new_without_args() {
assert_eq!(parse_local_command("/new").unwrap(), LocalCommand::New);
assert_eq!(parse_local_command("/new extra"), Err("usage: /new".into()));
}
#[test]
fn parse_local_command_accepts_login_and_logout_without_args() {
assert_eq!(parse_local_command("/login").unwrap(), LocalCommand::Login);
assert_eq!(
parse_local_command("/logout").unwrap(),
LocalCommand::Logout
);
assert_eq!(
parse_local_command("/login extra"),
Err("usage: /login".into())
);
assert_eq!(
parse_local_command("/logout extra"),
Err("usage: /logout".into())
);
}
#[test]
fn cancelled_turn_repairs_missing_tool_results() {
let root = tempdir().unwrap();
+4
View File
@@ -44,6 +44,10 @@ pub struct Cli {
pub enum Command {
/// Validate Cass config files.
Check,
/// Configure or update OpenAI-compatible provider login settings.
Login,
/// Remove saved providers and their models.
Logout,
/// Configure an OpenAI-compatible provider and first model.
Setup,
/// Update Cassady from official GitHub releases.
+324 -7
View File
@@ -18,6 +18,7 @@ use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupMode {
Explicit,
Login,
Auto,
}
@@ -45,6 +46,23 @@ pub struct SetupSelection {
pub supports_reasoning: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderLogoutCandidate {
pub id: String,
pub name: Option<String>,
pub default_model: Option<String>,
pub model_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogoutResult {
pub removed_provider_ids: Vec<String>,
pub removed_model_count: usize,
pub remaining_provider_count: usize,
pub active_provider: Option<String>,
pub active_model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ModelsResponse {
data: Vec<ModelItem>,
@@ -60,6 +78,13 @@ fn print_banner() {
println!("Configure an OpenAI-compatible provider, API key environment variable, and model.");
}
fn print_login_banner() {
println!("Cassady login");
println!(
"Add or update an OpenAI-compatible provider, API key environment variable, and model."
);
}
fn section(title: &str) {
println!("\n{title}");
println!("{}", "─".repeat(title.chars().count().max(12).min(72)));
@@ -187,10 +212,16 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
if !io::stdin().is_terminal() {
bail!("setup is interactive; run `cass setup` in a terminal");
match mode {
SetupMode::Login => bail!("login is interactive; run `cass login` in a terminal"),
_ => bail!("setup is interactive; run `cass setup` in a terminal"),
}
}
print_banner();
match mode {
SetupMode::Login => print_login_banner(),
_ => print_banner(),
}
match mode {
SetupMode::Explicit => {
@@ -206,6 +237,7 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
});
}
}
SetupMode::Login => {}
SetupMode::Auto => {
println!();
hint("Cassady needs this before starting your first chat.");
@@ -234,28 +266,128 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
let report = check::run(cli)?;
if report.has_errors() {
if std::env::var(&active_api_key_env).is_err() {
section("Setup saved");
section(match mode {
SetupMode::Login => "Login saved",
_ => "Setup saved",
});
warn("Your active provider API key is not available in this shell.");
hint(format!("Set it with: export {active_api_key_env}=..."));
hint("Then run: cass");
} else {
section("Setup saved with issues");
section(match mode {
SetupMode::Login => "Login saved with issues",
_ => "Setup saved with issues",
});
print!("{}", report.render());
hint("Run `cass setup` to try again or edit ~/.cass/config.json manually.");
hint(match mode {
SetupMode::Login => {
"Run `cass login` to try again or edit ~/.cass/config.json manually."
}
_ => "Run `cass setup` to try again or edit ~/.cass/config.json manually.",
});
}
return Ok(SetupOutcome {
start_session: false,
});
}
section("Setup complete");
section(match mode {
SetupMode::Login => "Login complete",
_ => "Setup complete",
});
success("Configuration saved and validated");
info("Starting your first Cassady session…");
match mode {
SetupMode::Login => info("Provider configuration is ready."),
_ => info("Starting your first Cassady session…"),
}
Ok(SetupOutcome {
start_session: true,
})
}
pub fn logout(root: &Path) -> Result<LogoutResult> {
fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
if !io::stdin().is_terminal() {
bail!("logout is interactive; run `cass logout` in a terminal");
}
let candidates = configured_providers(root)?;
if candidates.is_empty() {
bail!("no providers are configured; run `cass login` to add one");
}
section("Cassady logout");
warn("This removes provider entries from Cassady config only. It does not delete environment variables or provider accounts.");
let items = candidates
.iter()
.map(|candidate| {
let label = candidate.name.as_deref().unwrap_or(&candidate.id);
let model = candidate
.default_model
.as_deref()
.unwrap_or("no default model");
MenuItem::with_detail(
label.to_string(),
format!(
"{} · {} · {} model{}",
candidate.id,
model,
candidate.model_count,
if candidate.model_count == 1 { "" } else { "s" }
),
)
})
.collect();
let selected =
Menu::new("Remove saved providers", items).select_many(&BTreeSet::new(), true)?;
let provider_ids = selected
.into_iter()
.map(|idx| candidates[idx].id.clone())
.collect::<Vec<_>>();
let label = provider_ids.join(", ");
if !ask_yes_no(
&format!("Remove {label} and associated model entries?"),
false,
)? {
println!("Logout cancelled.");
return Ok(LogoutResult {
removed_provider_ids: Vec::new(),
removed_model_count: 0,
remaining_provider_count: candidates.len(),
active_provider: None,
active_model: None,
});
}
let result = remove_providers(root, &provider_ids)?;
if result.removed_provider_ids.is_empty() {
println!("No providers removed.");
} else {
success(format!(
"Removed {} provider{} and {} model entr{}",
result.removed_provider_ids.len(),
if result.removed_provider_ids.len() == 1 {
""
} else {
"s"
},
result.removed_model_count,
if result.removed_model_count == 1 {
"y"
} else {
"ies"
}
));
if let Some(provider) = &result.active_provider {
let model = result.active_model.as_deref().unwrap_or("no default model");
info(format!("Active provider is now {provider} ({model})"));
} else {
warn("No providers remain. Run `cass login` before starting a chat.");
}
}
Ok(result)
}
#[derive(Debug, Clone)]
struct ChosenProvider {
name: String,
@@ -565,6 +697,191 @@ pub fn apply_setups(root: &Path, selections: &[SetupSelection], active_index: us
Ok(())
}
pub fn configured_providers(root: &Path) -> Result<Vec<ProviderLogoutCandidate>> {
let providers = load_providers_or_empty(root)?;
let models = load_models_or_empty(root)?;
Ok(providers
.providers
.into_iter()
.map(|provider| {
let model_count = models
.models
.iter()
.filter(|model| model.provider == provider.id)
.count();
ProviderLogoutCandidate {
id: provider.id,
name: provider.name,
default_model: provider.default_model,
model_count,
}
})
.collect())
}
pub fn remove_providers(root: &Path, provider_ids: &[String]) -> Result<LogoutResult> {
if provider_ids.is_empty() {
bail!("select at least one provider to remove");
}
let selected: BTreeSet<String> = provider_ids
.iter()
.map(|id| id.trim().to_string())
.collect();
if selected.iter().any(|id| id.is_empty()) {
bail!("provider id must not be empty");
}
fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
let mut config_file = load_config_or_default(root)?;
let mut providers = load_providers_or_empty(root)?;
let mut models = load_models_or_empty(root)?;
let existing: BTreeSet<String> = providers
.providers
.iter()
.map(|provider| provider.id.clone())
.collect();
for id in &selected {
if !existing.contains(id) {
bail!("provider `{id}` is not configured");
}
}
let removed_provider_ids = providers
.providers
.iter()
.filter(|provider| selected.contains(&provider.id))
.map(|provider| provider.id.clone())
.collect::<Vec<_>>();
providers
.providers
.retain(|provider| !selected.contains(&provider.id));
let before_models = models.models.len();
models
.models
.retain(|model| !selected.contains(&model.provider));
let removed_model_count = before_models - models.models.len();
repair_active_defaults(&mut config_file, &providers, &models);
write_json_pretty(&config::providers_path(root), &providers)?;
write_json_pretty(&config::models_path(root), &models)?;
write_json_pretty(&config::config_path(root), &config_file)?;
Ok(LogoutResult {
removed_provider_ids,
removed_model_count,
remaining_provider_count: providers.providers.len(),
active_provider: config_file.default_provider,
active_model: config_file.default_model,
})
}
fn repair_active_defaults(
config_file: &mut ConfigFile,
providers: &ProvidersFile,
models: &ModelsFile,
) {
if providers.providers.is_empty() {
config_file.default_provider = None;
config_file.default_model = None;
config_file.default_reasoning_effort = None;
return;
}
let current_provider = config_file
.default_provider
.as_ref()
.filter(|id| {
providers
.providers
.iter()
.any(|provider| provider.id == **id)
})
.cloned();
let provider_id =
current_provider.unwrap_or_else(|| choose_provider_with_model(providers, models));
let model = config_file
.default_model
.as_ref()
.filter(|model| model_belongs_to_provider(models, &provider_id, model))
.cloned()
.or_else(|| default_model_for_provider(providers, models, &provider_id));
config_file.default_provider = Some(provider_id);
config_file.default_model = model;
if let Some(effort) = config_file.default_reasoning_effort {
if let Some(model_id) = config_file.default_model.as_deref() {
let model = models.models.iter().find(|model| {
model.provider == config_file.default_provider.as_deref().unwrap_or_default()
&& model.id == model_id
});
config_file.default_reasoning_effort = Some(effort.clamp_for_model(model));
}
}
}
fn choose_provider_with_model(providers: &ProvidersFile, models: &ModelsFile) -> String {
providers
.providers
.iter()
.find(|provider| {
provider
.default_model
.as_ref()
.is_some_and(|model| model_belongs_to_provider(models, &provider.id, model))
|| provider
.models
.iter()
.any(|model| model_belongs_to_provider(models, &provider.id, model))
|| models
.models
.iter()
.any(|model| model.provider == provider.id)
})
.or_else(|| providers.providers.first())
.map(|provider| provider.id.clone())
.unwrap_or_default()
}
fn default_model_for_provider(
providers: &ProvidersFile,
models: &ModelsFile,
provider_id: &str,
) -> Option<String> {
let provider = providers
.providers
.iter()
.find(|provider| provider.id == provider_id)?;
provider
.default_model
.as_ref()
.filter(|model| model_belongs_to_provider(models, provider_id, model))
.cloned()
.or_else(|| {
provider
.models
.iter()
.find(|model| model_belongs_to_provider(models, provider_id, model))
.cloned()
})
.or_else(|| {
models
.models
.iter()
.find(|model| model.provider == provider_id)
.map(|model| model.id.clone())
})
}
fn model_belongs_to_provider(models: &ModelsFile, provider_id: &str, model_id: &str) -> bool {
models
.models
.iter()
.any(|model| model.provider == provider_id && model.id == model_id)
}
fn upsert_provider(providers: &mut ProvidersFile, selection: &SetupSelection) {
let new_entry = ProviderDefinition {
id: selection.provider_id.clone(),
+23
View File
@@ -27,3 +27,26 @@ pub fn leave(mut terminal: CassTerminal) -> Result<()> {
terminal.show_cursor()?;
Ok(())
}
pub fn suspend(terminal: &mut CassTerminal) -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
pub fn resume(terminal: &mut CassTerminal) -> Result<()> {
enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
)?;
terminal.clear()?;
terminal.hide_cursor()?;
Ok(())
}
+140
View File
@@ -197,6 +197,146 @@ fn apply_setups_writes_multiple_providers_and_active_choice() {
);
}
#[test]
fn remove_providers_removes_models_and_preserves_active_provider() {
let root = tempdir().unwrap();
let selections = vec![
SetupSelection {
provider_id: "openai".into(),
provider_name: "OpenAI".into(),
base_url: "https://api.openai.com/v1".into(),
api_key_env: "OPENAI_API_KEY".into(),
model_id: "gpt-4.1".into(),
supports_tools: true,
supports_reasoning: true,
},
SetupSelection {
provider_id: "groq".into(),
provider_name: "Groq".into(),
base_url: "https://api.groq.com/openai/v1".into(),
api_key_env: "GROQ_API_KEY".into(),
model_id: "llama-3.3-70b-versatile".into(),
supports_tools: true,
supports_reasoning: false,
},
];
setup::apply_setups(root.path(), &selections, 0).unwrap();
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
assert_eq!(result.removed_provider_ids, vec!["groq"]);
assert_eq!(result.removed_model_count, 1);
assert_eq!(result.remaining_provider_count, 1);
assert_eq!(result.active_provider.as_deref(), Some("openai"));
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
let providers: ProvidersFile =
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
.unwrap();
assert_eq!(providers.providers.len(), 1);
assert_eq!(providers.providers[0].id, "openai");
let models: ModelsFile =
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
.unwrap();
assert_eq!(models.models.len(), 1);
assert_eq!(models.models[0].provider, "openai");
}
#[test]
fn remove_active_provider_selects_remaining_provider_and_model() {
let root = tempdir().unwrap();
let selections = vec![
SetupSelection {
provider_id: "openai".into(),
provider_name: "OpenAI".into(),
base_url: "https://api.openai.com/v1".into(),
api_key_env: "OPENAI_API_KEY".into(),
model_id: "gpt-4.1".into(),
supports_tools: true,
supports_reasoning: true,
},
SetupSelection {
provider_id: "groq".into(),
provider_name: "Groq".into(),
base_url: "https://api.groq.com/openai/v1".into(),
api_key_env: "GROQ_API_KEY".into(),
model_id: "llama-3.3-70b-versatile".into(),
supports_tools: true,
supports_reasoning: false,
},
];
setup::apply_setups(root.path(), &selections, 1).unwrap();
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
assert_eq!(result.active_provider.as_deref(), Some("openai"));
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
let config: ConfigFile =
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
.unwrap();
assert_eq!(config.default_provider.as_deref(), Some("openai"));
assert_eq!(config.default_model.as_deref(), Some("gpt-4.1"));
}
#[test]
fn remove_all_providers_clears_active_defaults() {
let root = tempdir().unwrap();
setup::apply_setup(
root.path(),
&SetupSelection {
provider_id: "openai".into(),
provider_name: "OpenAI".into(),
base_url: "https://api.openai.com/v1".into(),
api_key_env: "OPENAI_API_KEY".into(),
model_id: "gpt-4.1".into(),
supports_tools: true,
supports_reasoning: true,
},
)
.unwrap();
let result = setup::remove_providers(root.path(), &["openai".to_string()]).unwrap();
assert_eq!(result.remaining_provider_count, 0);
assert!(result.active_provider.is_none());
assert!(result.active_model.is_none());
let config: ConfigFile =
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
.unwrap();
assert!(config.default_provider.is_none());
assert!(config.default_model.is_none());
let models: ModelsFile =
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
.unwrap();
assert!(models.models.is_empty());
}
#[test]
fn remove_unknown_provider_fails() {
let root = tempdir().unwrap();
setup::apply_setup(
root.path(),
&SetupSelection {
provider_id: "openai".into(),
provider_name: "OpenAI".into(),
base_url: "https://api.openai.com/v1".into(),
api_key_env: "OPENAI_API_KEY".into(),
model_id: "gpt-4.1".into(),
supports_tools: true,
supports_reasoning: true,
},
)
.unwrap();
let err = setup::remove_providers(root.path(), &["missing".to_string()]).unwrap_err();
assert!(err
.to_string()
.contains("provider `missing` is not configured"));
}
#[test]
fn needs_initial_setup_detects_empty_and_default_only_roots() {
let root = tempdir().unwrap();