Add interactive setup wizard
This commit is contained in:
Generated
+1
-1
@@ -151,7 +151,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
|
||||
[[package]]
|
||||
name = "cassady"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
description = "Cassady/Cass minimal terminal coding agent"
|
||||
license = "MIT"
|
||||
|
||||
@@ -17,13 +17,27 @@ cassady
|
||||
|
||||
## Configure
|
||||
|
||||
By default Cass creates `~/.cass/providers.json` and `~/.cass/models.json` with Fireworks configured:
|
||||
On first run, Cass starts an interactive setup wizard if it cannot find a usable provider/model/API key:
|
||||
|
||||
```sh
|
||||
cass
|
||||
```
|
||||
|
||||
You can also run the wizard explicitly:
|
||||
|
||||
```sh
|
||||
cass setup
|
||||
```
|
||||
|
||||
The wizard uses clean keyboard prompts: `↑`/`↓` moves, `Space` selects providers, and `Enter` submits. It supports configuring multiple OpenAI-compatible providers at once: OpenAI, xAI, Fireworks, Groq, OpenRouter, OpenCode Zen, OpenCode Go, Cerebras, Novita, Together, and custom OpenAI-compatible endpoints. It asks for API key environment variables, tries to fetch models from `GET /models`, lets you retry model discovery or enter a model id manually if discovery fails, saves config, validates setup, and starts a new session when ready.
|
||||
|
||||
By default, Cass still ships with Fireworks defaults:
|
||||
|
||||
- base URL: `https://api.fireworks.ai/inference/v1`
|
||||
- model: `accounts/fireworks/models/qwen3p7-plus`
|
||||
- API key: `"$FIREWORKS_API_KEY"`
|
||||
|
||||
Set your key:
|
||||
Set your selected provider key, for example:
|
||||
|
||||
```sh
|
||||
export FIREWORKS_API_KEY=...
|
||||
@@ -58,6 +72,7 @@ cass [--model MODEL] [--base-url URL] [--api-key-env ENV] [--cwd PATH]
|
||||
cass --resume <chat-id>
|
||||
cass --resume
|
||||
cass check
|
||||
cass setup
|
||||
```
|
||||
|
||||
`cass --resume` without an ID lists chats for the current directory.
|
||||
@@ -69,7 +84,7 @@ cass check
|
||||
- `Enter`: fill autocomplete selection when a menu is open; otherwise send message / run command
|
||||
- `Tab`: cycle reasoning effort (`off` → `low` → `medium` → `high`; required-reasoning models skip `off`)
|
||||
- `Ctrl-J`: insert newline
|
||||
- `Shift-Tab`: toggle read-only/full-access mode while idle
|
||||
- `Shift-Tab`: cycle access mode while idle (`read-only` → `workspace-edit` → `full-access`)
|
||||
- `Ctrl-O`: toggle compact/full tool output display
|
||||
- `Ctrl-Shift-R`: toggle reasoning display
|
||||
- `Up`/`Down` or mouse wheel: scroll transcript when no autocomplete menu is open
|
||||
@@ -79,6 +94,7 @@ cass check
|
||||
## Commands
|
||||
|
||||
- `cass check`: validate Cass config files
|
||||
- `cass setup`: choose an OpenAI-compatible provider/model and save config
|
||||
- `/model <model>`: switch the model for future turns; model autocomplete lists entries from `~/.cass/models.json`
|
||||
- `/new`: create a new chat for the current directory
|
||||
- `/resume <chat>`: resume a saved chat; chat autocomplete lists chats for the current directory
|
||||
@@ -98,6 +114,6 @@ Reasoning is hidden by default unless `show_reasoning` is enabled; press `Ctrl-S
|
||||
|
||||
Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd` and the bundled docs directory at `~/.cass/docs`.
|
||||
|
||||
Full-access mode additionally allows `write`, `edit`, and `shell`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. `write` and `edit` are always blocked under `~/.cass/docs`. The `shell` tool runs commands via `sh -c` in the launch working directory with a configurable timeout (default 30 seconds) and streams stdout/stderr into the transcript while the command is running.
|
||||
Workspace-edit mode allows `ls`, `read`, `grep`, `write`, and `edit` inside the launch workspace. Bundled Cass docs remain read-only. Shell commands are available but require explicit approval before execution.
|
||||
|
||||
The `shell` tool is available in full-access mode and runs shell commands in the launch working directory.
|
||||
Full-access mode additionally allows broader filesystem access under the user's OS permissions. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. `write` and `edit` are always blocked under `~/.cass/docs`. The `shell` tool runs commands via `sh -c` in the launch working directory with a configurable timeout (default 30 seconds) and streams stdout/stderr into the transcript while the command is running.
|
||||
|
||||
+37
@@ -1,5 +1,42 @@
|
||||
# Cassady (Cass) Roadmap
|
||||
|
||||
## v0.2.2 — First-Run Onboarding and Setup Wizard ✅ Completed
|
||||
|
||||
This release focuses on making Cassady easy to start using from a fresh install. See `plans/V0_2_2_ONBOARDING_SETUP_WIZARD_PLAN.md`.
|
||||
|
||||
### Interactive Setup
|
||||
|
||||
- [x] **Add a first-run setup wizard.** When `cass` cannot resolve a usable active provider/model/API key, guide the user through setup instead of starting a chat that will fail.
|
||||
- Trigger automatically on first run or incomplete setup.
|
||||
- Add `cass setup` to run the wizard explicitly.
|
||||
- Keep `cass check` non-interactive.
|
||||
|
||||
- [x] **Support OpenAI-compatible provider selection.** Present a reusable keyboard menu with multi-select built-in providers and a custom provider option.
|
||||
- OpenAI: `https://api.openai.com/v1`
|
||||
- xAI: `https://api.x.ai/v1`
|
||||
- Fireworks: `https://api.fireworks.ai/inference/v1`
|
||||
- Groq: `https://api.groq.com/openai/v1`
|
||||
- OpenRouter: `https://openrouter.ai/api/v1`
|
||||
- OpenCode Zen: `https://opencode.ai/zen/v1`
|
||||
- OpenCode Go: `https://opencode.ai/zen/go/v1`
|
||||
- Cerebras: `https://api.cerebras.ai/v1`
|
||||
- Novita: `https://api.novita.ai/v3/openai`
|
||||
- Together: `https://api.together.xyz/v1`
|
||||
- Custom OpenAI-compatible provider.
|
||||
- Do not add Anthropic-native or non-OpenAI-compatible protocols in this release.
|
||||
|
||||
- [x] **Guide API key configuration.** Default to environment-variable based API keys, check whether the selected env var is set, and provide exact next steps when it is missing.
|
||||
|
||||
- [x] **Guide first-model selection.** Attempt OpenAI-compatible `GET /models` discovery when the API key is available, allow selecting a discovered model, and always provide manual model id entry as a fallback.
|
||||
|
||||
- [x] **Write valid config and start the first session.** Upsert provider/model entries, set active defaults, run setup validation, and automatically start a new chat only when the active API key is available.
|
||||
|
||||
### Setup Diagnostics and Docs
|
||||
|
||||
- [x] **Improve `cass check` onboarding output.** Show active provider, base URL, model, API key env var status, and actionable next steps such as `cass setup` or `export PROVIDER_API_KEY=...`.
|
||||
|
||||
- [x] **Refresh onboarding documentation.** Update README and bundled docs for `cass setup`, first-run behavior, OpenAI-compatible provider selection, API key env vars, model discovery/manual fallback, and current access modes including `workspace-edit`.
|
||||
|
||||
## v0.2.1 — Message Rendering Polish ✅ Completed
|
||||
|
||||
### Transcript Rendering
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@ These docs are embedded into the `cass` binary at build time and installed to `~
|
||||
|
||||
Cass tools may list, search, and read this directory. Mutating tools are blocked from writing here, even in full-access mode.
|
||||
|
||||
- [Configuration](configuration.md): `config.json`, `providers.json`, `models.json`, and `cass check`.
|
||||
- [Configuration](configuration.md): first-run setup, `cass setup`, `config.json`, `providers.json`, `models.json`, and `cass check`.
|
||||
|
||||
+36
-3
@@ -6,7 +6,7 @@ Cass reads user-editable config files from `~/.cass`.
|
||||
- `providers.json`: provider connection definitions.
|
||||
- `models.json`: model metadata.
|
||||
|
||||
Cass creates `providers.json` and `models.json` automatically if they are missing. The default provider is Fireworks.
|
||||
Cass creates `providers.json` and `models.json` automatically if they are missing. The default provider is Fireworks. On first run, Cass can also launch an interactive setup wizard to choose an OpenAI-compatible provider and first model.
|
||||
|
||||
## `config.json`
|
||||
|
||||
@@ -29,7 +29,7 @@ Fields:
|
||||
|
||||
- `default_provider`: optional provider id from `providers.json`. If omitted, Cass infers the provider from `default_model` when possible.
|
||||
- `default_model`: optional model id to use by default.
|
||||
- `default_access_mode`: `"read-only"` or `"full-access"`.
|
||||
- `default_access_mode`: `"read-only"`, `"workspace-edit"`, or `"full-access"`.
|
||||
- `context_message_limit`: optional legacy upper bound for recent non-system messages. Cass primarily budgets context from model metadata (`context_length` and `max_output_tokens`), compacts older tool outputs when needed, and trims only along valid tool-call boundaries.
|
||||
- `model_tool_result_limit`: optional max bytes of tool output sent back to the model.
|
||||
- `ui_tool_result_limit`: optional max bytes of tool output shown in the UI unless full output is toggled.
|
||||
@@ -114,6 +114,39 @@ Fields:
|
||||
|
||||
Reasoning effort is a runtime per-turn setting. Press `Tab` to cycle it while idle. For models with reasoning metadata, the default effort is `medium` unless overridden by `default_effort`; for models without metadata, reasoning starts `off`.
|
||||
|
||||
## Setup wizard
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
cass setup
|
||||
```
|
||||
|
||||
Cass also offers setup automatically when `cass` cannot resolve a usable active provider/model/API key before starting a chat.
|
||||
|
||||
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. On an empty install, Cass opens this menu before reading default Fireworks settings, even if `FIREWORKS_API_KEY` is already set.
|
||||
|
||||
The wizard supports configuring multiple OpenAI-compatible providers at once:
|
||||
|
||||
| Provider | Base URL | Suggested env var |
|
||||
| --- | --- | --- |
|
||||
| OpenAI | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
|
||||
| xAI | `https://api.x.ai/v1` | `XAI_API_KEY` |
|
||||
| Fireworks | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
|
||||
| Groq | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` |
|
||||
| OpenRouter | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
|
||||
| OpenCode Zen | `https://opencode.ai/zen/v1` | `OPENCODE_API_KEY` |
|
||||
| OpenCode Go | `https://opencode.ai/zen/go/v1` | `OPENCODE_API_KEY` |
|
||||
| Cerebras | `https://api.cerebras.ai/v1` | `CEREBRAS_API_KEY` |
|
||||
| Novita | `https://api.novita.ai/v3/openai` | `NOVITA_API_KEY` |
|
||||
| Together | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` |
|
||||
|
||||
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. If you configure more than one provider, setup asks which one Cass should use first.
|
||||
|
||||
Setup stores API keys as environment-variable references like `"$GROQ_API_KEY"` by default. If the selected environment variable is set, Cass tries to fetch models from `GET {base_url}/models` and lets you choose one. If discovery fails, Cass offers a retry before falling back to manual model entry. If the API key is not set, Cass asks you to enter a model id manually.
|
||||
|
||||
After setup, Cass writes/updates `config.json`, `providers.json`, and `models.json`, validates them, and starts a new chat only when the active API key is available in the current shell.
|
||||
|
||||
## Check configuration
|
||||
|
||||
Run:
|
||||
@@ -122,7 +155,7 @@ Run:
|
||||
cass check
|
||||
```
|
||||
|
||||
This validates JSON syntax, expected schema, duplicate provider/model ids, model/provider references, active provider/model resolution, and API key environment-variable availability. Missing API keys for inactive providers are warnings; a missing active provider API key is an error.
|
||||
This validates JSON syntax, expected schema, duplicate provider/model ids, model/provider references, active provider/model resolution, and API key environment-variable availability. Missing API keys for inactive providers are warnings; a missing active provider API key is an error. When setup is incomplete, `cass check` prints actionable next steps such as `export PROVIDER_API_KEY=...`, `cass check`, and `cass`.
|
||||
|
||||
## Ask Cass to edit config
|
||||
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
# v0.2.2 Onboarding and Setup Wizard Plan
|
||||
|
||||
## Goal
|
||||
|
||||
v0.2.2 focuses on first-run onboarding and quality of life when getting started with Cassady. A new user should be able to run `cass`, choose an OpenAI-compatible provider, choose the first model they want to use, configure the API key location, and immediately start a new Cassady session without reading documentation first.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A new user can install Cassady, run `cass`, select a provider/model, pass setup validation, and start their first chat in under five minutes.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add an interactive setup wizard.
|
||||
- Trigger setup automatically when Cass cannot resolve a usable active provider/model/API key.
|
||||
- Add an explicit `cass setup` command to run setup on demand.
|
||||
- Support only OpenAI-compatible providers.
|
||||
- Offer a built-in provider catalog with common OpenAI-compatible providers.
|
||||
- Support a custom OpenAI-compatible provider path.
|
||||
- Prefer model discovery via the provider's OpenAI-compatible `/models` endpoint.
|
||||
- Fall back to manual model id entry when model discovery fails or is skipped.
|
||||
- Write/update `~/.cass/config.json`, `~/.cass/providers.json`, and `~/.cass/models.json` safely.
|
||||
- Run validation equivalent to `cass check` after setup.
|
||||
- Start a new chat session automatically after successful first-run setup.
|
||||
- Improve `cass check` messaging where needed so setup errors are actionable.
|
||||
- Update README and bundled docs after implementation.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Anthropic-native support or any non-OpenAI-compatible API protocol.
|
||||
- Maintaining large hardcoded model catalogs for every provider.
|
||||
- Multi-provider account management beyond selecting and saving the active provider/model.
|
||||
- OAuth or browser-based provider authentication.
|
||||
- Full TUI redesign.
|
||||
- Changing agent/tool behavior after chat starts.
|
||||
|
||||
## Built-in Provider Catalog
|
||||
|
||||
The setup wizard should present these providers in this order:
|
||||
|
||||
| Provider | Provider id | Base URL | Suggested API key env var |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenAI | `openai` | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
|
||||
| xAI | `xai` | `https://api.x.ai/v1` | `XAI_API_KEY` |
|
||||
| Fireworks | `fireworks` | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
|
||||
| Groq | `groq` | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` |
|
||||
| OpenRouter | `openrouter` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
|
||||
| OpenCode Zen | `opencode-zen` | `https://opencode.ai/zen/v1` | `OPENCODE_API_KEY` |
|
||||
| OpenCode Go | `opencode-go` | `https://opencode.ai/zen/go/v1` | `OPENCODE_API_KEY` |
|
||||
| Cerebras | `cerebras` | `https://api.cerebras.ai/v1` | `CEREBRAS_API_KEY` |
|
||||
| Novita | `novita` | `https://api.novita.ai/v3/openai` | `NOVITA_API_KEY` |
|
||||
| Together | `together` | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` |
|
||||
| Custom OpenAI-compatible | user-entered | user-entered | user-entered |
|
||||
|
||||
Notes:
|
||||
|
||||
- The provider kind remains `openai-compatible` for every catalog entry.
|
||||
- Use `Cerebras` spelling in UI and docs.
|
||||
- OpenCode Zen and OpenCode Go share the suggested `OPENCODE_API_KEY` env var but have separate provider ids and base URLs.
|
||||
- If a provider requires extra HTTP headers beyond Authorization in the future, defer that to a later provider configuration enhancement unless it blocks the standard OpenAI-compatible flow.
|
||||
|
||||
## User Experience
|
||||
|
||||
### First-run trigger
|
||||
|
||||
When the user runs:
|
||||
|
||||
```sh
|
||||
cass
|
||||
```
|
||||
|
||||
Cass should run normal config resolution first. If no usable active provider/model/API key exists, Cass should show a friendly setup prompt instead of dropping the user into a chat that will fail on the first request.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Welcome to Cassady.
|
||||
|
||||
Cassady needs an OpenAI-compatible provider and model before starting your first chat.
|
||||
|
||||
Start setup now? [Y/n]
|
||||
```
|
||||
|
||||
Default should be `Y`. If the user chooses `n`, print a concise next step:
|
||||
|
||||
```text
|
||||
Run `cass setup` when you are ready.
|
||||
```
|
||||
|
||||
### Explicit setup command
|
||||
|
||||
Add:
|
||||
|
||||
```sh
|
||||
cass setup
|
||||
```
|
||||
|
||||
This command should run the same wizard even if config already exists. If existing config is present, the wizard should make that clear and avoid destructive surprises:
|
||||
|
||||
```text
|
||||
Cassady already has provider configuration.
|
||||
|
||||
This setup can update your active provider/model while preserving unrelated providers where possible.
|
||||
Continue? [y/N]
|
||||
```
|
||||
|
||||
Default should be `N` for already-configured setups.
|
||||
|
||||
### Provider selection
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
Choose an OpenAI-compatible provider:
|
||||
|
||||
1. OpenAI
|
||||
2. xAI
|
||||
3. Fireworks
|
||||
4. Groq
|
||||
5. OpenRouter
|
||||
6. OpenCode Zen
|
||||
7. OpenCode Go
|
||||
8. Cerebras
|
||||
9. Novita
|
||||
10. Together
|
||||
11. Custom OpenAI-compatible
|
||||
|
||||
Provider [3]:
|
||||
```
|
||||
|
||||
Default can be Fireworks to preserve the current Cassady default.
|
||||
|
||||
The prompt should accept the number and, if easy to support, a case-insensitive provider id/name.
|
||||
|
||||
### Custom provider path
|
||||
|
||||
For custom providers, ask:
|
||||
|
||||
```text
|
||||
Provider name:
|
||||
Provider id:
|
||||
Base URL:
|
||||
API key environment variable:
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- Provider id must be non-empty and safe for config ids: lowercase letters, numbers, `_`, `-`, and `.` are acceptable.
|
||||
- Base URL must be non-empty and should parse as an absolute HTTP/HTTPS URL.
|
||||
- Env var name must be non-empty and should look like an environment variable name: uppercase letters, numbers, and `_` recommended. Do not make lowercase env vars impossible if the user insists, but warn.
|
||||
|
||||
### API key handling
|
||||
|
||||
The wizard should default to storing env-var references, not literal keys.
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
API key environment variable [FIREWORKS_API_KEY]:
|
||||
```
|
||||
|
||||
Then check whether the variable is set in the current process environment.
|
||||
|
||||
If set:
|
||||
|
||||
```text
|
||||
✓ FIREWORKS_API_KEY is set
|
||||
```
|
||||
|
||||
If missing:
|
||||
|
||||
```text
|
||||
! FIREWORKS_API_KEY is not set in this shell.
|
||||
|
||||
Set it before starting a chat:
|
||||
export FIREWORKS_API_KEY=...
|
||||
|
||||
Continue setup anyway? [Y/n]
|
||||
```
|
||||
|
||||
Default should be `Y`, because users may want Cassady to write config first and set env vars later. However, Cass should not auto-start the chat after setup if the selected active API key is still unavailable.
|
||||
|
||||
Literal API keys should not be the default path. If supported in v0.2.2, put it behind an explicit advanced option:
|
||||
|
||||
```text
|
||||
Store a literal API key in providers.json? This is less secure than an environment variable. [y/N]
|
||||
```
|
||||
|
||||
It is acceptable to defer literal-key entry from the wizard because current config files already support literal keys for advanced manual configuration.
|
||||
|
||||
### Model selection
|
||||
|
||||
After provider and API key env var are selected, attempt model discovery when the API key is available.
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
GET {base_url}/models
|
||||
Authorization: Bearer {api_key}
|
||||
```
|
||||
|
||||
Expected OpenAI-compatible response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": "model-id" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If discovery succeeds and returns models:
|
||||
|
||||
```text
|
||||
Choose your first model:
|
||||
|
||||
1. accounts/fireworks/models/qwen3p7-plus
|
||||
2. accounts/fireworks/models/deepseek-v3
|
||||
3. Enter model id manually
|
||||
|
||||
Model:
|
||||
```
|
||||
|
||||
Model list behavior:
|
||||
|
||||
- Sort models alphabetically unless provider order is meaningful and preserved from response.
|
||||
- Cap displayed models to a reasonable amount, e.g. first 50, with manual entry always available.
|
||||
- If filtering/search is easy in a future interactive UI, defer it. A simple numbered list is enough for v0.2.2.
|
||||
|
||||
If discovery fails, API key is missing, or response is unsupported:
|
||||
|
||||
```text
|
||||
Cassady could not fetch models from this provider.
|
||||
|
||||
Enter the model id you want to use:
|
||||
```
|
||||
|
||||
Manual model id must be non-empty.
|
||||
|
||||
### Model metadata defaults
|
||||
|
||||
The wizard should create a minimal useful model metadata entry.
|
||||
|
||||
Defaults for discovered or manually entered models:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "selected-model-id",
|
||||
"provider": "selected-provider-id",
|
||||
"supports_tools": true,
|
||||
"supports_streaming": true,
|
||||
"reasoning": {
|
||||
"supported": true,
|
||||
"required": false,
|
||||
"default_effort": "medium",
|
||||
"request_format": "reasoning_effort"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenAI-compatible providers vary in reasoning support. Since Cassady already allows model metadata edits, v0.2.2 can choose a pragmatic default but should let the user opt out if the setup flow asks advanced questions.
|
||||
|
||||
Recommended v0.2.2 simple path:
|
||||
|
||||
```text
|
||||
Does this model support tool calls? [Y/n]
|
||||
Does this model support reasoning effort controls? [Y/n]
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- Tool calls: `Y`
|
||||
- Reasoning controls: `Y` for continuity with current defaults, or `n` if early testing shows many providers reject reasoning fields.
|
||||
|
||||
If the user says reasoning is not supported, write:
|
||||
|
||||
```json
|
||||
"reasoning": { "supported": false }
|
||||
```
|
||||
|
||||
If the user says tool calls are not supported, write `"supports_tools": false` and warn:
|
||||
|
||||
```text
|
||||
! Cassady works best with models that support tool calls.
|
||||
```
|
||||
|
||||
### Config write behavior
|
||||
|
||||
After selection, write/update:
|
||||
|
||||
- `~/.cass/providers.json`
|
||||
- `~/.cass/models.json`
|
||||
- `~/.cass/config.json`
|
||||
|
||||
Rules:
|
||||
|
||||
- Preserve unrelated existing providers and models where possible.
|
||||
- Upsert the selected provider by id.
|
||||
- Upsert the selected model by id.
|
||||
- Set active preferences in `config.json`:
|
||||
- `default_provider`: selected provider id
|
||||
- `default_model`: selected model id
|
||||
- keep existing user preferences such as `default_access_mode`, tool result limits, context settings, and `show_reasoning` unless setup explicitly changes them.
|
||||
- Do not write API key literals by default. Use `"$ENV_VAR_NAME"`.
|
||||
- Use pretty JSON formatting.
|
||||
- Avoid printing literal API key values if literal keys are ever supported.
|
||||
|
||||
Example provider entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "groq",
|
||||
"name": "Groq",
|
||||
"kind": "openai-compatible",
|
||||
"base_url": "https://api.groq.com/openai/v1",
|
||||
"api_key": "$GROQ_API_KEY",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"models": [
|
||||
"llama-3.3-70b-versatile"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Example config entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_provider": "groq",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"default_access_mode": "read-only"
|
||||
}
|
||||
```
|
||||
|
||||
## Post-setup validation and session start
|
||||
|
||||
After writing config, run the same validation used by `cass check`.
|
||||
|
||||
If validation passes and active API key is available:
|
||||
|
||||
```text
|
||||
Setup complete.
|
||||
|
||||
Starting your first Cassady session...
|
||||
```
|
||||
|
||||
Then start a new chat session automatically.
|
||||
|
||||
If validation passes but API key is missing:
|
||||
|
||||
```text
|
||||
Setup saved, but your API key is not available in this shell.
|
||||
|
||||
Set it with:
|
||||
export FIREWORKS_API_KEY=...
|
||||
|
||||
Then run:
|
||||
cass
|
||||
```
|
||||
|
||||
Do not start a chat automatically in this case.
|
||||
|
||||
If validation fails:
|
||||
|
||||
```text
|
||||
Setup was saved, but Cassady is not ready yet.
|
||||
|
||||
<rendered check errors>
|
||||
|
||||
Run `cass setup` to try again or edit ~/.cass/config.json manually.
|
||||
```
|
||||
|
||||
Do not start a chat automatically.
|
||||
|
||||
## `cass check` Quality-of-Life Improvements
|
||||
|
||||
`cass check` should remain non-interactive, but its output should be more onboarding-oriented.
|
||||
|
||||
Add or verify:
|
||||
|
||||
- Active provider id.
|
||||
- Active provider base URL.
|
||||
- Active model id.
|
||||
- API key env var name and whether it is set.
|
||||
- Next-step suggestions when setup is incomplete.
|
||||
- A hint to run `cass setup` when config is missing or invalid.
|
||||
|
||||
Example failure:
|
||||
|
||||
```text
|
||||
Cass config check
|
||||
|
||||
✓ ~/.cass/providers.json: valid (1 provider)
|
||||
✓ ~/.cass/models.json: valid (1 model)
|
||||
✓ active provider: fireworks
|
||||
✓ active model: accounts/fireworks/models/qwen3p7-plus
|
||||
✗ api key: environment variable `FIREWORKS_API_KEY` is not set
|
||||
|
||||
Next step:
|
||||
export FIREWORKS_API_KEY=...
|
||||
|
||||
Then run:
|
||||
cass check
|
||||
cass
|
||||
|
||||
Config check failed.
|
||||
```
|
||||
|
||||
## CLI/API Design Notes
|
||||
|
||||
Suggested CLI enum addition:
|
||||
|
||||
```rust
|
||||
pub enum Command {
|
||||
Check,
|
||||
Setup,
|
||||
}
|
||||
```
|
||||
|
||||
Suggested module:
|
||||
|
||||
```text
|
||||
src/setup.rs
|
||||
```
|
||||
|
||||
Potential responsibilities:
|
||||
|
||||
- Provider catalog definitions.
|
||||
- Interactive prompt helpers.
|
||||
- Provider/model selection.
|
||||
- Model discovery.
|
||||
- Config upsert/write.
|
||||
- Post-setup validation result.
|
||||
|
||||
Keep setup separate from the TUI chat app so it can run in plain terminal mode before ratatui/crossterm takes over.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Network failures during model discovery should not fail setup; fall back to manual model id entry.
|
||||
- Invalid user input should re-prompt with concise guidance.
|
||||
- Config write failures should fail setup and print the file path and error.
|
||||
- Validation failures after writes should be printed clearly.
|
||||
- Do not reveal literal API keys in errors or logs.
|
||||
- If stdout is non-interactive in the future, setup can fail with a message telling the user to run interactively. v0.2.2 does not need a full non-interactive setup mode.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
Add unit/integration coverage for:
|
||||
|
||||
- Provider catalog contains the expected providers, ids, URLs, and env vars.
|
||||
- Custom provider validation accepts valid ids/base URLs and rejects empty/invalid required fields.
|
||||
- Config upsert preserves unrelated providers/models.
|
||||
- Config upsert updates selected provider/model and active defaults.
|
||||
- `cass check` reports missing active API key with actionable next steps.
|
||||
- Model discovery parses OpenAI-compatible `/models` responses.
|
||||
- Model discovery failure falls back to manual model entry without failing setup.
|
||||
- First-run app path invokes setup when active config cannot be used.
|
||||
- Successful setup with available API key proceeds to a new session.
|
||||
- Successful setup with missing API key saves config but does not start a chat.
|
||||
|
||||
If interactive stdin/stdout tests are too heavy, isolate the wizard behind an input/output trait so scripted tests can provide answers and capture prompts.
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
After implementation, update:
|
||||
|
||||
- `README.md`
|
||||
- Add `cass setup`.
|
||||
- Describe first-run provider/model selection.
|
||||
- Update access mode docs to include `workspace-edit` if still missing.
|
||||
- `docs/configuration.md`
|
||||
- Add setup wizard section.
|
||||
- Document built-in provider catalog.
|
||||
- Clarify env-var API key handling.
|
||||
- `docs/README.md`
|
||||
- Link to setup/configuration docs.
|
||||
- Any release notes/changelog file if one is added before v0.2.2.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Running `cass` on a fresh machine/config prompts for setup instead of failing in-chat.
|
||||
- The user can select OpenAI, xAI, Fireworks, Groq, OpenRouter, OpenCode Zen, OpenCode Go, Cerebras, Novita, Together, or a custom OpenAI-compatible provider.
|
||||
- The user can select a discovered model or enter a model id manually.
|
||||
- Setup writes valid Cassady config files.
|
||||
- Setup validates the result and only starts a new chat when the active API key is available.
|
||||
- `cass setup` can be run explicitly.
|
||||
- `cass check` provides actionable next steps for incomplete setup.
|
||||
- No Anthropic-native or non-OpenAI-compatible provider path appears in setup.
|
||||
+39
-3
@@ -1,6 +1,6 @@
|
||||
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
|
||||
use crate::cli::{self, Command};
|
||||
use crate::config::{Config, ModelDefinition, ReasoningEffort};
|
||||
use crate::config::{self, Config, ModelDefinition, ReasoningEffort};
|
||||
use crate::conversation::{self, Conversation, Record};
|
||||
use crate::prompt;
|
||||
use crate::ui::autofill::{AutoFillItem, AutoFillMenu};
|
||||
@@ -20,7 +20,7 @@ const TURN_CANCELLED_MESSAGE: &str = "Turn cancelled by user.";
|
||||
const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user.";
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
let cli = cli::parse();
|
||||
let mut cli = cli::parse();
|
||||
if matches!(cli.command, Some(Command::Check)) {
|
||||
let report = crate::check::run(&cli)?;
|
||||
print!("{}", report.render());
|
||||
@@ -30,7 +30,35 @@ pub async fn run() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = Config::load(&cli)?;
|
||||
if matches!(cli.command, Some(Command::Setup)) {
|
||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Explicit).await?;
|
||||
if !outcome.start_session {
|
||||
return Ok(());
|
||||
}
|
||||
cli.command = None;
|
||||
}
|
||||
|
||||
if cli.command.is_none()
|
||||
&& cli.resume.is_none()
|
||||
&& crate::setup::needs_initial_setup(&config::cass_root())
|
||||
{
|
||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Auto).await?;
|
||||
if !outcome.start_session {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut config = match Config::load(&cli) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
eprintln!("Cassady is not ready to start: {err:#}\n");
|
||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Auto).await?;
|
||||
if !outcome.start_session {
|
||||
return Ok(());
|
||||
}
|
||||
Config::load(&cli)?
|
||||
}
|
||||
};
|
||||
let cwd = resolve_cwd(cli.cwd.clone())?;
|
||||
|
||||
if matches!(cli.resume, Some(None)) {
|
||||
@@ -38,6 +66,14 @@ pub async fn run() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if config.resolved_api_key().is_err() {
|
||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Auto).await?;
|
||||
if !outcome.start_session {
|
||||
return Ok(());
|
||||
}
|
||||
config = Config::load(&cli)?;
|
||||
}
|
||||
|
||||
let (conversation, warning) = if let Some(Some(id)) = cli.resume.clone() {
|
||||
Conversation::load(&config.conversations_dir(), &id)?
|
||||
} else {
|
||||
|
||||
@@ -38,12 +38,54 @@ impl CheckReport {
|
||||
out.push_str(error);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
let next_steps = self.next_steps();
|
||||
if !next_steps.is_empty() {
|
||||
out.push_str("\nNext step");
|
||||
if next_steps.len() != 1 {
|
||||
out.push('s');
|
||||
}
|
||||
out.push_str(":\n");
|
||||
for step in &next_steps {
|
||||
out.push_str(" ");
|
||||
out.push_str(step);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
if !self.errors.is_empty() {
|
||||
out.push_str("\nConfig check failed.\n");
|
||||
} else {
|
||||
out.push_str("\nAll checks passed.\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn next_steps(&self) -> Vec<String> {
|
||||
if self.errors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
for error in &self.errors {
|
||||
if let Some(name) = missing_env_var_name(error) {
|
||||
return vec![
|
||||
format!("export {name}=..."),
|
||||
"cass check".into(),
|
||||
"cass".into(),
|
||||
];
|
||||
}
|
||||
}
|
||||
vec!["cass setup".into()]
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_env_var_name(error: &str) -> Option<String> {
|
||||
let marker = "environment variable `";
|
||||
let rest = error.split_once(marker)?.1;
|
||||
let name = rest.split_once('`')?.0;
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<CheckReport> {
|
||||
@@ -149,6 +191,10 @@ fn check_active_config(report: &mut CheckReport, cfg: &Config, providers: &Provi
|
||||
report
|
||||
.successes
|
||||
.push(format!("active provider: {}", cfg.provider_id));
|
||||
report.successes.push(format!(
|
||||
"active provider base URL: {}",
|
||||
cfg.active_provider.base_url
|
||||
));
|
||||
report
|
||||
.successes
|
||||
.push(format!("active model: {}", cfg.model));
|
||||
|
||||
@@ -44,6 +44,8 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Validate Cass config files.
|
||||
Check,
|
||||
/// Configure an OpenAI-compatible provider and first model.
|
||||
Setup,
|
||||
}
|
||||
|
||||
pub fn parse() -> Cli {
|
||||
|
||||
@@ -7,9 +7,11 @@ pub mod config;
|
||||
pub mod conversation;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod menu;
|
||||
pub mod prompt;
|
||||
pub mod providers;
|
||||
pub mod security;
|
||||
pub mod setup;
|
||||
pub mod tools;
|
||||
pub mod ui;
|
||||
|
||||
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
use anyhow::{bail, Result};
|
||||
use crossterm::cursor::{Hide, MoveToColumn, MoveUp, Show};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor};
|
||||
use crossterm::terminal::{self, Clear, ClearType};
|
||||
use crossterm::{execute, queue};
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{self, Write};
|
||||
|
||||
const MAX_VISIBLE_ITEMS: usize = 12;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MenuItem {
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_detail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu {
|
||||
title: String,
|
||||
items: Vec<MenuItem>,
|
||||
visible_items: usize,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
pub fn new(title: impl Into<String>, items: Vec<MenuItem>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
items,
|
||||
visible_items: MAX_VISIBLE_ITEMS,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_visible_items(mut self, visible_items: usize) -> Self {
|
||||
self.visible_items = visible_items.max(1);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn select_one(&self, initial: usize) -> Result<usize> {
|
||||
if self.items.is_empty() {
|
||||
bail!("menu has no items");
|
||||
}
|
||||
let mut session = MenuSession::enter()?;
|
||||
let mut highlighted = initial.min(self.items.len() - 1);
|
||||
let mut top = 0usize;
|
||||
let mut footer = String::new();
|
||||
adjust_view(&mut top, highlighted, self.visible_items, self.items.len());
|
||||
|
||||
loop {
|
||||
session.render(self, highlighted, top, None, &footer)?;
|
||||
footer.clear();
|
||||
match event::read()? {
|
||||
Event::Key(key)
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& key.code == KeyCode::Char('c') =>
|
||||
{
|
||||
bail!("menu cancelled");
|
||||
}
|
||||
Event::Key(key) => match key.code {
|
||||
KeyCode::Esc => bail!("menu cancelled"),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
highlighted = highlighted.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
highlighted = (highlighted + 1).min(self.items.len() - 1);
|
||||
}
|
||||
KeyCode::Home => highlighted = 0,
|
||||
KeyCode::End => highlighted = self.items.len() - 1,
|
||||
KeyCode::PageUp => {
|
||||
highlighted = highlighted.saturating_sub(self.visible_items);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
highlighted = (highlighted + self.visible_items).min(self.items.len() - 1);
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => return Ok(highlighted),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
adjust_view(&mut top, highlighted, self.visible_items, self.items.len());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_many(
|
||||
&self,
|
||||
initially_selected: &BTreeSet<usize>,
|
||||
require_one: bool,
|
||||
) -> Result<Vec<usize>> {
|
||||
if self.items.is_empty() {
|
||||
bail!("menu has no items");
|
||||
}
|
||||
let mut session = MenuSession::enter()?;
|
||||
let mut highlighted = 0usize;
|
||||
let mut top = 0usize;
|
||||
let mut selected: BTreeSet<usize> = initially_selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|idx| *idx < self.items.len())
|
||||
.collect();
|
||||
let mut footer = String::new();
|
||||
|
||||
loop {
|
||||
session.render(self, highlighted, top, Some(&selected), &footer)?;
|
||||
footer.clear();
|
||||
match event::read()? {
|
||||
Event::Key(key)
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& key.code == KeyCode::Char('c') =>
|
||||
{
|
||||
bail!("menu cancelled");
|
||||
}
|
||||
Event::Key(key) => match key.code {
|
||||
KeyCode::Esc => bail!("menu cancelled"),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
highlighted = highlighted.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
highlighted = (highlighted + 1).min(self.items.len() - 1);
|
||||
}
|
||||
KeyCode::Home => highlighted = 0,
|
||||
KeyCode::End => highlighted = self.items.len() - 1,
|
||||
KeyCode::PageUp => {
|
||||
highlighted = highlighted.saturating_sub(self.visible_items);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
highlighted = (highlighted + self.visible_items).min(self.items.len() - 1);
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if !selected.insert(highlighted) {
|
||||
selected.remove(&highlighted);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if require_one && selected.is_empty() {
|
||||
footer =
|
||||
"Select at least one item with Space, then press Enter.".into();
|
||||
} else {
|
||||
return Ok(selected.into_iter().collect());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
adjust_view(&mut top, highlighted, self.visible_items, self.items.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn adjust_view(top: &mut usize, highlighted: usize, visible_items: usize, len: usize) {
|
||||
let visible_items = visible_items.max(1).min(len.max(1));
|
||||
if highlighted < *top {
|
||||
*top = highlighted;
|
||||
} else if highlighted >= *top + visible_items {
|
||||
*top = highlighted + 1 - visible_items;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextPrompt {
|
||||
title: String,
|
||||
default: Option<String>,
|
||||
required: bool,
|
||||
}
|
||||
|
||||
impl TextPrompt {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
default: None,
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_default(mut self, default: impl Into<String>) -> Self {
|
||||
self.default = Some(default.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn required(mut self, required: bool) -> Self {
|
||||
self.required = required;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn prompt(&self) -> Result<String> {
|
||||
let mut session = TextSession::enter()?;
|
||||
let mut value = self.default.clone().unwrap_or_default();
|
||||
let mut footer = String::new();
|
||||
|
||||
loop {
|
||||
session.render(self, &value, &footer)?;
|
||||
footer.clear();
|
||||
match event::read()? {
|
||||
Event::Key(key)
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& key.code == KeyCode::Char('c') =>
|
||||
{
|
||||
bail!("prompt cancelled");
|
||||
}
|
||||
Event::Key(key)
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& key.code == KeyCode::Char('u') =>
|
||||
{
|
||||
value.clear();
|
||||
}
|
||||
Event::Key(key) => match key.code {
|
||||
KeyCode::Esc => bail!("prompt cancelled"),
|
||||
KeyCode::Enter => {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
session.finish()?;
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
if let Some(default) = &self.default {
|
||||
session.finish()?;
|
||||
return Ok(default.clone());
|
||||
}
|
||||
if self.required {
|
||||
footer = "Value is required.".into();
|
||||
} else {
|
||||
session.finish()?;
|
||||
return Ok(String::new());
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
value.pop();
|
||||
}
|
||||
KeyCode::Char(ch) => {
|
||||
if !key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
value.push(ch);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TextSession {
|
||||
rendered_lines: u16,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl TextSession {
|
||||
fn enter() -> Result<Self> {
|
||||
terminal::enable_raw_mode()?;
|
||||
execute!(io::stdout(), Show)?;
|
||||
Ok(Self {
|
||||
rendered_lines: 0,
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn render(&mut self, prompt: &TextPrompt, value: &str, footer: &str) -> Result<()> {
|
||||
let mut out = io::stdout();
|
||||
if self.rendered_lines > 0 {
|
||||
let lines_up = self.rendered_lines.saturating_sub(1);
|
||||
if lines_up > 0 {
|
||||
queue!(out, MoveUp(lines_up))?;
|
||||
}
|
||||
queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown))?;
|
||||
} else {
|
||||
queue!(out, MoveToColumn(0))?;
|
||||
}
|
||||
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::Cyan),
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print("? "),
|
||||
ResetColor,
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print(&prompt.title),
|
||||
SetAttribute(Attribute::Reset),
|
||||
Print("\r\n")
|
||||
)?;
|
||||
|
||||
let help = if !footer.is_empty() {
|
||||
format!(" ! {footer}")
|
||||
} else if prompt.default.is_some() {
|
||||
" default prefilled · Enter submit · Ctrl-U clear · Esc cancel".to_string()
|
||||
} else {
|
||||
" Enter submit · Esc cancel".to_string()
|
||||
};
|
||||
let help_color = if footer.is_empty() {
|
||||
Color::DarkGrey
|
||||
} else {
|
||||
Color::Yellow
|
||||
};
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(help_color),
|
||||
Print(help),
|
||||
ResetColor,
|
||||
Print("\r\n"),
|
||||
SetForegroundColor(Color::Cyan),
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print(" › "),
|
||||
ResetColor,
|
||||
SetAttribute(Attribute::Reset),
|
||||
Print(value)
|
||||
)?;
|
||||
|
||||
out.flush()?;
|
||||
self.rendered_lines = 3;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<()> {
|
||||
if !self.finished {
|
||||
let mut out = io::stdout();
|
||||
queue!(out, Print("\r\n"))?;
|
||||
out.flush()?;
|
||||
self.finished = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TextSession {
|
||||
fn drop(&mut self) {
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuSession {
|
||||
rendered_lines: u16,
|
||||
}
|
||||
|
||||
impl MenuSession {
|
||||
fn enter() -> Result<Self> {
|
||||
terminal::enable_raw_mode()?;
|
||||
execute!(io::stdout(), Hide)?;
|
||||
Ok(Self { rendered_lines: 0 })
|
||||
}
|
||||
|
||||
fn render(
|
||||
&mut self,
|
||||
menu: &Menu,
|
||||
highlighted: usize,
|
||||
top: usize,
|
||||
selected: Option<&BTreeSet<usize>>,
|
||||
footer: &str,
|
||||
) -> Result<()> {
|
||||
let mut out = io::stdout();
|
||||
if self.rendered_lines > 0 {
|
||||
queue!(
|
||||
out,
|
||||
MoveUp(self.rendered_lines),
|
||||
MoveToColumn(0),
|
||||
Clear(ClearType::FromCursorDown)
|
||||
)?;
|
||||
} else {
|
||||
queue!(out, MoveToColumn(0))?;
|
||||
}
|
||||
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::Cyan),
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print("? "),
|
||||
ResetColor,
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print(&menu.title),
|
||||
SetAttribute(Attribute::Reset),
|
||||
Print("\r\n")
|
||||
)?;
|
||||
let help = if selected.is_some() {
|
||||
" ↑/↓ move · Space toggle · Enter submit · Esc cancel"
|
||||
} else {
|
||||
" ↑/↓ move · Enter submit · Esc cancel"
|
||||
};
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print(help),
|
||||
ResetColor,
|
||||
Print("\r\n")
|
||||
)?;
|
||||
|
||||
let len = menu.items.len();
|
||||
let visible = menu.visible_items.max(1).min(len);
|
||||
let end = (top + visible).min(len);
|
||||
let mut lines = 2u16;
|
||||
|
||||
if top > 0 {
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print(format!(" ↑ {} more\r\n", top)),
|
||||
ResetColor
|
||||
)?;
|
||||
lines += 1;
|
||||
}
|
||||
|
||||
for idx in top..end {
|
||||
let item = &menu.items[idx];
|
||||
let is_highlighted = idx == highlighted;
|
||||
if is_highlighted {
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::Cyan),
|
||||
SetAttribute(Attribute::Bold),
|
||||
Print(" › ")
|
||||
)?;
|
||||
} else {
|
||||
queue!(out, Print(" "))?;
|
||||
}
|
||||
|
||||
if let Some(selected) = selected {
|
||||
let mark = if selected.contains(&idx) {
|
||||
"[x] "
|
||||
} else {
|
||||
"[ ] "
|
||||
};
|
||||
queue!(out, Print(mark))?;
|
||||
}
|
||||
|
||||
queue!(out, Print(&item.label))?;
|
||||
if let Some(detail) = &item.detail {
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print(" — "),
|
||||
Print(detail),
|
||||
ResetColor
|
||||
)?;
|
||||
if is_highlighted {
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::Cyan),
|
||||
SetAttribute(Attribute::Bold)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
queue!(
|
||||
out,
|
||||
SetAttribute(Attribute::Reset),
|
||||
ResetColor,
|
||||
Print("\r\n")
|
||||
)?;
|
||||
lines += 1;
|
||||
}
|
||||
|
||||
if end < len {
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
Print(format!(" ↓ {} more\r\n", len - end)),
|
||||
ResetColor
|
||||
)?;
|
||||
lines += 1;
|
||||
}
|
||||
|
||||
if !footer.is_empty() {
|
||||
queue!(
|
||||
out,
|
||||
SetForegroundColor(Color::Yellow),
|
||||
Print(" ! "),
|
||||
Print(footer),
|
||||
ResetColor,
|
||||
Print("\r\n")
|
||||
)?;
|
||||
lines += 1;
|
||||
}
|
||||
|
||||
out.flush()?;
|
||||
self.rendered_lines = lines;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MenuSession {
|
||||
fn drop(&mut self) {
|
||||
let mut out = io::stdout();
|
||||
if self.rendered_lines > 0 {
|
||||
let _ = queue!(
|
||||
out,
|
||||
MoveUp(self.rendered_lines),
|
||||
MoveToColumn(0),
|
||||
Clear(ClearType::FromCursorDown)
|
||||
);
|
||||
}
|
||||
let _ = execute!(out, Show);
|
||||
let _ = out.flush();
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
||||
+742
@@ -0,0 +1,742 @@
|
||||
use crate::check;
|
||||
use crate::cli::Cli;
|
||||
use crate::config::{
|
||||
self, ConfigFile, ModelDefinition, ModelsFile, ProviderDefinition, ProvidersFile,
|
||||
ReasoningEffort, ReasoningMetadata, ReasoningRequestFormat, DEFAULT_PROVIDER_KIND,
|
||||
};
|
||||
use crate::menu::{Menu, MenuItem, TextPrompt};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::io::{self, IsTerminal};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SetupMode {
|
||||
Explicit,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SetupOutcome {
|
||||
pub start_session: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderCatalogEntry {
|
||||
pub name: &'static str,
|
||||
pub id: &'static str,
|
||||
pub base_url: &'static str,
|
||||
pub api_key_env: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetupSelection {
|
||||
pub provider_id: String,
|
||||
pub provider_name: String,
|
||||
pub base_url: String,
|
||||
pub api_key_env: String,
|
||||
pub model_id: String,
|
||||
pub supports_tools: bool,
|
||||
pub supports_reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsResponse {
|
||||
data: Vec<ModelItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelItem {
|
||||
id: String,
|
||||
}
|
||||
|
||||
fn print_banner() {
|
||||
println!("Cassady setup");
|
||||
println!("Configure 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)));
|
||||
}
|
||||
|
||||
fn key_value(label: &str, value: &str) {
|
||||
println!(" {label:<12} {value}");
|
||||
}
|
||||
|
||||
fn info(message: impl AsRef<str>) {
|
||||
println!(" → {}", message.as_ref());
|
||||
}
|
||||
|
||||
fn success(message: impl AsRef<str>) {
|
||||
println!(" ✓ {}", message.as_ref());
|
||||
}
|
||||
|
||||
fn warn(message: impl AsRef<str>) {
|
||||
print_wrapped(" ! ", message.as_ref());
|
||||
}
|
||||
|
||||
fn hint(message: impl AsRef<str>) {
|
||||
print_wrapped(" ", message.as_ref());
|
||||
}
|
||||
|
||||
fn print_wrapped(prefix: &str, text: &str) {
|
||||
let width = crossterm::terminal::size()
|
||||
.map(|(width, _)| width as usize)
|
||||
.unwrap_or(100)
|
||||
.clamp(40, 140);
|
||||
let available = width.saturating_sub(prefix.chars().count()).max(20);
|
||||
|
||||
for line in text.lines() {
|
||||
let mut current = String::new();
|
||||
for word in line.split_whitespace() {
|
||||
let next_len = if current.is_empty() {
|
||||
word.chars().count()
|
||||
} else {
|
||||
current.chars().count() + 1 + word.chars().count()
|
||||
};
|
||||
if next_len > available && !current.is_empty() {
|
||||
println!("{prefix}{current}");
|
||||
current.clear();
|
||||
}
|
||||
if !current.is_empty() {
|
||||
current.push(' ');
|
||||
}
|
||||
current.push_str(word);
|
||||
}
|
||||
if current.is_empty() {
|
||||
println!("{prefix}");
|
||||
} else {
|
||||
println!("{prefix}{current}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider_catalog() -> Vec<ProviderCatalogEntry> {
|
||||
vec![
|
||||
ProviderCatalogEntry {
|
||||
name: "OpenAI",
|
||||
id: "openai",
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key_env: "OPENAI_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "xAI",
|
||||
id: "xai",
|
||||
base_url: "https://api.x.ai/v1",
|
||||
api_key_env: "XAI_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "Fireworks",
|
||||
id: "fireworks",
|
||||
base_url: "https://api.fireworks.ai/inference/v1",
|
||||
api_key_env: "FIREWORKS_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "Groq",
|
||||
id: "groq",
|
||||
base_url: "https://api.groq.com/openai/v1",
|
||||
api_key_env: "GROQ_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "OpenRouter",
|
||||
id: "openrouter",
|
||||
base_url: "https://openrouter.ai/api/v1",
|
||||
api_key_env: "OPENROUTER_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "OpenCode Zen",
|
||||
id: "opencode-zen",
|
||||
base_url: "https://opencode.ai/zen/v1",
|
||||
api_key_env: "OPENCODE_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "OpenCode Go",
|
||||
id: "opencode-go",
|
||||
base_url: "https://opencode.ai/zen/go/v1",
|
||||
api_key_env: "OPENCODE_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "Cerebras",
|
||||
id: "cerebras",
|
||||
base_url: "https://api.cerebras.ai/v1",
|
||||
api_key_env: "CEREBRAS_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "Novita",
|
||||
id: "novita",
|
||||
base_url: "https://api.novita.ai/v3/openai",
|
||||
api_key_env: "NOVITA_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "Together",
|
||||
id: "together",
|
||||
base_url: "https://api.together.xyz/v1",
|
||||
api_key_env: "TOGETHER_API_KEY",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
|
||||
let root = config::cass_root();
|
||||
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");
|
||||
}
|
||||
|
||||
print_banner();
|
||||
|
||||
match mode {
|
||||
SetupMode::Explicit => {
|
||||
if existing_setup_files(&root)
|
||||
&& !ask_yes_no(
|
||||
"Update your active provider/model while preserving unrelated entries?",
|
||||
false,
|
||||
)?
|
||||
{
|
||||
println!("Setup cancelled.");
|
||||
return Ok(SetupOutcome {
|
||||
start_session: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
SetupMode::Auto => {
|
||||
println!();
|
||||
hint("Cassady needs this before starting your first chat.");
|
||||
if !ask_yes_no("Start setup now?", true)? {
|
||||
println!("Run `cass setup` when you are ready.");
|
||||
return Ok(SetupOutcome {
|
||||
start_session: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let providers = choose_providers()?;
|
||||
let total_providers = providers.len();
|
||||
let mut selections = Vec::new();
|
||||
|
||||
for (idx, provider) in providers.into_iter().enumerate() {
|
||||
let configured = configure_provider(provider, idx + 1, total_providers).await?;
|
||||
selections.push(configured.selection);
|
||||
}
|
||||
|
||||
let active_index = choose_active_provider(&selections)?;
|
||||
let active_api_key_env = selections[active_index].api_key_env.clone();
|
||||
apply_setups(&root, &selections, active_index)?;
|
||||
|
||||
let report = check::run(cli)?;
|
||||
if report.has_errors() {
|
||||
if std::env::var(&active_api_key_env).is_err() {
|
||||
section("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");
|
||||
print!("{}", report.render());
|
||||
hint("Run `cass setup` to try again or edit ~/.cass/config.json manually.");
|
||||
}
|
||||
return Ok(SetupOutcome {
|
||||
start_session: false,
|
||||
});
|
||||
}
|
||||
|
||||
section("Setup complete");
|
||||
success("Configuration saved and validated");
|
||||
info("Starting your first Cassady session…");
|
||||
Ok(SetupOutcome {
|
||||
start_session: true,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChosenProvider {
|
||||
name: String,
|
||||
id: String,
|
||||
base_url: String,
|
||||
api_key_env: String,
|
||||
}
|
||||
|
||||
fn choose_providers() -> Result<Vec<ChosenProvider>> {
|
||||
let catalog = provider_catalog();
|
||||
let mut items: Vec<MenuItem> = catalog
|
||||
.iter()
|
||||
.map(|entry| MenuItem::with_detail(entry.name, entry.base_url))
|
||||
.collect();
|
||||
items.push(MenuItem::with_detail(
|
||||
"Custom OpenAI-compatible provider",
|
||||
"enter your own base URL",
|
||||
));
|
||||
|
||||
let selected = Menu::new("Choose the providers you want to configure", items)
|
||||
.select_many(&BTreeSet::new(), true)?;
|
||||
|
||||
let mut providers = Vec::new();
|
||||
for idx in selected {
|
||||
if idx < catalog.len() {
|
||||
let entry = &catalog[idx];
|
||||
providers.push(ChosenProvider {
|
||||
name: entry.name.to_string(),
|
||||
id: entry.id.to_string(),
|
||||
base_url: entry.base_url.to_string(),
|
||||
api_key_env: entry.api_key_env.to_string(),
|
||||
});
|
||||
} else {
|
||||
providers.push(choose_custom_provider()?);
|
||||
while choose_bool("Add another custom provider?", false)? {
|
||||
providers.push(choose_custom_provider()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let names = providers
|
||||
.iter()
|
||||
.map(|provider| provider.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
success(format!("Providers selected: {names}"));
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
fn choose_custom_provider() -> Result<ChosenProvider> {
|
||||
section("Custom provider");
|
||||
let name = ask_required("Provider name")?;
|
||||
let id = loop {
|
||||
let value = ask_required("Provider id")?;
|
||||
match validate_provider_id(&value) {
|
||||
Ok(()) => break value,
|
||||
Err(err) => warn(err.to_string()),
|
||||
}
|
||||
};
|
||||
let base_url = loop {
|
||||
let value = ask_required("Base URL")?;
|
||||
match validate_base_url(&value) {
|
||||
Ok(()) => break value,
|
||||
Err(err) => warn(err.to_string()),
|
||||
}
|
||||
};
|
||||
let api_key_env = ask_required("API key environment variable")?;
|
||||
Ok(ChosenProvider {
|
||||
name,
|
||||
id,
|
||||
base_url,
|
||||
api_key_env,
|
||||
})
|
||||
}
|
||||
|
||||
struct ConfiguredProvider {
|
||||
selection: SetupSelection,
|
||||
}
|
||||
|
||||
async fn configure_provider(
|
||||
provider: ChosenProvider,
|
||||
index: usize,
|
||||
total: usize,
|
||||
) -> Result<ConfiguredProvider> {
|
||||
section(&format!("Provider {index}/{total}: {}", provider.name));
|
||||
key_value("id", &provider.id);
|
||||
key_value("endpoint", &provider.base_url);
|
||||
|
||||
let api_key_env = ask_default("API key environment variable", &provider.api_key_env)?;
|
||||
if !looks_like_env_var(&api_key_env) {
|
||||
warn(format!(
|
||||
"`{api_key_env}` is an unusual environment variable name. Continuing."
|
||||
));
|
||||
}
|
||||
let api_key = match std::env::var(&api_key_env) {
|
||||
Ok(value) if !value.is_empty() => {
|
||||
success(format!("{api_key_env} is set"));
|
||||
Some(value)
|
||||
}
|
||||
_ => {
|
||||
warn(format!(
|
||||
"{api_key_env} is not set in this shell. Setup can still be saved."
|
||||
));
|
||||
hint(format!("Later, run: export {api_key_env}=..."));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let model_id = choose_model(&provider, api_key.as_deref()).await?;
|
||||
|
||||
section("Model capabilities");
|
||||
key_value("model", &model_id);
|
||||
let supports_tools = choose_bool("Does this model support tool calls?", true)?;
|
||||
if supports_tools {
|
||||
success("Tool calls enabled");
|
||||
} else {
|
||||
warn("Cassady works best with models that support tool calls.");
|
||||
}
|
||||
let supports_reasoning =
|
||||
choose_bool("Does this model support reasoning effort controls?", true)?;
|
||||
if supports_reasoning {
|
||||
success("Reasoning controls enabled");
|
||||
}
|
||||
|
||||
Ok(ConfiguredProvider {
|
||||
selection: SetupSelection {
|
||||
provider_id: provider.id,
|
||||
provider_name: provider.name,
|
||||
base_url: provider.base_url,
|
||||
api_key_env,
|
||||
model_id,
|
||||
supports_tools,
|
||||
supports_reasoning,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn choose_active_provider(selections: &[SetupSelection]) -> Result<usize> {
|
||||
if selections.is_empty() {
|
||||
bail!("no providers selected");
|
||||
}
|
||||
if selections.len() == 1 {
|
||||
success(format!(
|
||||
"Active provider: {} ({})",
|
||||
selections[0].provider_name, selections[0].model_id
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let items = selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
MenuItem::with_detail(
|
||||
selection.provider_name.clone(),
|
||||
format!("{} · {}", selection.provider_id, selection.model_id),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let choice = Menu::new("Which provider should Cass use first?", items).select_one(0)?;
|
||||
success(format!(
|
||||
"Active provider: {} ({})",
|
||||
selections[choice].provider_name, selections[choice].model_id
|
||||
));
|
||||
Ok(choice)
|
||||
}
|
||||
|
||||
async fn choose_model(provider: &ChosenProvider, api_key: Option<&str>) -> Result<String> {
|
||||
section("Model");
|
||||
|
||||
let Some(api_key) = api_key else {
|
||||
warn("Model discovery was skipped because the API key is not available in this shell.");
|
||||
hint("Enter the model id manually now; Cassady will use it after the key is exported.");
|
||||
return ask_manual_model();
|
||||
};
|
||||
|
||||
loop {
|
||||
info(format!("Fetching models from {}…", provider.name));
|
||||
match discover_models(&provider.base_url, api_key).await {
|
||||
Ok(models) if !models.is_empty() => return choose_discovered_model(models),
|
||||
Ok(_) => {
|
||||
warn(format!("{} returned an empty model list.", provider.name));
|
||||
}
|
||||
Err(err) => {
|
||||
warn(format!("Could not fetch models from {}.", provider.name));
|
||||
hint(err.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match choose_model_discovery_fallback()? {
|
||||
ModelDiscoveryFallback::Retry => continue,
|
||||
ModelDiscoveryFallback::Manual => return ask_manual_model(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModelDiscoveryFallback {
|
||||
Retry,
|
||||
Manual,
|
||||
}
|
||||
|
||||
fn choose_model_discovery_fallback() -> Result<ModelDiscoveryFallback> {
|
||||
let choice = Menu::new(
|
||||
"Model list could not be loaded. What would you like to do?",
|
||||
vec![
|
||||
MenuItem::with_detail("Retry model discovery", "try GET /models again"),
|
||||
MenuItem::with_detail(
|
||||
"Enter model id manually",
|
||||
"continue without provider model list",
|
||||
),
|
||||
],
|
||||
)
|
||||
.select_one(0)?;
|
||||
|
||||
Ok(match choice {
|
||||
0 => ModelDiscoveryFallback::Retry,
|
||||
_ => ModelDiscoveryFallback::Manual,
|
||||
})
|
||||
}
|
||||
|
||||
fn choose_discovered_model(mut discovered: Vec<String>) -> Result<String> {
|
||||
success(format!("Found {} models", discovered.len()));
|
||||
discovered.sort();
|
||||
discovered.dedup();
|
||||
let mut items: Vec<MenuItem> = discovered
|
||||
.iter()
|
||||
.map(|model| MenuItem::new(model.clone()))
|
||||
.collect();
|
||||
items.push(MenuItem::with_detail(
|
||||
"Enter model id manually",
|
||||
"use this if the model is not listed",
|
||||
));
|
||||
|
||||
let choice = Menu::new("Choose your first model", items)
|
||||
.with_visible_items(14)
|
||||
.select_one(0)?;
|
||||
if choice == discovered.len() {
|
||||
ask_manual_model()
|
||||
} else {
|
||||
let model = discovered[choice].clone();
|
||||
success(format!("Model selected: {model}"));
|
||||
Ok(model)
|
||||
}
|
||||
}
|
||||
|
||||
fn ask_manual_model() -> Result<String> {
|
||||
let model = ask_required("Model id")?;
|
||||
success(format!("Model selected: {model}"));
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
pub async fn discover_models(base_url: &str, api_key: &str) -> Result<Vec<String>> {
|
||||
let client = Client::builder().timeout(Duration::from_secs(15)).build()?;
|
||||
let url = format!("{}/models", base_url.trim_end_matches('/'));
|
||||
let resp = client.get(url).bearer_auth(api_key).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("provider returned {status}: {text}");
|
||||
}
|
||||
let body: ModelsResponse = resp.json().await?;
|
||||
Ok(body
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|item| item.id.trim().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn apply_setup(root: &Path, selection: &SetupSelection) -> Result<()> {
|
||||
apply_setups(root, std::slice::from_ref(selection), 0)
|
||||
}
|
||||
|
||||
pub fn apply_setups(root: &Path, selections: &[SetupSelection], active_index: usize) -> Result<()> {
|
||||
if selections.is_empty() {
|
||||
bail!("at least one provider must be configured");
|
||||
}
|
||||
let Some(active) = selections.get(active_index) else {
|
||||
bail!("active provider selection is out of range");
|
||||
};
|
||||
for selection in selections {
|
||||
validate_provider_id(&selection.provider_id)?;
|
||||
validate_base_url(&selection.base_url)?;
|
||||
if selection.api_key_env.trim().is_empty() {
|
||||
bail!("API key environment variable must not be empty");
|
||||
}
|
||||
if selection.model_id.trim().is_empty() {
|
||||
bail!("model 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)?;
|
||||
|
||||
for selection in selections {
|
||||
upsert_provider(&mut providers, selection);
|
||||
upsert_model(&mut models, selection);
|
||||
}
|
||||
|
||||
config_file.default_provider = Some(active.provider_id.clone());
|
||||
config_file.default_model = Some(active.model_id.clone());
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn upsert_provider(providers: &mut ProvidersFile, selection: &SetupSelection) {
|
||||
let new_entry = ProviderDefinition {
|
||||
id: selection.provider_id.clone(),
|
||||
name: Some(selection.provider_name.clone()),
|
||||
kind: DEFAULT_PROVIDER_KIND.to_string(),
|
||||
base_url: selection.base_url.clone(),
|
||||
api_key: format!("${}", selection.api_key_env),
|
||||
default_model: Some(selection.model_id.clone()),
|
||||
models: vec![selection.model_id.clone()],
|
||||
};
|
||||
|
||||
if let Some(existing) = providers
|
||||
.providers
|
||||
.iter_mut()
|
||||
.find(|provider| provider.id == selection.provider_id)
|
||||
{
|
||||
let mut model_ids: BTreeSet<String> = existing.models.iter().cloned().collect();
|
||||
model_ids.insert(selection.model_id.clone());
|
||||
*existing = ProviderDefinition {
|
||||
models: model_ids.into_iter().collect(),
|
||||
..new_entry
|
||||
};
|
||||
} else {
|
||||
providers.providers.push(new_entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_model(models: &mut ModelsFile, selection: &SetupSelection) {
|
||||
let model = ModelDefinition {
|
||||
id: selection.model_id.clone(),
|
||||
provider: selection.provider_id.clone(),
|
||||
display_name: None,
|
||||
context_length: None,
|
||||
max_output_tokens: None,
|
||||
supports_tools: selection.supports_tools,
|
||||
supports_streaming: true,
|
||||
reasoning: ReasoningMetadata {
|
||||
supported: selection.supports_reasoning,
|
||||
required: false,
|
||||
default_effort: if selection.supports_reasoning {
|
||||
ReasoningEffort::Medium
|
||||
} else {
|
||||
ReasoningEffort::Off
|
||||
},
|
||||
request_format: ReasoningRequestFormat::ReasoningEffort,
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(existing) = models.models.iter_mut().find(|existing| {
|
||||
existing.provider == selection.provider_id && existing.id == selection.model_id
|
||||
}) {
|
||||
*existing = model;
|
||||
} else {
|
||||
models.models.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_config_or_default(root: &Path) -> Result<ConfigFile> {
|
||||
Ok(config::load_config_file(root)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn load_providers_or_empty(root: &Path) -> Result<ProvidersFile> {
|
||||
let path = config::providers_path(root);
|
||||
if !path.exists() {
|
||||
return Ok(ProvidersFile { providers: vec![] });
|
||||
}
|
||||
let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))
|
||||
}
|
||||
|
||||
fn load_models_or_empty(root: &Path) -> Result<ModelsFile> {
|
||||
let path = config::models_path(root);
|
||||
if !path.exists() {
|
||||
return Ok(ModelsFile { models: vec![] });
|
||||
}
|
||||
let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))
|
||||
}
|
||||
|
||||
fn validate_provider_id(id: &str) -> Result<()> {
|
||||
if id.trim().is_empty() {
|
||||
bail!("provider id must not be empty");
|
||||
}
|
||||
if !id.chars().all(|ch| {
|
||||
ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-' || ch == '.'
|
||||
}) {
|
||||
bail!("provider id may contain only lowercase letters, numbers, `_`, `-`, and `.`");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_base_url(base_url: &str) -> Result<()> {
|
||||
let url = reqwest::Url::parse(base_url).context("base URL must be an absolute URL")?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => Ok(()),
|
||||
other => bail!("base URL must use http or https, not `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_env_var(value: &str) -> bool {
|
||||
let mut chars = value.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first.is_ascii_alphabetic() || first == '_')
|
||||
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
||||
}
|
||||
|
||||
pub fn needs_initial_setup(root: &Path) -> bool {
|
||||
let config_exists = config::config_path(root).exists();
|
||||
let providers_exists = config::providers_path(root).exists();
|
||||
let models_exists = config::models_path(root).exists();
|
||||
|
||||
if !config_exists && !providers_exists && !models_exists {
|
||||
return true;
|
||||
}
|
||||
if config_exists {
|
||||
return false;
|
||||
}
|
||||
if !providers_exists || !models_exists {
|
||||
return true;
|
||||
}
|
||||
|
||||
providers_are_default(root).unwrap_or(false) && models_are_default(root).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn providers_are_default(root: &Path) -> Result<bool> {
|
||||
let providers = load_providers_or_empty(root)?;
|
||||
let default = config::default_provider_definition();
|
||||
Ok(providers.providers.len() == 1
|
||||
&& providers.providers[0].id == default.id
|
||||
&& providers.providers[0].kind == default.kind
|
||||
&& providers.providers[0].base_url == default.base_url
|
||||
&& providers.providers[0].api_key == default.api_key
|
||||
&& providers.providers[0].default_model == default.default_model
|
||||
&& providers.providers[0].models == default.models)
|
||||
}
|
||||
|
||||
fn models_are_default(root: &Path) -> Result<bool> {
|
||||
let models = load_models_or_empty(root)?;
|
||||
let default = config::default_model_definition();
|
||||
Ok(models.models.len() == 1
|
||||
&& models.models[0].id == default.id
|
||||
&& models.models[0].provider == default.provider)
|
||||
}
|
||||
|
||||
fn existing_setup_files(root: &Path) -> bool {
|
||||
config::providers_path(root).exists()
|
||||
|| config::models_path(root).exists()
|
||||
|| config::config_path(root).exists()
|
||||
}
|
||||
|
||||
fn ask_required(prompt: &str) -> Result<String> {
|
||||
TextPrompt::new(prompt).required(true).prompt()
|
||||
}
|
||||
|
||||
fn ask_default(prompt: &str, default: &str) -> Result<String> {
|
||||
TextPrompt::new(prompt).with_default(default).prompt()
|
||||
}
|
||||
|
||||
fn ask_yes_no(prompt: &str, default_yes: bool) -> Result<bool> {
|
||||
choose_bool(prompt, default_yes)
|
||||
}
|
||||
|
||||
fn choose_bool(prompt: &str, default_yes: bool) -> Result<bool> {
|
||||
let initial = if default_yes { 0 } else { 1 };
|
||||
let choice =
|
||||
Menu::new(prompt, vec![MenuItem::new("Yes"), MenuItem::new("No")]).select_one(initial)?;
|
||||
Ok(choice == 0)
|
||||
}
|
||||
|
||||
fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value)?;
|
||||
fs::write(path, format!("{text}\n")).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use cassady::config::{self, ConfigFile, ModelsFile, ProvidersFile, ReasoningEffort};
|
||||
use cassady::setup::{self, SetupSelection};
|
||||
use tempfile::tempdir;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[test]
|
||||
fn provider_catalog_contains_expected_openai_compatible_providers() {
|
||||
let catalog = setup::provider_catalog();
|
||||
let ids: Vec<_> = catalog.iter().map(|entry| entry.id).collect();
|
||||
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"openai",
|
||||
"xai",
|
||||
"fireworks",
|
||||
"groq",
|
||||
"openrouter",
|
||||
"opencode-zen",
|
||||
"opencode-go",
|
||||
"cerebras",
|
||||
"novita",
|
||||
"together",
|
||||
]
|
||||
);
|
||||
assert!(catalog
|
||||
.iter()
|
||||
.all(|entry| entry.base_url.starts_with("https://")));
|
||||
assert_eq!(
|
||||
catalog
|
||||
.iter()
|
||||
.find(|entry| entry.id == "opencode-zen")
|
||||
.unwrap()
|
||||
.base_url,
|
||||
"https://opencode.ai/zen/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog
|
||||
.iter()
|
||||
.find(|entry| entry.id == "opencode-go")
|
||||
.unwrap()
|
||||
.base_url,
|
||||
"https://opencode.ai/zen/go/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_setup_upserts_selected_provider_model_and_preserves_unrelated_entries() {
|
||||
let root = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("providers.json"),
|
||||
r#"{
|
||||
"providers": [
|
||||
{
|
||||
"id": "existing",
|
||||
"kind": "openai-compatible",
|
||||
"base_url": "https://existing.example/v1",
|
||||
"api_key": "$EXISTING_API_KEY",
|
||||
"default_model": "existing-model",
|
||||
"models": ["existing-model"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("models.json"),
|
||||
r#"{
|
||||
"models": [
|
||||
{
|
||||
"id": "existing-model",
|
||||
"provider": "existing"
|
||||
}
|
||||
]
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("config.json"),
|
||||
r#"{
|
||||
"default_access_mode": "workspace-edit",
|
||||
"show_reasoning": true
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
setup::apply_setup(
|
||||
root.path(),
|
||||
&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,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let providers: ProvidersFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(providers.providers.iter().any(|p| p.id == "existing"));
|
||||
let groq = providers
|
||||
.providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == "groq")
|
||||
.unwrap();
|
||||
assert_eq!(groq.api_key, "$GROQ_API_KEY");
|
||||
assert_eq!(
|
||||
groq.default_model.as_deref(),
|
||||
Some("llama-3.3-70b-versatile")
|
||||
);
|
||||
|
||||
let models: ModelsFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(models
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model.provider == "existing" && model.id == "existing-model"));
|
||||
let model = models
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.provider == "groq" && model.id == "llama-3.3-70b-versatile")
|
||||
.unwrap();
|
||||
assert!(model.supports_tools);
|
||||
assert!(!model.reasoning.supported);
|
||||
assert_eq!(model.reasoning.default_effort, ReasoningEffort::Off);
|
||||
|
||||
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("groq"));
|
||||
assert_eq!(
|
||||
config.default_model.as_deref(),
|
||||
Some("llama-3.3-70b-versatile")
|
||||
);
|
||||
assert!(config.show_reasoning.unwrap());
|
||||
assert_eq!(
|
||||
config.default_access_mode.unwrap().to_string(),
|
||||
"workspace-edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_setups_writes_multiple_providers_and_active_choice() {
|
||||
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 providers: ProvidersFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(providers.providers.len(), 2);
|
||||
assert!(providers
|
||||
.providers
|
||||
.iter()
|
||||
.any(|provider| provider.id == "openai"));
|
||||
assert!(providers
|
||||
.providers
|
||||
.iter()
|
||||
.any(|provider| provider.id == "groq"));
|
||||
|
||||
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("groq"));
|
||||
assert_eq!(
|
||||
config.default_model.as_deref(),
|
||||
Some("llama-3.3-70b-versatile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_initial_setup_detects_empty_and_default_only_roots() {
|
||||
let root = tempdir().unwrap();
|
||||
assert!(setup::needs_initial_setup(root.path()));
|
||||
|
||||
std::fs::write(
|
||||
root.path().join("providers.json"),
|
||||
serde_json::to_string_pretty(&ProvidersFile {
|
||||
providers: vec![config::default_provider_definition()],
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("models.json"),
|
||||
serde_json::to_string_pretty(&ModelsFile {
|
||||
models: vec![config::default_model_definition()],
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(setup::needs_initial_setup(root.path()));
|
||||
|
||||
std::fs::write(
|
||||
root.path().join("config.json"),
|
||||
serde_json::to_string_pretty(&ConfigFile {
|
||||
default_provider: Some("fireworks".into()),
|
||||
default_model: Some(config::DEFAULT_MODEL.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!setup::needs_initial_setup(root.path()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_models_parses_openai_compatible_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.and(header("authorization", "Bearer test-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "model-b"},
|
||||
{"id": "model-a"}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let models = setup::discover_models(&format!("{}/v1", server.uri()), "test-key")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(models, vec!["model-b", "model-a"]);
|
||||
}
|
||||
Reference in New Issue
Block a user