6 Commits
Author SHA1 Message Date
owen 7c84a54e6a Add self-update command
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
2026-06-25 07:50:05 -05:00
owen 71f84c03ce Add Rust embedding API
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
2026-06-25 06:53:13 -05:00
owen 4d021fe2ab Resolve lru advisory for v0.2.5
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
2026-06-25 05:53:53 -05:00
owen 7c8cc9eac5 Adding a security policy 2026-06-25 05:39:37 -05:00
IrrelevantandGitHub 52c879e7c8 Merge pull request #2 from owenqwenstarsky/add-github-issue-templates
Add GitHub issue templates
2026-06-24 20:58:19 -05:00
owen 60dfdf3806 Add GitHub issue templates 2026-06-24 20:57:00 -05:00
27 changed files with 4518 additions and 134 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Bug report
description: Report a reproducible problem with Cassady / cass
title: "bug: "
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug. Please include enough detail for someone to reproduce it.
- type: textarea
id: summary
attributes:
label: Summary
description: What went wrong?
placeholder: Cassady crashes when...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: List the exact commands, key presses, or config changes needed to trigger the issue.
placeholder: |
1. Run `cass ...`
2. Press ...
3. See ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead? Paste errors, logs, or terminal output when useful.
render: text
validations:
required: true
- type: input
id: version
attributes:
label: Cassady version
description: Output of `cass --version` or `cassady --version`.
placeholder: cassady 0.2.x
validations:
required: true
- type: dropdown
id: install
attributes:
label: Install method
options:
- Release archive
- cargo install --git
- cargo install --path / local build
- Other
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- macOS Apple Silicon
- macOS Intel
- Linux x86_64
- Linux ARM64
- Windows x86_64
- Other
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment details
description: Terminal, shell, provider/model, access mode, and anything notable from `cass check`. Do not include API keys.
placeholder: |
Terminal: ...
Shell: ...
Provider/model: ...
Access mode: ...
`cass check`: ...
- type: textarea
id: config
attributes:
label: Relevant configuration
description: Paste sanitized snippets from `~/.cass/config.json`, `providers.json`, or `models.json` if relevant. Remove API keys and secrets.
render: json
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I removed API keys, tokens, and other secrets from this report.
required: true
- label: I searched existing issues for a duplicate.
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions and usage help
url: https://github.com/owenqwenstarsky/cassady/discussions
about: Ask questions, share setup notes, or discuss ideas before filing an issue.
+44
View File
@@ -0,0 +1,44 @@
name: Documentation issue
description: Report missing, confusing, or incorrect documentation
title: "docs: "
labels: [documentation]
body:
- type: textarea
id: location
attributes:
label: Documentation location
description: Link to the page or name the file/section, if known.
placeholder: README.md, docs/providers.md, docs/access-modes.md, ...
validations:
required: true
- type: textarea
id: issue
attributes:
label: What is unclear or incorrect?
description: Describe the gap, mistake, or confusing wording.
validations:
required: true
- type: textarea
id: suggested
attributes:
label: Suggested improvement
description: If you have specific wording or examples in mind, add them here.
- type: dropdown
id: impact
attributes:
label: Impact
options:
- Blocks installation or first use
- Causes incorrect configuration
- Confuses normal usage
- Typo or small polish
- Other
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues for a duplicate.
required: true
@@ -0,0 +1,59 @@
name: Feature request
description: Suggest an improvement or new capability for Cassady / cass
title: "feat: "
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting an improvement. Please focus on the user problem and desired outcome.
- type: textarea
id: problem
attributes:
label: Problem or use case
description: What are you trying to do, and what makes it hard today?
placeholder: I want to...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the change you would like to see.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What workarounds or other designs have you considered?
- type: dropdown
id: area
attributes:
label: Area
options:
- Terminal UI
- Setup and configuration
- Providers and models
- Tools and file editing
- Safety and access modes
- Conversation history and resume
- Documentation
- Packaging and releases
- Other
validations:
required: true
- type: textarea
id: examples
attributes:
label: Examples or mockups
description: Add sample commands, UI text, config snippets, screenshots, or links that clarify the request.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues for related requests.
required: true
- label: This request is in scope for a terminal coding agent, not a general package manager or updater.
required: false
+52
View File
@@ -0,0 +1,52 @@
name: Maintenance task
description: Track internal cleanup, refactoring, testing, CI, or release work
title: "chore: "
labels: [maintenance]
body:
- type: textarea
id: goal
attributes:
label: Goal
description: What should be done, and why?
validations:
required: true
- type: textarea
id: scope
attributes:
label: Scope
description: List concrete files, modules, or workflows that are in scope.
placeholder: |
- src/...
- tests/...
- .github/workflows/...
validations:
required: true
- type: textarea
id: out_of_scope
attributes:
label: Out of scope
description: Note anything this task should intentionally avoid.
- type: textarea
id: acceptance
attributes:
label: Acceptance criteria
description: What must be true before this can be closed?
placeholder: |
- [ ] ...
- [ ] `cargo test --locked --all-targets` passes
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- Refactoring
- Tests
- CI
- Release process
- Dependencies
- Documentation maintenance
- Other
validations:
required: true
Generated
+974 -102
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "cassady"
version = "0.2.4"
version = "0.2.7"
edition = "2021"
description = "Cassady/Cass minimal terminal coding agent"
license = "MIT"
@@ -23,22 +23,26 @@ anyhow = "1"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
crossterm = "0.28"
crossterm = "0.29"
dirs = "5"
futures-util = "0.3"
ignore = "0.4"
include_dir = "0.7"
nanoid = "0.4"
ratatui = "0.28"
ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color"] }
regex = "1"
pulldown-cmark = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
flate2 = "1"
tar = "0.4"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] }
tui-textarea = "0.6"
unicode-width = "0.1"
zip = { version = "2", default-features = false, features = ["deflate"] }
[dev-dependencies]
tempfile = "3"
+22 -2
View File
@@ -8,9 +8,10 @@ The project installs two equivalent commands, `cass` and `cassady`; examples use
- Provider support is OpenAI-compatible chat/completions APIs only.
- The primary interface is an interactive terminal UI.
- v0.2.6 adds an experimental Rust embedding API for headless sessions; it is useful for early integrations but not yet a stable long-term library contract.
- Config and conversation state live under `~/.cass`.
- Windows binaries are built for releases, but deeper Windows terminal, path, shell, and filesystem polish is planned for a later release.
- Cassady is not an installer, updater, or package manager.
- `cass update` can update release-archive installs from official GitHub releases; external package managers should still be updated through their own tools.
## Install from source
@@ -38,6 +39,7 @@ If Cassady cannot resolve a usable provider, model, or API key, it offers to run
```sh
cass setup
cass check
cass update --check
cass
```
@@ -65,9 +67,10 @@ cass --resume <chat-id>
cass --resume
cass check
cass setup
cass update
```
`cass --resume` without an id lists saved chats for the current directory. When Cassady exits a chat, it prints a resume command for that session.
`cass --resume` without an id lists saved chats for the current directory. `cass update` checks official GitHub releases and can update both `cass` and `cassady` in the current install directory. When Cassady exits a chat, it prints a resume command for that session.
Common in-chat commands:
@@ -112,12 +115,29 @@ API key references should usually be written as environment variables such as `"
Detailed bundled docs live in this repository under [`docs/`](docs/README.md) and are installed to `~/.cass/docs` at runtime.
## Experimental Rust embedding API
Rust applications can import Cassady and run headless sessions without launching the TUI:
```rust
use cassady::prelude::*;
let session = SessionBuilder::new()
.cwd(".")
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
```
See [Experimental Rust embedding API](docs/embedding.md) for session creation, streamed events, approval handling, cancellation, and current limitations.
## More documentation
- [Commands](docs/commands.md)
- [Configuration](docs/configuration.md)
- [Providers and models](docs/providers.md)
- [Access modes and tool safety](docs/access-modes.md)
- [Experimental Rust embedding API](docs/embedding.md)
- [Workflows](docs/workflows.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Platform notes](docs/platforms.md)
+69
View File
@@ -1,5 +1,74 @@
# Cassady (Cass) Roadmap
## v0.2.7 — Self-Update Command
This release focuses on making Cassady easy to keep current after installation. The goal is to let users run one clean command, `cass update`, to check GitHub releases, choose the recommended prebuilt binary or a source-build fallback, verify what will be installed, and update both `cass` and `cassady` safely. See `plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md`.
### Update Command Experience
- [x] **Add a polished `cass update` command.** Check the official Cassady GitHub releases, compare the running version to the selected release, and guide the user through an interactive update flow.
- Keep update independent of provider/model setup so it works even when config is missing or invalid.
- Support script-friendly checks with flags such as `--check`, `--dry-run`, and `--yes`.
- [x] **Select the right update path.** Prefer a matching prebuilt release archive when available, with explicit `--prebuilt` and `--source` modes for users who want to choose.
- Support the same macOS, Linux, and Windows targets used by Cassady releases.
- Offer source builds for unsupported targets or users who prefer building locally.
### Safe Installation
- [x] **Verify and stage prebuilt artifacts before replacing binaries.** Download archives and SHA-256 files from the release, verify checksums, extract safely, and validate staged `cass`/`cassady` binaries.
- Reject checksum mismatches, unsafe archive paths, missing binaries, and unexpected versions.
- Show clear progress and failure messages without dumping raw implementation details.
- [x] **Replace installed binaries cleanly.** Update the current binary and same-directory companion binary when possible, using backups and rollback on failure.
- Do not auto-run `sudo` or administrator prompts.
- Handle Windows executable replacement with a staged helper or documented manual fallback if necessary.
### Source Build Fallback and Documentation
- [x] **Build from release source when requested.** Download the selected release source, validate its version, check for Rust tooling, run a locked release build, and install the resulting local binaries.
- Keep source mode tied to release tags rather than arbitrary branches.
- Do not attempt cross-compilation or Rust toolchain installation in this release.
- [x] **Document and test update behavior.** Update README and bundled docs for `cass update`, platform notes, troubleshooting, and package-manager caveats.
- Add tests for release parsing, target mapping, asset selection, checksum validation, archive extraction safety, install planning, and mocked update flows.
## v0.2.6 — Rust Embedding API ✅ Completed
This release focuses on adding the first intentional Rust library surface for embedding Cassady in other Rust projects. The goal is to provide the bones for programmatic, headless agent sessions: configure a workspace, start or resume an agent session, send turns, stream typed events, and handle approvals without launching the TUI. See `plans/V0_2_6_RUST_EMBEDDING_API_PLAN.md`.
### Experimental Public API
- [x] **Add a supported embedding module.** Provide a small `cassady::embedding` API with builder, session, turn, event, approval, and error types so callers do not need to stitch together internal modules directly.
- Mark the API experimental for v0.2.6 rather than promising long-term semver stability.
- Keep existing CLI/TUI behavior unchanged while steering library users toward the new module.
- [x] **Support host-configured agent sessions.** Let Rust callers create or resume headless sessions with explicit cwd, access mode, model/provider overrides, reasoning effort, and Cassady config root.
- Reuse existing config files, global instructions, bundled docs, security policy, and JSONL conversation storage.
- Avoid requiring callers to construct CLI-specific types.
### Headless Turn Execution
- [x] **Run agent turns programmatically.** Add a Tokio-native API for sending one user message, streaming assistant/tool/status events, and returning the updated session or conversation state.
- Prevent or clearly reject overlapping turns unless the type design makes them impossible.
- Preserve provider streaming, tool execution, prompt generation, and context behavior from the existing agent loop.
- [x] **Expose approval handling to host applications.** Allow embedded callers to approve or deny tool approval requests, especially shell commands in `workspace-edit` mode.
- Include request id, tool call id, tool name, arguments, and reason in approval events.
- Document cancellation/drop behavior for active turns.
### Documentation and Validation
- [x] **Add a minimal headless example.** Include a compilable Rust example that imports Cassady, starts a session, sends a prompt, and prints streamed assistant output.
- Note that a configured OpenAI-compatible provider and API key are still required.
- Show where to handle approval requests even if the first example defaults to `read-only`.
- [x] **Document the experimental Rust API.** Add bundled docs and README links for setup requirements, basic usage, event handling, approvals, limitations, and current non-goals.
- Make clear that multi-agent orchestration, custom providers, custom tools, daemons, and stable plugin APIs are deferred.
- [x] **Test embedding without a terminal.** Add integration tests that use temporary config/conversation roots and mock provider responses to verify session creation, turn streaming, resume, approval flow, and access-mode behavior.
- Ensure `cargo test --locked --all-targets` covers the new public API and examples.
## v0.2.4 — System Prompt Refinement
This release focuses on making Cassady's system prompt clearer, more intuitive, and more useful for everyday coding work without letting it become bulky. The target is a well-structured prompt around 1,000 tokens that gives the model enough product context, safety expectations, and workflow guidance to behave consistently across read-only, workspace-edit, and full-access sessions. See `plans/V0_2_4_SYSTEM_PROMPT_REFINEMENT_PLAN.md`.
+64
View File
@@ -0,0 +1,64 @@
# Security Policy
## Supported Versions
Security updates are provided for the latest released version of Cassady. If you are using an older version, please upgrade to the latest release before reporting an issue, unless the issue also affects the latest release.
| Version | Supported |
| ------- | --------- |
| Latest | ✅ |
| Older releases | ❌ |
## Reporting a Vulnerability
Please do **not** report security vulnerabilities in public GitHub issues, discussions, or pull requests.
To report a vulnerability, use GitHub's private vulnerability reporting for this repository:
1. Open the repository on GitHub.
2. Go to **Security** → **Report a vulnerability**.
3. Include as much detail as you can about the issue, impact, affected versions, and steps to reproduce.
## What to Include
Helpful reports include:
- A description of the vulnerability and likely impact.
- Steps to reproduce or a minimal proof of concept.
- The Cassady version, operating system, shell, and terminal environment.
- Relevant configuration details with secrets removed.
- Any known mitigations or workarounds.
Do not include live API keys, tokens, private prompts, or sensitive project files in a report. Redact secrets before sharing logs or configuration.
## Response Expectations
After a report is received, the maintainer will aim to:
- Acknowledge the report within 7 days.
- Confirm whether the issue is in scope and reproducible.
- Provide status updates when there is meaningful progress.
- Coordinate disclosure timing before publishing details publicly.
Security fixes may be released as patch versions when appropriate. Public disclosure should wait until a fix or mitigation is available, unless otherwise coordinated with the maintainer.
## Scope
Examples of in-scope issues include vulnerabilities in Cassady that could:
- Bypass documented access modes, approval prompts, or workspace boundaries.
- Cause unintended file reads, writes, edits, or shell command execution.
- Leak API keys, provider credentials, chat history, or local configuration.
- Corrupt or expose session data stored under `~/.cass`.
- Introduce unsafe behavior in bundled release artifacts.
Out-of-scope issues generally include:
- Vulnerabilities in third-party model providers or APIs not controlled by this project.
- Prompt-injection behavior that does not bypass Cassady's documented safety controls.
- Issues that require a compromised local machine, shell, dependency cache, or provider account.
- Reports against unsupported older versions that are fixed in the latest release.
## Safe Harbor
Good-faith security research is welcome. Please avoid privacy violations, data destruction, service disruption, and accessing data that does not belong to you. If you follow this policy and make a good-faith effort to avoid harm, the maintainer will not pursue legal action for your research.
+3 -2
View File
@@ -6,11 +6,12 @@ Cassady tools may list, search, and read this directory. Mutating tools are bloc
## Contents
- [Commands](commands.md): CLI forms, global flags, in-chat commands, and keys.
- [Commands](commands.md): CLI forms, global flags, `cass update`, in-chat commands, and keys.
- [Configuration](configuration.md): `~/.cass` files, setup, precedence, schema examples, and validation.
- [Providers and models](providers.md): built-in OpenAI-compatible providers, custom endpoints, model discovery, and reasoning metadata.
- [Access modes and tool safety](access-modes.md): what tools can read, write, edit, and run in each mode.
- [Experimental Rust embedding API](embedding.md): import Cassady from Rust, start headless sessions, stream events, and handle approvals.
- [Workflows](workflows.md): common ways to inspect code, apply edits, run checks, switch models, and resume chats.
- [Troubleshooting](troubleshooting.md): symptoms, likely causes, fixes, and verification commands.
- [Platform notes](platforms.md): macOS, Linux, and Windows environment/path notes.
- [Platform notes](platforms.md): macOS, Linux, Windows, release artifact, and update notes.
- [Glossary](glossary.md): short definitions for Cassady terms.
+28 -1
View File
@@ -9,10 +9,11 @@ cass [OPTIONS]
cassady [OPTIONS]
cass check [OPTIONS]
cass setup [OPTIONS]
cass update [OPTIONS]
cass --resume [CHAT_ID]
```
Run `cass --help`, `cass check --help`, or `cass setup --help` for the help generated by the current binary.
Run `cass --help`, `cass check --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
## Startup behavior
@@ -61,6 +62,32 @@ Missing API keys for inactive providers are warnings. A missing active API key i
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.
### `cass update`
Checks official GitHub releases for Cassady, including Cassady prereleases, and updates the current install directory. The updater runs before provider/model config is loaded, so it can be used even if `~/.cass` is missing or invalid.
By default, Cassady selects the matching prebuilt archive for the current platform, downloads the archive and `.sha256` file, verifies SHA-256, stages both `cass` and `cassady`, and replaces same-directory binaries with rollback backups. If no prebuilt archive is available, it can build from the selected release source when you choose source mode.
Useful options:
- `--check`: check the selected release without installing.
- `--dry-run`: show the selected release, mode, asset, and install plan without downloading or installing.
- `--yes` / `-y`: accept default prompts for non-interactive use.
- `--prebuilt`: require a matching prebuilt archive.
- `--source`: build from release source even when a prebuilt archive exists.
- `--to TAG`: use a specific release tag such as `v0.2.7`.
Examples:
```sh
cass update --check
cass update --dry-run
cass update
cass update --source
```
The updater does not invoke `sudo` or administrator prompts. If the install directory is not writable, rerun the update from an install location you own or update through the same package manager or manual process you originally used.
## In-chat commands
Type `/` to open command autocomplete.
+98
View File
@@ -0,0 +1,98 @@
# Experimental Rust embedding API
Cassady v0.2.6 includes an experimental Rust API for running headless agent sessions from another Rust program. The API is intended for early integrations and may change before Cassady declares a stable library contract.
The embedding API uses the same provider configuration, global instructions, prompts, access modes, tools, and JSONL conversation storage as the `cass` terminal UI. By default it reads and writes under `~/.cass`, so run `cass setup` first or create compatible `config.json`, `providers.json`, and `models.json` files programmatically.
## Minimal example
```rust
use cassady::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let session = SessionBuilder::new()
.cwd(std::env::current_dir()?)
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
let mut turn = session
.start_turn("Summarize this project in a few sentences.")
.await?;
while let Some(event) = turn.next_event().await? {
match event {
Event::AssistantChunk(text) => print!("{text}"),
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await?;
eprintln!("\nResume chat with: cass --resume {}", session.id());
Ok(())
}
```
Add Cassady from a git checkout or path dependency, and ensure your application runs on Tokio.
## Creating or resuming sessions
Use `SessionBuilder` to set host-controlled options:
```rust
let session = SessionBuilder::new()
.config_root("/tmp/my-cass-root")
.cwd("/path/to/workspace")
.access_mode(AccessMode::WorkspaceEdit)
.model("my-model")
.base_url("https://provider.example/v1")
.api_key_env("MY_PROVIDER_KEY")
.build()
.await?;
let resumed = SessionBuilder::new()
.cwd("/path/to/workspace")
.resume(session.id())
.await?;
```
`build()` is equivalent to `new_session()`. Resumed and new sessions use Cassady's normal `conversations/*.jsonl` files, so CLI and embedded sessions can interoperate.
## Events and approvals
`Session::start_turn` consumes the session and returns a `Turn`. This type design prevents overlapping turns for the same session. Call `turn.finish().await?` after receiving `Event::Finished` to recover the updated `Session`.
Important events include:
- `AssistantChunk` and `ReasoningChunk`
- `ToolCallStarted`, `ToolOutputChunk`, and `ToolResult`
- `ApprovalRequested` and `ApprovalResolved`
- `Status`
- `Finished`
When a tool needs approval, decide in host code:
```rust
while let Some(event) = turn.next_event().await? {
match event {
Event::ApprovalRequested(request) => {
eprintln!("approval needed for {}: {}", request.name, request.reason);
turn.deny(&request.request_id)?;
}
Event::Finished => break,
_ => {}
}
}
```
The approval policy is the same as the TUI: shell is unavailable in `read-only`, requires approval in `workspace-edit`, and runs directly in `full-access` unless destructive-operation confirmation is enabled.
## Cancellation
Dropping a `Turn` aborts the underlying task. Prefer `turn.cancel().await?` when you want Cassady to repair the conversation with cancellation records before returning the session.
## Current limitations
The v0.2.6 API is intentionally small and experimental. It does not include custom provider traits, custom tools, plugin loading, multi-agent orchestration, background daemons, task queues, or a synchronous/blocking wrapper.
+15 -2
View File
@@ -52,9 +52,22 @@ That directory contains `config.json`, `providers.json`, `models.json`, `global.
## Non-interactive contexts
- `cass check` is suitable for scripts and CI because it prints text and exits non-zero on errors.
- `cass update --check` and `cass update --dry-run` are suitable for scripts that only need release status or an install plan.
- `cass update --yes` accepts default prompts for scripted updates, but still fails instead of escalating privileges when the install directory is not writable.
- `cass setup` requires an interactive terminal.
- `cass` chat is an interactive terminal UI.
## Release artifacts
## Release artifacts and updates
When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path. Cassady itself does not install, update, or manage PATH entries.
When using release archives, each archive contains both `cass` and `cassady`. Put the extracted binaries somewhere on your `PATH` or run them by explicit path.
`cass update` can update release-archive installs from official GitHub releases. It supports the same prebuilt targets as the release process:
- macOS Apple Silicon: `aarch64-apple-darwin`
- Linux x86_64: `x86_64-unknown-linux-gnu`
- Linux ARM64: `aarch64-unknown-linux-gnu`
- Windows x86_64: `x86_64-pc-windows-gnu`
On macOS and Linux, the updater replaces same-directory `cass` and `cassady` binaries with backups and rollback on failure. On Windows, replacing a running `.exe` is more constrained; if automatic replacement is unavailable, Cassady leaves staged files in place and reports manual copy guidance instead of partially modifying the install.
If Cassady is installed through a package manager in the future, prefer that package manager's update command instead of `cass update`.
+23
View File
@@ -143,6 +143,29 @@ Likely cause: Windows CRLF line endings or invisible whitespace differences.
Fix: re-read the exact target region and preserve the line endings in `old_text`, or use a smaller unique snippet.
## Update command problems
Symptom: `cass update` cannot complete.
Likely causes and fixes:
- Network or GitHub API failure: retry later or verify proxy/firewall settings.
- No matching prebuilt archive: use `cass update --source` if you have Rust installed, or download the release archive manually for a supported target.
- SHA-256 mismatch: do not install the archive. Retry the update; if it repeats, check the GitHub release page before proceeding.
- Missing Rust toolchain in source mode: install Rust/Cargo yourself, then rerun `cass update --source`. Cassady does not install Rust automatically.
- Non-writable install directory: update through the original install method, move Cassady to a directory you own, or manually replace the binaries. Cassady does not run `sudo` for you.
- PATH conflict: `cass update` updates the current executable directory. Run `which cass` / `which cassady` on macOS/Linux or `Get-Command cass` in PowerShell to confirm which binary your shell starts.
- Windows replacement limitation: if Cassady reports that automatic replacement is unavailable, use the staged file paths it prints and copy them after the running process exits.
Useful checks:
```sh
cass update --check
cass update --dry-run
cass --version
cassady --version
```
## Terminal rendering problems
Symptom: the UI appears garbled or keys do not behave as expected.
+33
View File
@@ -0,0 +1,33 @@
use cassady::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let session = SessionBuilder::new()
.cwd(std::env::current_dir()?)
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
let mut turn = session
.start_turn("Summarize this project in a few sentences.")
.await?;
while let Some(event) = turn.next_event().await? {
match event {
Event::AssistantChunk(text) => print!("{text}"),
Event::ApprovalRequested(request) => {
eprintln!(
"approval requested for {}: {}; denying in this example",
request.name, request.reason
);
turn.deny(&request.request_id)?;
}
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await?;
eprintln!("\nResume chat with: cass --resume {}", session.id());
Ok(())
}
+298
View File
@@ -0,0 +1,298 @@
# v0.2.6 Rust Embedding API Implementation Plan
## Goal
v0.2.6 adds the first intentional public Rust API for embedding Cassady in another Rust project. A developer should be able to add Cassady as a dependency, configure a workspace/model/access mode, start a headless agent session, send user messages, receive streamed agent events, and handle approval requests without launching the interactive TUI.
Success statement:
> A small Rust program can import `cassady`, start a new headless session in a workspace, stream assistant/tool events from a turn, optionally approve shell requests, and inspect the updated conversation state using documented experimental APIs.
## Scope
### In scope
- Add an experimental embedding API module with cohesive public types instead of requiring callers to wire together internal modules directly.
- Support starting a new headless agent session from Rust code.
- Support resuming an existing conversation by id when using Cassady's existing conversation storage.
- Support running one turn at a time and streaming typed events to the host application.
- Expose approval handling for tools that require host/user consent, especially shell in `workspace-edit` mode.
- Reuse the existing config, provider, prompt, security, conversation, and tool execution paths used by the CLI/TUI.
- Provide simple builder/options types for cwd, access mode, model/base URL/API key overrides, reasoning effort, and Cassady config root.
- Add a crate-level `prelude` or clearly documented imports for common embedding use.
- Add docs and examples that show a minimal headless integration.
- Add integration tests that exercise the public API without a terminal.
### Out of scope
- Declaring the Rust API stable for semver compatibility. The API should be explicitly marked experimental in v0.2.6.
- Replacing the CLI/TUI as the primary user interface.
- Multi-agent orchestration, task queues, background daemons, schedulers, or distributed workers.
- Custom model provider traits or non-OpenAI-compatible protocols.
- User-defined custom tools or plugin loading.
- A synchronous/blocking API. The first embedding surface can require Tokio.
- Exposing low-level terminal UI internals as supported public API.
- Publishing to crates.io as part of this release unless separately requested.
## Context and Current State
Cassady already builds a library crate:
- `Cargo.toml` defines `[lib] name = "cassady" path = "src/lib.rs"`.
- `src/lib.rs` currently re-exports many internal modules directly and exposes `run()` for the CLI/TUI path.
- `src/agent.rs` contains the core async turn loop:
- `AgentSettings`
- `AgentEvent`
- `AgentCommand`
- `run_turn(...)`
- `run_turn_with_commands(...)`
- `src/app.rs` owns interactive startup, TUI state, chat creation/resume, cancellation, approval UI, and local slash commands.
- `src/conversation.rs` persists conversations as JSONL and can create/load/list chats.
- `src/config.rs` loads providers, models, active defaults, API key references, access mode, tool limits, and docs paths.
- `src/security.rs` centralizes access-mode decisions.
- `src/tools/*` implements the same tools that headless sessions should use.
The current crate can technically be imported, but the supported path is unclear: callers must know which internal modules to combine, how to create base prompts, how to load config safely, how to route approval commands, and how to consume events. v0.2.6 should add a thin, intentional API layer over these internals.
## Design Principles
1. **Thin wrapper over proven internals.** Reuse the same agent loop and policy code as the CLI so embedded behavior matches interactive behavior.
2. **Explicitly experimental.** Make the new API useful without promising final naming or long-term stability yet.
3. **Headless first.** The API should not depend on `ratatui`, terminal setup, crossterm event loops, or slash-command UI state.
4. **Host owns presentation.** Embedded callers receive typed events and decide how to display assistant chunks, tool calls, approvals, and errors.
5. **Safe defaults.** Default to `read-only`, environment-variable API keys, existing Cassady config files, and workspace-rooted paths.
6. **Approval is part of the API.** Hosts must be able to approve or deny requests rather than having Cassady assume a TUI is present.
7. **Keep the first surface small.** Prefer one clear session builder and one turn-running method over exposing every internal knob.
## Design
### Module layout
Add a new module, for example:
```rust
pub mod embedding;
pub mod prelude;
```
`src/embedding.rs` should be the supported experimental API. Existing internal modules can remain public in v0.2.6 for compatibility, but docs should steer new users toward `cassady::embedding` or `cassady::prelude`.
Suggested public surface:
```rust
pub struct SessionBuilder { ... }
pub struct Session { ... }
pub struct SessionOptions { ... }
pub struct Turn { ... }
pub enum Event { ... }
pub enum Command { ... }
pub struct ConversationInfo { ... }
```
The exact names can change during implementation, but they should avoid leaking TUI-specific terms.
### Builder and options
Provide a builder that covers common embedding setup:
```rust
let mut session = cassady::embedding::SessionBuilder::new()
.cwd("/path/to/project")
.access_mode(AccessMode::WorkspaceEdit)
.model("accounts/fireworks/models/qwen3p7-plus")
.build()
.await?;
```
Builder responsibilities:
- Resolve and canonicalize `cwd` like CLI startup.
- Load config from the default Cassady root unless an explicit root/path is supplied.
- Apply model/base URL/API key env overrides without requiring a `Cli` value from callers.
- Resolve API key availability before starting a turn and return a useful error.
- Install or locate bundled docs as needed by `Config::load` behavior.
- Create the base system prompt with `~/.cass/global.md` when starting a new conversation.
- Default access mode to config/default, then builder override, then `read-only` if no config exists.
Avoid requiring callers to import or construct `cli::Cli`.
### New and resumed sessions
Support at least:
```rust
let session = SessionBuilder::new().cwd(".").new_session().await?;
let session = SessionBuilder::new().cwd(".").resume("chat-id").await?;
```
A `Session` should expose lightweight metadata:
```rust
session.id();
session.cwd();
session.model();
session.access_mode();
session.conversation_path();
```
The conversation should continue to be persisted in the same JSONL format so CLI and library sessions can interoperate.
### Running a turn
Provide a headless one-turn API that streams events:
```rust
let mut turn = session.start_turn("Explain the crate layout").await?;
while let Some(event) = turn.next_event().await? {
match event {
Event::AssistantChunk(text) => print!("{text}"),
Event::ApprovalRequested(request) => {
turn.approve(request.id).await?;
}
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await?;
```
Alternative designs are acceptable, such as returning `(EventStream, CommandSink)` plus a completion handle, as long as examples are simple and approval commands are supported.
The wrapper can map `agent::AgentEvent` and `agent::AgentCommand` into public embedding types. It should avoid exposing internal channel mechanics unless that is the cleanest Tokio-native API.
### Event model
Expose typed events that are stable enough for hosts to build UI/logging around:
- assistant text chunks
- reasoning chunks, when provider/model returns them
- tool call started
- tool output chunk
- tool result
- approval requested
- approval resolved
- status
- turn finished
- error or turn failure
The public event type can wrap or re-export `agent::AgentEvent` initially, but the plan should prefer a dedicated type if it prevents low-level internals from becoming accidental API.
### Approval behavior
Approval requests should include:
- request id
- tool call id
- tool name
- arguments
- human-readable reason
The host should be able to approve or deny by request id. If the host drops the turn or never responds, cancellation/drop behavior should be documented.
For v0.2.6, keep approval policy aligned with `security.rs`:
- `read-only`: shell unavailable.
- `workspace-edit`: shell asks.
- `full-access`: shell allowed.
### Cancellation and drop behavior
The TUI already cancels by aborting the agent task and repairing pending records. The embedding API should define a basic behavior:
- Dropping an active turn should abort the underlying task if possible.
- A simple explicit `cancel()` method is preferred if practical.
- Conversation repair for cancelled turns can be minimal in v0.2.6, but pending tool calls must not corrupt resumed conversations.
If full parity with the TUI cancellation path is too large, document the limitation and add tests for the supported behavior.
### Error handling
Use a public result alias such as:
```rust
pub type Result<T> = std::result::Result<T, Error>;
```
The first pass may wrap `anyhow::Error`, but public errors should include enough context for embedding callers to distinguish:
- config load errors
- missing API key
- provider request errors
- conversation load/create errors
- active turn already running
- approval request not found or already resolved
Do not panic for ordinary configuration or runtime failures.
### Examples
Add at least one compilable example under `examples/`, for example `examples/headless_agent.rs`:
```rust
use cassady::prelude::*;
#[tokio::main]
async fn main() -> cassady::embedding::Result<()> {
let mut session = SessionBuilder::new()
.cwd(std::env::current_dir()?)
.access_mode(AccessMode::ReadOnly)
.build()
.await?;
let mut turn = session.start_turn("Summarize this project.").await?;
while let Some(event) = turn.next_event().await? {
if let Event::AssistantChunk(text) = event {
print!("{text}");
}
}
turn.finish().await?;
Ok(())
}
```
The example should be honest about requiring configured providers and API keys.
## Implementation Steps
1. **Define the experimental API shape.** Add `src/embedding.rs` with builder, session, turn, event, command/approval, and result/error types.
2. **Add non-CLI config loading helpers.** Refactor or add helpers in `src/config.rs` so library callers can apply overrides without constructing `cli::Cli`.
3. **Extract chat creation/resume helpers.** Move reusable prompt/global/conversation setup out of `src/app.rs` into functions usable by both TUI and embedding API.
4. **Wrap the existing agent loop.** Use `agent::run_turn_with_commands` internally and provide a host-friendly event stream plus approval methods.
5. **Handle turn lifecycle.** Ensure a session cannot run overlapping turns unless explicitly supported; persist and return the updated conversation after a turn finishes.
6. **Add cancellation/drop handling.** Provide at least a documented `cancel()` path and avoid leaving pending tool-call records in a corrupted state.
7. **Add examples and docs.** Create a headless example and a bundled docs page for the experimental Rust API.
8. **Update README and crate exports.** Add `embedding`/`prelude` exports and a short README section pointing to the new docs.
9. **Test the public surface.** Add integration tests with a mock OpenAI-compatible server and temporary config/conversation roots.
## Tests
- Unit tests for builder option precedence: default config, explicit cwd, access mode, model, base URL, API key env, and config root.
- Integration test that starts a new session and runs a turn against `wiremock`, asserting assistant chunks and persisted conversation records.
- Integration test that resumes an existing conversation through the embedding API.
- Integration test for approval flow in `workspace-edit` mode using a mock tool call that requests shell approval.
- Test that read-only sessions do not expose write/edit/shell tools through the embedded turn.
- Test that starting a second turn while one is active returns an error or is impossible by type design.
- Example compilation through `cargo test --examples` or equivalent.
## Documentation
- Add `docs/rust-api.md` or `docs/embedding.md` describing the experimental API, setup requirements, minimal example, event loop, approval handling, and limitations.
- Link the new page from `docs/README.md` and the README.
- Document that the API is experimental in v0.2.6 and may change before a stable 1.0-style library contract.
- Include a note that embedded sessions use the same `~/.cass` config and conversation storage by default.
- Mention how hosts should run `cass setup` or provide config programmatically before using the API.
## Acceptance Criteria
- A Rust binary in `examples/` can import `cassady`, create a headless session, run a turn, and stream assistant output without launching the TUI.
- Embedded sessions use the same provider, prompt, security, tool, and conversation paths as the CLI.
- Approval requests can be approved or denied programmatically.
- New public API docs and README links clearly label the surface experimental.
- CLI/TUI behavior remains unchanged.
- `cargo fmt` and `cargo test --locked --all-targets` pass.
+390
View File
@@ -0,0 +1,390 @@
# v0.2.7 Self-Update Command Implementation Plan
## Goal
v0.2.7 adds a polished `cass update` command that can update Cassady from official GitHub releases without requiring users to manually download archives, verify checksums, unpack binaries, or rebuild from source.
Success statement:
> A user can run `cass update`, see the available release, choose the recommended prebuilt binary or a source build fallback, and finish with updated `cass` and `cassady` commands in the same install location.
## Scope
### In scope
- Add a `cass update` / `cassady update` subcommand.
- Query official Cassady GitHub releases from `owenqwenstarsky/cassady`.
- Compare the current binary version with the latest stable release.
- Download and install the matching prebuilt archive when available.
- Verify prebuilt archives with the shipped `.sha256` files before installing.
- Offer a source-build path that downloads release source for the selected tag and builds local binaries.
- Update both shipped binaries, `cass` and `cassady`, when possible.
- Use interactive prompts by default with clear summaries, confirmations, progress, success, and recovery messages.
- Provide non-interactive flags for check-only and yes-to-prompts usage.
- Keep `cass update` independent of model/provider setup so updates work even when `~/.cass` is missing or broken.
- Add tests for release parsing, target detection, asset selection, checksum validation, archive extraction safety, and install planning.
- Update README and bundled docs.
### Out of scope
- Publishing through Homebrew, apt, winget, Scoop, npm, or other package managers.
- Automatic background updates or prompts during normal chat startup.
- Updating Cassady when it was installed by an external package manager that should own the install directory.
- Privilege escalation, `sudo` automation, or administrator prompts.
- Code signing, notarization, or signature verification beyond existing SHA-256 files.
- Downgrading by default. Installing an older tag should require an explicit flag if supported.
- Cross-compiling in source mode. Source builds target the current host platform only.
## Context and Current State
Relevant files:
- `Cargo.toml`: package version and two binaries, `cass` and `cassady`.
- `src/cli.rs`: Clap command definitions currently include `check` and `setup`.
- `src/app.rs`: top-level command dispatch; update should run before setup/config loading.
- `src/main.rs` and `src/bin/cassady.rs`: both call `cassady::run()`.
- `README.md` and `docs/commands.md`: command documentation.
- `docs/platforms.md` and `docs/troubleshooting.md`: platform and recovery guidance.
- `AGENTS.md`: release artifacts use these names:
- `cassady-vX.Y.Z-aarch64-apple-darwin.tar.gz`
- `cassady-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz`
- `cassady-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz`
- `cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip`
Current releases include both `cass` and `cassady` in each archive plus one `.sha256` file per archive. The update command should reuse that release contract instead of inventing a new distribution format.
## Design Principles
1. **Boring and recoverable.** Updating should be explicit, easy to understand, and safe to interrupt before installation starts.
2. **Use official release artifacts first.** Prefer prebuilt archives with SHA-256 verification; fall back to source builds when the user asks or no asset matches.
3. **No surprise setup coupling.** Users should not need a configured provider, model, or API key to update the CLI.
4. **Respect install ownership.** Do not auto-escalate privileges or overwrite package-manager-owned paths without clear user confirmation.
5. **Both command names stay aligned.** If the user has both `cass` and `cassady` in the install directory, update them together.
6. **Interactive by default, scriptable when requested.** The normal path should be friendly; flags should support CI/check scripts.
7. **Fail closed on integrity.** Missing or mismatched checksums for prebuilt artifacts must stop installation.
## User Experience
### Default interactive flow
```text
$ cass update
Cassady update
Current version: v0.2.6
Latest release: v0.2.7
Install path: /usr/local/bin
Recommended: prebuilt aarch64-apple-darwin archive
Update Cassady to v0.2.7? [Y/n]
```
If the user accepts, Cassady should show concise phases:
```text
Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz ... 8.4 MB
Downloading cassady-v0.2.7-aarch64-apple-darwin.tar.gz.sha256 ... done
Verifying SHA-256 ... ok
Preparing cass and cassady ... ok
Installing to /usr/local/bin ... ok
Verifying installed version ... cass 0.2.7
Cassady is up to date.
```
If the current version is already latest:
```text
Cassady is already up to date.
Current version: v0.2.7
Latest release: v0.2.7
```
### Prebuilt or source selection
The default `auto` mode should choose the prebuilt release asset when a supported target is detected. If no matching prebuilt exists, prompt for source mode:
```text
No prebuilt archive is available for this platform.
Build Cassady v0.2.7 from source instead? [Y/n]
```
If both paths are available and the user asks for source mode:
```sh
cass update --source
```
Cassady should confirm prerequisites before building:
```text
Source build requires cargo, rustc, and a working C toolchain.
Build Cassady v0.2.7 from release source now? [Y/n]
```
### Useful flags
Add a command shape like:
```sh
cass update [OPTIONS]
```
Suggested options:
- `--check`: check GitHub for the latest release and print status without installing.
- `--yes`: accept default prompts for non-interactive use.
- `--prebuilt`: require a matching prebuilt archive; fail instead of falling back to source.
- `--source`: build from release source even when a prebuilt archive exists.
- `--to TAG`: install a specific release tag such as `v0.2.7`.
- `--dry-run`: resolve the release, target, assets, and install path without downloading or installing.
Optional later flags, only if implementation needs them:
- `--stable-only`: ignore prerelease tags during latest-release selection if Cassady later publishes both stable and prerelease channels.
- `--install-dir PATH`: install into an explicit directory. This should be advanced and carefully documented because it can conflict with PATH order.
Avoid adding a public `--repo` override unless needed for testing; tests can inject a mock client instead.
## Design
### Module layout
Add a focused update module:
```rust
pub mod update;
```
Suggested internal types:
```rust
pub struct UpdateOptions { ... }
pub enum UpdateMode { Auto, Prebuilt, Source }
pub struct ReleaseInfo { ... }
pub struct ReleaseAsset { ... }
pub struct PlatformTarget { ... }
pub struct UpdatePlan { ... }
pub enum InstallAction { Replace, AddCompanion, SkipMissingCompanion }
```
`src/cli.rs` should add an `Update` subcommand with parsed flags. `src/app.rs` should dispatch it before setup/config loading:
```rust
if let Some(Command::Update(args)) = cli.command {
return crate::update::run(args).await;
}
```
This keeps update usable even when `Config::load()` would fail.
### GitHub release discovery
Use the GitHub Releases API with an explicit user agent:
- Latest release: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases?per_page=30` and choose the highest semver non-draft tag, including prereleases because Cassady's current release process marks releases as prereleases.
- Specific tag: `GET https://api.github.com/repos/owenqwenstarsky/cassady/releases/tags/{tag}`
Parse:
- `tag_name`
- `name`
- `draft`
- `prerelease`
- `assets[].name`
- `assets[].browser_download_url`
- `assets[].size`
- `tarball_url` or `zipball_url` for source mode
Use `semver` to compare `env!("CARGO_PKG_VERSION")` with release tags after stripping a leading `v`. Draft releases should never be selected. Prereleases should be eligible by default while Cassady's official releases are marked as prereleases.
### Platform target mapping
Map the running platform to release asset targets:
| OS | Arch | Target | Archive |
| --- | --- | --- | --- |
| macOS | `aarch64` | `aarch64-apple-darwin` | `.tar.gz` |
| Linux | `x86_64` | `x86_64-unknown-linux-gnu` | `.tar.gz` |
| Linux | `aarch64` | `aarch64-unknown-linux-gnu` | `.tar.gz` |
| Windows | `x86_64` | `x86_64-pc-windows-gnu` | `.zip` |
Unsupported platforms should produce a clean message and offer source mode when possible.
### Prebuilt update path
For tag `vX.Y.Z` and target `TARGET`, find:
```text
cassady-vX.Y.Z-TARGET.tar.gz
cassady-vX.Y.Z-TARGET.tar.gz.sha256
```
or on Windows:
```text
cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip
cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip.sha256
```
Flow:
1. Download archive and checksum into a temporary staging directory.
2. Parse the `.sha256` file and verify that the checksum filename matches the downloaded archive name.
3. Compute SHA-256 of the archive and compare exactly.
4. Extract into staging using path traversal checks.
5. Require the expected binaries:
- Unix: `cass`, `cassady`
- Windows: `cass.exe`, `cassady.exe`
6. Run the staged `cass --version` or `cassady --version` when possible and confirm the expected version.
7. Build an install plan for the current executable directory.
8. Confirm the final plan with the user unless `--yes` was supplied.
9. Replace binaries with backups and rollback on failure.
10. Verify installed version after replacement when possible.
Archive extraction must reject absolute paths, `..` components, symlinks that escape staging, and unexpected top-level layouts.
### Source-build update path
Source mode should still be tied to a GitHub release tag, not an arbitrary branch.
Flow:
1. Resolve the selected release tag.
2. Download release source from `tarball_url` or `zipball_url` into staging.
3. Extract with the same path traversal protections as prebuilt archives.
4. Verify `Cargo.toml` version matches the selected tag.
5. Run:
```sh
cargo build --release --locked --bins
```
from the extracted source tree.
6. Locate built binaries under `target/release/`.
7. Run staged `--version` checks.
8. Install using the same installer path as prebuilt updates.
Before source mode starts, check for `cargo` and `rustc` on PATH and show a clear error if they are missing. Do not attempt to install Rust automatically.
### Install planning and replacement
Determine the current executable path with `std::env::current_exe()`, then derive the install directory. The install plan should include:
- current binary path
- sibling `cass` path
- sibling `cassady` path
- which binaries currently exist
- which binaries are writable
- whether companion binaries will be updated, added, skipped, or blocked
Recommended behavior:
- Always update the currently running binary name.
- If the sibling binary exists in the same directory, update it too.
- If the sibling binary is missing and the directory is writable, ask whether to install it.
- If a target path is not writable, stop with an actionable message. Do not invoke `sudo` or administrator prompts automatically.
- Use backups such as `.cass-update-backup-v0.2.6-<timestamp>` during replacement.
- If any replacement fails, restore backups before returning an error.
Unix can generally replace a running executable via atomic rename. Windows cannot reliably overwrite the running `.exe`; implement one of these approaches during coding:
1. Preferred: stage replacements and spawn a small PowerShell or `cmd` helper that waits for the current process to exit, moves files into place, and writes a log.
2. Fallback: stage replacements and print exact manual copy commands if helper launch is unavailable.
Document any Windows limitation honestly in `docs/platforms.md` and `docs/troubleshooting.md`.
### Output and error style
Keep output concise and user-facing:
- Show current version, target version, install directory, selected mode, and asset/source name before changing files.
- Show clear phase lines for download, verify, build, install, and final verification.
- On failure, say whether anything was changed and where staging/backups are located.
- If update cannot proceed because the install path is not writable, tell the user which path failed and suggest reinstalling through the same method they originally used.
Avoid dumping raw GitHub JSON, backtraces, or Cargo logs unless the source build fails; in that case, preserve the final relevant Cargo output and staging path.
## Dependencies
Likely additions to `Cargo.toml`:
- `semver` for version comparison.
- `sha2` for SHA-256 verification.
- `tar` and `flate2` for `.tar.gz` extraction.
- `zip` for Windows release archives and GitHub source zips if used.
Prefer small, well-maintained crates. Reuse existing `reqwest`, `tokio`, `serde`, and `serde_json`.
## Implementation Steps
1. Add CLI parsing for `cass update` and dispatch it before setup/config loading.
2. Add `src/update.rs` with release API types, version comparison, and target detection.
3. Implement GitHub release fetching with a testable client abstraction or injectable base URL for tests.
4. Implement asset selection for current platform and update mode.
5. Implement download, progress reporting, and checksum verification for prebuilt archives.
6. Implement safe archive extraction and staged binary validation.
7. Implement install planning from `current_exe()` and companion binary detection.
8. Implement Unix replacement with backups and rollback.
9. Implement Windows staged-helper replacement or a clearly documented manual fallback.
10. Implement source mode: source download, version validation, prerequisite checks, `cargo build --release --locked --bins`, and staged binary validation.
11. Polish interactive prompts and `--check`, `--dry-run`, `--yes`, `--prebuilt`, `--source`, and `--to` behavior.
12. Update docs and release notes template expectations if needed.
13. Add tests and run full verification.
## Tests
Add focused unit tests for:
- parsing `vX.Y.Z` tags and comparing against the current version shape
- ignoring drafts and prereleases where applicable
- mapping supported and unsupported platform targets
- matching asset and checksum filenames
- parsing `.sha256` lines generated by the release process
- rejecting checksum filename mismatches and digest mismatches
- rejecting archive path traversal entries
- planning installation when only `cass`, only `cassady`, or both binaries exist
- refusing non-writable install targets in planning or dry-run mode
- source mode validating that `Cargo.toml` version matches the selected tag
Add integration-style tests with a mock HTTP server for:
- already-up-to-date response
- latest prebuilt update plan
- missing prebuilt with source fallback prompt path, where build execution can be mocked
- download checksum mismatch failure
- successful staged install into a temporary directory using fake binaries
Manual checks:
```sh
cargo fmt
cargo test --locked --all-targets
cargo run -- update --check
cargo run -- update --dry-run --to v0.2.7
```
For a real release candidate, test from a temporary install directory before using `cass update` on the developer's normal binary.
## Documentation
Update:
- `README.md`: mention `cass update` in install/update and everyday command sections.
- `docs/commands.md`: full command reference, flags, interactivity, examples, and exit behavior.
- `docs/platforms.md`: platform-specific update support and Windows replacement notes.
- `docs/troubleshooting.md`: network failures, checksum mismatch, no matching prebuilt, missing Rust toolchain, non-writable install directory, PATH conflicts, and rollback recovery.
- `docs/README.md`: add any new update-related links or summaries.
Document that users should prefer the package manager's update mechanism if Cassady was installed through a package manager in the future.
## Acceptance Criteria
- `cass update --check` reports the current/latest release without reading provider config.
- `cass update --dry-run` shows the selected release, mode, asset/source, and install plan without modifying files.
- On supported release targets, `cass update` can download the matching official archive, verify SHA-256, stage both binaries, and update the current install directory.
- `cass update --source` can download release source, build with `cargo build --release --locked --bins`, and install the resulting local binaries.
- Checksum mismatch, missing assets, unsupported platforms, missing Rust toolchain, and non-writable install paths fail with clear messages and no partial install.
- Existing `cass` and `cassady` sibling binaries remain version-aligned after a successful update.
- README and bundled docs explain the command accurately.
- `cargo fmt` and `cargo test --locked --all-targets` pass.
+4
View File
@@ -21,6 +21,10 @@ const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user.";
pub async fn run() -> Result<()> {
let mut cli = cli::parse();
if let Some(Command::Update(args)) = cli.command.clone() {
return crate::update::run(args).await;
}
if matches!(cli.command, Some(Command::Check)) {
let report = crate::check::run(&cli)?;
print!("{}", report.render());
+30 -1
View File
@@ -1,4 +1,4 @@
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Debug, Parser, Clone)]
@@ -46,6 +46,35 @@ pub enum Command {
Check,
/// Configure an OpenAI-compatible provider and first model.
Setup,
/// Update Cassady from official GitHub releases.
Update(UpdateArgs),
}
#[derive(Debug, Args, Clone, PartialEq, Eq)]
pub struct UpdateArgs {
/// Check the latest release without installing.
#[arg(long)]
pub check: bool,
/// Show what would be updated without downloading or installing.
#[arg(long)]
pub dry_run: bool,
/// Accept default prompts for non-interactive use.
#[arg(long, short = 'y')]
pub yes: bool,
/// Require a matching prebuilt archive and do not fall back to source.
#[arg(long, conflicts_with = "source")]
pub prebuilt: bool,
/// Build from release source even when a prebuilt archive exists.
#[arg(long, conflicts_with = "prebuilt")]
pub source: bool,
/// Install a specific release tag, such as v0.2.7.
#[arg(long, value_name = "TAG")]
pub to: Option<String>,
}
pub fn parse() -> Cli {
+54 -20
View File
@@ -151,6 +151,34 @@ pub struct Config {
pub docs_dir: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigOverrides {
pub model: Option<String>,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub access_mode: Option<AccessMode>,
}
impl ConfigOverrides {
pub fn from_cli(cli: &Cli) -> Self {
let access_mode = if cli.readonly {
Some(AccessMode::ReadOnly)
} else if cli.workspace_edit {
Some(AccessMode::WorkspaceEdit)
} else if cli.full_access {
Some(AccessMode::FullAccess)
} else {
None
};
Self {
model: cli.model.clone(),
base_url: cli.base_url.clone(),
api_key_env: cli.api_key_env.clone(),
access_mode,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiKeyReference {
Env(String),
@@ -287,17 +315,29 @@ pub fn models_path(root: &Path) -> PathBuf {
impl Config {
pub fn load(cli: &Cli) -> Result<Self> {
Self::load_from_root(cass_root(), cli)
Self::load_with_overrides(cass_root(), ConfigOverrides::from_cli(cli))
}
pub fn load_from_root(root: PathBuf, cli: &Cli) -> Result<Self> {
pub fn load_with_overrides(root: PathBuf, overrides: ConfigOverrides) -> Result<Self> {
fs::create_dir_all(root.join("conversations"))
.with_context(|| format!("creating {}", root.join("conversations").display()))?;
let docs_dir = crate::docs::install(&root)?;
Self::load_from_root_with_docs(root, docs_dir, cli)
Self::load_from_root_with_docs_and_overrides(root, docs_dir, overrides)
}
pub fn load_from_root(root: PathBuf, cli: &Cli) -> Result<Self> {
Self::load_with_overrides(root, ConfigOverrides::from_cli(cli))
}
pub fn load_from_root_with_docs(root: PathBuf, docs_dir: PathBuf, cli: &Cli) -> Result<Self> {
Self::load_from_root_with_docs_and_overrides(root, docs_dir, ConfigOverrides::from_cli(cli))
}
pub fn load_from_root_with_docs_and_overrides(
root: PathBuf,
docs_dir: PathBuf,
overrides: ConfigOverrides,
) -> Result<Self> {
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
let providers = load_or_create_default_provider_registry(&root)?;
let models = load_or_create_default_model_registry(&root)?;
@@ -330,19 +370,13 @@ impl Config {
}
}
if cli.readonly {
cfg.default_access_mode = AccessMode::ReadOnly;
}
if cli.workspace_edit {
cfg.default_access_mode = AccessMode::WorkspaceEdit;
}
if cli.full_access {
cfg.default_access_mode = AccessMode::FullAccess;
if let Some(access_mode) = overrides.access_mode {
cfg.default_access_mode = access_mode;
}
let requested_model = requested_model(file.as_ref(), cli);
let requested_model = requested_model(file.as_ref(), &overrides);
let provider_id_from_config = requested_provider_id(file.as_ref(), &providers);
let legacy = legacy_provider_override(file.as_ref(), cli);
let legacy = legacy_provider_override(file.as_ref(), &overrides);
let mut provider = resolve_provider(
requested_model.as_deref().unwrap_or(DEFAULT_MODEL),
@@ -353,10 +387,10 @@ impl Config {
&models,
)?;
if let Some(base_url) = &cli.base_url {
if let Some(base_url) = &overrides.base_url {
provider.base_url = base_url.clone();
}
if let Some(api_key_env) = &cli.api_key_env {
if let Some(api_key_env) = &overrides.api_key_env {
provider.api_key = format!("${api_key_env}");
}
@@ -706,8 +740,8 @@ pub fn find_model_for_provider<'a>(
.find(|m| m.provider == provider_id && m.id == model_id)
}
fn requested_model(file: Option<&ConfigFile>, cli: &Cli) -> Option<String> {
cli.model.clone().or_else(|| {
fn requested_model(file: Option<&ConfigFile>, overrides: &ConfigOverrides) -> Option<String> {
overrides.model.clone().or_else(|| {
file.and_then(|f| {
f.default_model
.clone()
@@ -738,13 +772,13 @@ struct LegacyProviderOverride {
fn legacy_provider_override(
file: Option<&ConfigFile>,
cli: &Cli,
overrides: &ConfigOverrides,
) -> Option<LegacyProviderOverride> {
let base_url = cli
let base_url = overrides
.base_url
.clone()
.or_else(|| file.and_then(|f| f.base_url.clone()));
let api_key = cli
let api_key = overrides
.api_key_env
.as_ref()
.map(|env| format!("${env}"))
+563
View File
@@ -0,0 +1,563 @@
//! Experimental Rust embedding API for running Cassady without the TUI.
//!
//! This module provides the first Rust-native surface for embedding Cassady in
//! another application. It reuses Cassady's existing runtime behavior while
//! giving the host application control over event presentation, turn lifecycle,
//! and approval decisions.
use crate::access::AccessMode;
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
use crate::config::{Config, ConfigOverrides, ReasoningEffort};
use crate::conversation::{self, Conversation, Record};
use crate::prompt;
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
const TURN_CANCELLED_MESSAGE: &str = "Turn cancelled by host.";
const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by host.";
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("configuration error: {0}")]
Config(#[source] anyhow::Error),
#[error("conversation error: {0}")]
Conversation(#[source] anyhow::Error),
#[error("agent error: {0}")]
Agent(#[source] anyhow::Error),
#[error("agent task failed: {0}")]
Join(#[source] tokio::task::JoinError),
#[error("turn is already closed")]
TurnClosed,
#[error("approval request `{0}` is not pending")]
ApprovalNotPending(String),
#[error("turn session state is unavailable")]
MissingSession,
}
impl Error {
fn config(err: anyhow::Error) -> Self {
Self::Config(err)
}
fn conversation(err: anyhow::Error) -> Self {
Self::Conversation(err)
}
fn agent(err: anyhow::Error) -> Self {
Self::Agent(err)
}
}
#[derive(Debug, Clone, Default)]
pub struct SessionBuilder {
config_root: Option<PathBuf>,
cwd: Option<PathBuf>,
access_mode: Option<AccessMode>,
model: Option<String>,
base_url: Option<String>,
api_key_env: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
}
impl SessionBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn config_root(mut self, root: impl Into<PathBuf>) -> Self {
self.config_root = Some(root.into());
self
}
pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn access_mode(mut self, mode: AccessMode) -> Self {
self.access_mode = Some(mode);
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn api_key_env(mut self, api_key_env: impl Into<String>) -> Self {
self.api_key_env = Some(api_key_env.into());
self
}
pub fn reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
self.reasoning_effort = Some(effort);
self
}
pub async fn build(self) -> Result<Session> {
self.new_session().await
}
pub async fn new_session(self) -> Result<Session> {
let PreparedSession {
config,
cwd,
mode,
reasoning_effort,
} = self.prepare().await?;
let conversation = create_new_conversation(&config, &cwd)?;
Ok(Session {
config,
cwd,
mode,
reasoning_effort,
conversation,
resume_warning: None,
})
}
pub async fn resume(self, chat_id: impl AsRef<str>) -> Result<Session> {
let PreparedSession {
config,
cwd,
mode,
reasoning_effort,
} = self.prepare().await?;
let (conversation, warning) =
Conversation::load(&config.conversations_dir(), chat_id.as_ref())
.map_err(Error::conversation)?;
Ok(Session {
config,
cwd,
mode,
reasoning_effort,
conversation,
resume_warning: warning,
})
}
async fn prepare(self) -> Result<PreparedSession> {
let root = self.config_root.unwrap_or_else(crate::config::cass_root);
let overrides = ConfigOverrides {
model: self.model,
base_url: self.base_url,
api_key_env: self.api_key_env,
access_mode: self.access_mode,
};
let config = Config::load_with_overrides(root, overrides).map_err(Error::config)?;
config.resolved_api_key().map_err(Error::config)?;
let cwd = resolve_cwd(self.cwd).map_err(Error::config)?;
let mode = config.default_access_mode;
let reasoning_effort = self
.reasoning_effort
.unwrap_or(config.reasoning_effort)
.clamp_for_model(config.model_metadata.as_ref());
Ok(PreparedSession {
config,
cwd,
mode,
reasoning_effort,
})
}
}
struct PreparedSession {
config: Config,
cwd: PathBuf,
mode: AccessMode,
reasoning_effort: ReasoningEffort,
}
#[derive(Debug)]
pub struct Session {
config: Config,
cwd: PathBuf,
mode: AccessMode,
reasoning_effort: ReasoningEffort,
conversation: Conversation,
resume_warning: Option<String>,
}
impl Session {
pub fn id(&self) -> &str {
&self.conversation.id
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn model(&self) -> &str {
&self.config.model
}
pub fn access_mode(&self) -> AccessMode {
self.mode
}
pub fn reasoning_effort(&self) -> ReasoningEffort {
self.reasoning_effort
}
pub fn conversation_path(&self) -> &Path {
&self.conversation.path
}
pub fn records(&self) -> &[Record] {
&self.conversation.records
}
pub fn resume_warning(&self) -> Option<&str> {
self.resume_warning.as_deref()
}
pub fn info(&self) -> ConversationInfo {
ConversationInfo {
id: self.conversation.id.clone(),
cwd: self.cwd.clone(),
model: self.config.model.clone(),
access_mode: self.mode,
reasoning_effort: self.reasoning_effort,
path: self.conversation.path.clone(),
record_count: self.conversation.records.len(),
}
}
pub async fn start_turn(self, user_message: impl Into<String>) -> Result<Turn> {
let message = user_message.into();
let turn_start_len = self.conversation.records.len();
let (event_tx, event_rx) = mpsc::unbounded_channel::<AgentEvent>();
let (command_tx, command_rx) = mpsc::unbounded_channel::<AgentCommand>();
let settings = AgentSettings {
config: self.config.clone(),
cwd: self.cwd.clone(),
mode: self.mode,
reasoning_effort: self.reasoning_effort,
};
let conversation = self.conversation.clone();
let task_message = message.clone();
let handle = tokio::spawn(agent::run_turn_with_commands(
conversation,
task_message,
settings,
event_tx,
command_rx,
));
Ok(Turn {
session: Some(self),
handle: Some(handle),
event_rx,
command_tx: Some(command_tx),
pending_approvals: BTreeSet::new(),
turn_start_len,
user_message: message,
})
}
}
#[derive(Debug, Clone)]
pub struct ConversationInfo {
pub id: String,
pub cwd: PathBuf,
pub model: String,
pub access_mode: AccessMode,
pub reasoning_effort: ReasoningEffort,
pub path: PathBuf,
pub record_count: usize,
}
#[derive(Debug)]
pub struct Turn {
session: Option<Session>,
handle: Option<JoinHandle<anyhow::Result<Conversation>>>,
event_rx: mpsc::UnboundedReceiver<AgentEvent>,
command_tx: Option<mpsc::UnboundedSender<AgentCommand>>,
pending_approvals: BTreeSet<String>,
turn_start_len: usize,
user_message: String,
}
impl Turn {
pub async fn next_event(&mut self) -> Result<Option<Event>> {
match self.event_rx.recv().await {
Some(event) => {
let event = Event::from_agent(event);
match &event {
Event::ApprovalRequested(request) => {
self.pending_approvals.insert(request.request_id.clone());
}
Event::ApprovalResolved { request_id, .. } => {
self.pending_approvals.remove(request_id);
}
_ => {}
}
Ok(Some(event))
}
None => Ok(None),
}
}
pub fn approve(&mut self, request_id: impl AsRef<str>) -> Result<()> {
self.resolve_approval(request_id.as_ref(), true)
}
pub fn deny(&mut self, request_id: impl AsRef<str>) -> Result<()> {
self.resolve_approval(request_id.as_ref(), false)
}
pub async fn finish(mut self) -> Result<Session> {
let handle = self.handle.take().ok_or(Error::TurnClosed)?;
let conversation = match handle.await.map_err(Error::Join)? {
Ok(conversation) => conversation,
Err(err) => return Err(Error::agent(err)),
};
let mut session = self.session.take().ok_or(Error::MissingSession)?;
session.conversation = conversation;
self.command_tx = None;
Ok(session)
}
pub async fn cancel(mut self) -> Result<Session> {
if let Some(handle) = &self.handle {
handle.abort();
}
if let Some(handle) = self.handle.take() {
match handle.await {
Ok(Ok(conversation)) => {
let mut session = self.session.take().ok_or(Error::MissingSession)?;
session.conversation = conversation;
self.command_tx = None;
return Ok(session);
}
Ok(Err(err)) => return Err(Error::agent(err)),
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(Error::Join(err)),
}
}
let mut session = self.session.take().ok_or(Error::MissingSession)?;
session.conversation = finalize_cancelled_turn(
&session.config,
&session.conversation.id,
self.turn_start_len,
&self.user_message,
)?;
self.command_tx = None;
Ok(session)
}
fn resolve_approval(&mut self, request_id: &str, approved: bool) -> Result<()> {
if !self.pending_approvals.remove(request_id) {
return Err(Error::ApprovalNotPending(request_id.to_string()));
}
let tx = self.command_tx.as_ref().ok_or(Error::TurnClosed)?;
tx.send(AgentCommand::ApprovalDecision {
request_id: request_id.to_string(),
approved,
})
.map_err(|_| Error::TurnClosed)
}
}
impl Drop for Turn {
fn drop(&mut self) {
if let Some(handle) = &self.handle {
handle.abort();
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
AssistantChunk(String),
ReasoningChunk(String),
ToolCallStarted {
id: String,
name: String,
arguments: Value,
},
ToolOutputChunk {
id: String,
name: String,
stream: String,
content: String,
},
ToolResult {
id: String,
name: String,
ok: bool,
content: String,
},
ApprovalRequested(ApprovalRequest),
ApprovalResolved {
request_id: String,
approved: bool,
},
Status(String),
Finished,
}
impl Event {
fn from_agent(event: AgentEvent) -> Self {
match event {
AgentEvent::AssistantChunk(text) => Self::AssistantChunk(text),
AgentEvent::ReasoningChunk(text) => Self::ReasoningChunk(text),
AgentEvent::ToolCallStarted {
id,
name,
arguments,
} => Self::ToolCallStarted {
id,
name,
arguments,
},
AgentEvent::ToolOutputChunk {
id,
name,
stream,
content,
} => Self::ToolOutputChunk {
id,
name,
stream,
content,
},
AgentEvent::ToolResult {
id,
name,
ok,
content,
} => Self::ToolResult {
id,
name,
ok,
content,
},
AgentEvent::ApprovalRequested {
request_id,
tool_call_id,
name,
arguments,
reason,
} => Self::ApprovalRequested(ApprovalRequest {
request_id,
tool_call_id,
name,
arguments,
reason,
}),
AgentEvent::ApprovalResolved {
request_id,
approved,
} => Self::ApprovalResolved {
request_id,
approved,
},
AgentEvent::Status(status) => Self::Status(status),
AgentEvent::TurnFinished => Self::Finished,
}
}
}
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
pub request_id: String,
pub tool_call_id: String,
pub name: String,
pub arguments: Value,
pub reason: String,
}
fn resolve_cwd(cwd: Option<PathBuf>) -> anyhow::Result<PathBuf> {
let cwd = cwd.unwrap_or(std::env::current_dir()?);
cwd.canonicalize()
.map_err(anyhow::Error::from)
.map_err(|err| anyhow::anyhow!("resolving cwd {}: {err}", cwd.display()))
}
fn create_new_conversation(config: &Config, cwd: &Path) -> Result<Conversation> {
let global = fs::read_to_string(config.global_path()).ok();
let base = prompt::build_base_system_prompt(global.as_deref());
Conversation::create(&config.conversations_dir(), &config.model, cwd, base)
.map_err(Error::conversation)
}
fn finalize_cancelled_turn(
config: &Config,
chat_id: &str,
turn_start_len: usize,
turn_message: &str,
) -> Result<Conversation> {
let (mut conversation, _) =
Conversation::load(&config.conversations_dir(), chat_id).map_err(Error::conversation)?;
if conversation.records.len() <= turn_start_len {
conversation
.append(Record::User {
content: turn_message.to_string(),
ts: conversation::now_ts(),
})
.map_err(Error::conversation)?;
}
for (id, name) in pending_tool_calls(&conversation.records) {
conversation
.append(Record::Tool {
tool_call_id: id,
name,
ok: false,
content: TOOL_CANCELLED_MESSAGE.to_string(),
ts: conversation::now_ts(),
})
.map_err(Error::conversation)?;
}
if !matches!(
conversation.records.last(),
Some(Record::Assistant { content, tool_calls, .. })
if content == TURN_CANCELLED_MESSAGE && tool_calls.is_empty()
) {
conversation
.append(Record::Assistant {
content: TURN_CANCELLED_MESSAGE.to_string(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: Vec::new(),
ts: conversation::now_ts(),
})
.map_err(Error::conversation)?;
}
Ok(conversation)
}
fn pending_tool_calls(records: &[Record]) -> Vec<(String, String)> {
let mut pending = Vec::new();
for record in records {
match record {
Record::Assistant { tool_calls, .. } => {
pending = tool_calls
.iter()
.map(|call| (call.id.clone(), call.name.clone()))
.collect();
}
Record::Tool { tool_call_id, .. } => {
pending.retain(|(id, _)| id != tool_call_id);
}
Record::User { .. } => pending.clear(),
_ => {}
}
}
pending
}
+3
View File
@@ -6,14 +6,17 @@ pub mod cli;
pub mod config;
pub mod conversation;
pub mod docs;
pub mod embedding;
pub mod error;
pub mod menu;
pub mod prelude;
pub mod prompt;
pub mod providers;
pub mod security;
pub mod setup;
pub mod tools;
pub mod ui;
pub mod update;
pub async fn run() -> anyhow::Result<()> {
app::run().await
+7
View File
@@ -0,0 +1,7 @@
//! Common imports for Cassady's experimental Rust embedding API.
pub use crate::access::AccessMode;
pub use crate::config::ReasoningEffort;
pub use crate::embedding::{
ApprovalRequest, ConversationInfo, Event, Session, SessionBuilder, Turn,
};
+1255
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -59,6 +59,7 @@ fn expected_bundled_docs_exist() {
"configuration.md",
"providers.md",
"access-modes.md",
"embedding.md",
"workflows.md",
"troubleshooting.md",
"platforms.md",
+315
View File
@@ -0,0 +1,315 @@
use cassady::access::AccessMode;
use cassady::config::ReasoningEffort;
use cassady::conversation::Record;
use cassady::embedding::{Event, SessionBuilder};
use serde_json::json;
use tempfile::tempdir;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sse(body: &str) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_raw(body.as_bytes().to_vec(), "text/event-stream")
}
fn content_sse(content: &str) -> ResponseTemplate {
sse(&format!(
"data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":{}}}}}]}}\r\n\r\ndata: [DONE]\r\n\r\n",
serde_json::to_string(content).unwrap()
))
}
fn tool_call_sse(id: &str, name: &str, arguments: &str) -> ResponseTemplate {
sse(&format!(
"data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"{id}\",\"type\":\"function\",\"function\":{{\"name\":\"{name}\",\"arguments\":{}}}}}]}}}}]}}\r\n\r\ndata: [DONE]\r\n\r\n",
serde_json::to_string(arguments).unwrap()
))
}
fn write_test_config(root: &std::path::Path, base_url: &str) {
std::fs::write(
root.join("providers.json"),
serde_json::to_string_pretty(&json!({
"providers": [{
"id": "test-provider",
"kind": "openai-compatible",
"base_url": base_url,
"api_key": "test-key",
"default_model": "test-model",
"models": ["test-model"]
}]
}))
.unwrap(),
)
.unwrap();
std::fs::write(
root.join("models.json"),
serde_json::to_string_pretty(&json!({
"models": [{
"id": "test-model",
"provider": "test-provider",
"context_length": 128,
"max_output_tokens": 64,
"reasoning": {
"supported": true,
"required": false,
"default_effort": "off",
"request_format": "reasoning_effort"
}
}]
}))
.unwrap(),
)
.unwrap();
std::fs::write(
root.join("config.json"),
serde_json::to_string_pretty(&json!({
"default_provider": "test-provider",
"default_model": "test-model",
"default_reasoning_effort": "off"
}))
.unwrap(),
)
.unwrap();
}
#[tokio::test]
async fn embedded_session_runs_turn_and_streams_events() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(content_sse("Hello from embedded Cassady."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::ReadOnly)
.reasoning_effort(ReasoningEffort::Off)
.build()
.await
.unwrap();
assert_eq!(session.model(), "test-model");
assert_eq!(session.access_mode(), AccessMode::ReadOnly);
let mut turn = session.start_turn("say hi").await.unwrap();
let mut streamed = String::new();
while let Some(event) = turn.next_event().await.unwrap() {
match event {
Event::AssistantChunk(chunk) => streamed.push_str(&chunk),
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await.unwrap();
assert_eq!(streamed, "Hello from embedded Cassady.");
assert!(session.records().iter().any(|record| matches!(
record,
Record::Assistant { content, .. } if content == "Hello from embedded Cassady."
)));
assert!(session.conversation_path().is_file());
}
#[tokio::test]
async fn builder_overrides_config_for_model_endpoint_key_mode_and_reasoning() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(body_string_contains("\"model\":\"test-model\""))
.and(body_string_contains("\"reasoning_effort\":\"low\""))
.respond_with(content_sse("Overrides worked."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), "https://wrong.example/v1");
let env_name = "CASSADY_EMBEDDING_TEST_KEY";
let old = std::env::var(env_name).ok();
std::env::set_var(env_name, "test-key-from-env");
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::WorkspaceEdit)
.model("test-model")
.base_url(server.uri())
.api_key_env(env_name)
.reasoning_effort(ReasoningEffort::Low)
.build()
.await
.unwrap();
assert_eq!(session.access_mode(), AccessMode::WorkspaceEdit);
assert_eq!(session.reasoning_effort(), ReasoningEffort::Low);
let mut turn = session.start_turn("check overrides").await.unwrap();
while let Some(event) = turn.next_event().await.unwrap() {
if matches!(event, Event::Finished) {
break;
}
}
let _session = turn.finish().await.unwrap();
if let Some(old) = old {
std::env::set_var(env_name, old);
} else {
std::env::remove_var(env_name);
}
}
#[tokio::test]
async fn embedded_session_can_resume_existing_conversation() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(content_sse("First turn."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::ReadOnly)
.build()
.await
.unwrap();
let mut turn = session.start_turn("first").await.unwrap();
while let Some(event) = turn.next_event().await.unwrap() {
if matches!(event, Event::Finished) {
break;
}
}
let session = turn.finish().await.unwrap();
let id = session.id().to_string();
let record_count = session.records().len();
let resumed = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.resume(&id)
.await
.unwrap();
assert_eq!(resumed.id(), id);
assert_eq!(resumed.records().len(), record_count);
assert!(resumed.resume_warning().is_none());
}
#[tokio::test]
async fn embedded_approval_flow_can_approve_shell() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(body_string_contains("exit code: 0"))
.respond_with(content_sse("Approved shell."))
.with_priority(1)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(tool_call_sse(
"call_shell",
"shell",
r#"{"command":"touch marker"}"#,
))
.with_priority(10)
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let marker = cwd.path().join("marker");
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::WorkspaceEdit)
.build()
.await
.unwrap();
let mut turn = session.start_turn("run shell").await.unwrap();
let mut saw_request = false;
let mut saw_resolved = false;
while let Some(event) = turn.next_event().await.unwrap() {
match event {
Event::ApprovalRequested(request) => {
saw_request = true;
assert_eq!(request.name, "shell");
assert!(!marker.exists());
turn.approve(&request.request_id).unwrap();
}
Event::ApprovalResolved { approved, .. } => {
saw_resolved = approved;
}
Event::Finished => break,
_ => {}
}
}
let session = turn.finish().await.unwrap();
assert!(saw_request);
assert!(saw_resolved);
assert!(marker.exists());
assert!(session.records().iter().any(|record| matches!(
record,
Record::Tool { name, ok, content, .. }
if name == "shell" && *ok && content.contains("exit code: 0")
)));
}
#[tokio::test]
async fn read_only_embedding_does_not_advertise_mutating_tools() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(content_sse("Readonly."))
.expect(1)
.mount(&server)
.await;
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
write_test_config(root.path(), &server.uri());
let session = SessionBuilder::new()
.config_root(root.path())
.cwd(cwd.path())
.access_mode(AccessMode::ReadOnly)
.build()
.await
.unwrap();
let mut turn = session.start_turn("inspect only").await.unwrap();
while let Some(event) = turn.next_event().await.unwrap() {
if matches!(event, Event::Finished) {
break;
}
}
let _session = turn.finish().await.unwrap();
let requests = server.received_requests().await.unwrap();
let body = String::from_utf8_lossy(&requests[0].body);
assert!(body.contains("\"name\":\"ls\""));
assert!(body.contains("\"name\":\"read\""));
assert!(body.contains("\"name\":\"grep\""));
assert!(!body.contains("\"name\":\"write\""));
assert!(!body.contains("\"name\":\"edit\""));
assert!(!body.contains("\"name\":\"shell\""));
}