Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37bc677bd | ||
|
|
6767bef88e | ||
|
|
cd6b2ca034 | ||
|
|
4687f835cb | ||
|
|
1ab982167b | ||
|
|
1c1eeb18f7 | ||
|
|
93348269a8 | ||
|
|
a59c9fbbae | ||
|
|
2845529682 | ||
|
|
bcbb9dfa1b | ||
|
|
7c84a54e6a | ||
|
|
71f84c03ce | ||
|
|
4d021fe2ab | ||
|
|
7c8cc9eac5 | ||
|
|
52c879e7c8 | ||
|
|
60dfdf3806 | ||
|
|
8ffc5284e3 | ||
|
|
7f0f1619af | ||
|
|
009a39d748 | ||
|
|
a3e4248957 | ||
|
|
df88563808 | ||
|
|
2077894896 | ||
|
|
6ec48a9d4a | ||
|
|
ccf6d79fa4 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the docs Markdown files into a static GitHub Pages site.
|
||||
|
||||
This intentionally avoids themed site generators and template files. Each Markdown
|
||||
file is converted to a minimal standalone HTML page, and relative .md links are
|
||||
rewritten to the generated .html filenames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import markdown
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
SITE_DIR = ROOT / "site"
|
||||
|
||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "toc"]
|
||||
HREF_RE = re.compile(r'href="([^"]+)"')
|
||||
|
||||
|
||||
def output_path(source: Path) -> Path:
|
||||
if source.name == "README.md":
|
||||
return SITE_DIR / "index.html"
|
||||
return SITE_DIR / f"{source.stem}.html"
|
||||
|
||||
|
||||
def page_title(text: str, fallback: str) -> str:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def rewrite_markdown_links(rendered: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
href = html.unescape(match.group(1))
|
||||
parts = urlsplit(href)
|
||||
if parts.scheme or parts.netloc or not parts.path.endswith(".md"):
|
||||
return match.group(0)
|
||||
|
||||
url_path = PurePosixPath(parts.path)
|
||||
if url_path.name == "README.md":
|
||||
new_path = str(url_path.with_name("index.html"))
|
||||
else:
|
||||
new_path = parts.path[:-3] + ".html"
|
||||
|
||||
new_href = urlunsplit(("", "", new_path, parts.query, parts.fragment))
|
||||
return f'href="{html.escape(new_href, quote=True)}"'
|
||||
|
||||
return HREF_RE.sub(replace, rendered)
|
||||
|
||||
|
||||
def render_page(source: Path) -> str:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
title = page_title(text, "Cassady docs")
|
||||
body = markdown.markdown(
|
||||
text,
|
||||
extensions=MARKDOWN_EXTENSIONS,
|
||||
output_format="html5",
|
||||
)
|
||||
body = rewrite_markdown_links(body)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
"<head>",
|
||||
' <meta charset="utf-8">',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
f" <title>{html.escape(title)}</title>",
|
||||
"</head>",
|
||||
"<body>",
|
||||
body,
|
||||
"</body>",
|
||||
"</html>",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def copy_static_assets() -> None:
|
||||
for item in DOCS_DIR.iterdir():
|
||||
if item.suffix == ".md":
|
||||
continue
|
||||
destination = SITE_DIR / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, destination)
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, destination)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if SITE_DIR.exists():
|
||||
shutil.rmtree(SITE_DIR)
|
||||
SITE_DIR.mkdir(parents=True)
|
||||
|
||||
for source in sorted(DOCS_DIR.glob("*.md")):
|
||||
output_path(source).write_text(render_page(source), encoding="utf-8")
|
||||
|
||||
copy_static_assets()
|
||||
(SITE_DIR / ".nojekyll").write_text("", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,16 @@
|
||||
name: CI
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'tests/**'
|
||||
- 'docs/**'
|
||||
- 'plans/**'
|
||||
- '*.md'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build.rs'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Publish Markdown site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/scripts/build-pages.py'
|
||||
- '.github/workflows/pages.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/scripts/build-pages.py'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Markdown site
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install Markdown converter
|
||||
run: python -m pip install Markdown
|
||||
|
||||
- name: Build site
|
||||
run: python .github/scripts/build-pages.py
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy Pages artifact
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1 +1,3 @@
|
||||
/target/
|
||||
/dist/
|
||||
/site/
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Release process: build artifacts and create a draft GitHub release
|
||||
|
||||
Use this process when preparing a Cassady (`cass`) release. Always create the GitHub release as a **draft** first; do not publish the final release unless the user explicitly asks.
|
||||
|
||||
### 1. Review prior releases for consistency
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
gh release list --repo owenqwenstarsky/cassady --limit 5
|
||||
gh release view --repo owenqwenstarsky/cassady --json tagName,body --jq '.tagName + "\n\n" + .body'
|
||||
```
|
||||
|
||||
Keep release notes consistent with the existing format:
|
||||
|
||||
- Title: `## Cassady vX.Y.Z`
|
||||
- One short summary paragraph.
|
||||
- `### Downloads` with four bullets in this order: macOS Apple Silicon, Linux x86_64, Linux ARM64, Windows x86_64.
|
||||
- Note that each archive contains both `cass` and `cassady`; mention SHA-256 files.
|
||||
- `### Highlights` with concise user-facing bullets.
|
||||
- Optional `### Upgrade notes` only when compatibility, config, or migration details matter.
|
||||
- `### Install from source` with a `cargo install --git ... --tag vX.Y.Z` command.
|
||||
- `### Verification` listing the exact test/build commands used.
|
||||
|
||||
### 2. Confirm the version and starting state
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
VERSION=$(awk -F\" '/^version = / { print $2; exit }' Cargo.toml)
|
||||
TAG="v${VERSION}"
|
||||
echo "$TAG"
|
||||
git log --oneline --decorate -20
|
||||
```
|
||||
|
||||
If the version is wrong, update `Cargo.toml` and `Cargo.lock` first, then commit that change before tagging. Release tags should point at the intended release commit on `main`.
|
||||
|
||||
### 3. Run verification before packaging
|
||||
|
||||
```sh
|
||||
cargo +stable test --locked --all-targets
|
||||
```
|
||||
|
||||
### 4. Build all release binaries
|
||||
|
||||
Prerequisites for cross-builds: stable Rust, `cargo-zigbuild`, Zig, and the needed Rust targets.
|
||||
|
||||
```sh
|
||||
rustup target add aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-pc-windows-gnu
|
||||
cargo install cargo-zigbuild --locked
|
||||
```
|
||||
|
||||
Build the same four targets used by previous releases:
|
||||
|
||||
```sh
|
||||
cargo +stable build --release --locked --target aarch64-apple-darwin
|
||||
cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu
|
||||
cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu
|
||||
cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
### 5. Rebuild the `dist/` artifacts
|
||||
|
||||
This creates the unpacked artifact directories, compressed archives, and checksum files for the current `$TAG`. Generate checksums from inside `dist/` so the `.sha256` files contain archive names without a `dist/` prefix.
|
||||
|
||||
```sh
|
||||
VERSION=$(awk -F\" '/^version = / { print $2; exit }' Cargo.toml)
|
||||
TAG="v${VERSION}"
|
||||
|
||||
rm -rf \
|
||||
"dist/cassady-${TAG}-aarch64-apple-darwin"* \
|
||||
"dist/cassady-${TAG}-x86_64-unknown-linux-gnu"* \
|
||||
"dist/cassady-${TAG}-aarch64-unknown-linux-gnu"* \
|
||||
"dist/cassady-${TAG}-x86_64-pc-windows-gnu"*
|
||||
|
||||
mkdir -p dist
|
||||
|
||||
for target in aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||
name="cassady-${TAG}-${target}"
|
||||
mkdir -p "dist/${name}"
|
||||
cp "target/${target}/release/cass" "dist/${name}/cass"
|
||||
cp "target/${target}/release/cassady" "dist/${name}/cassady"
|
||||
cp README.md "dist/${name}/README.md"
|
||||
tar -C dist -czf "dist/${name}.tar.gz" "${name}"
|
||||
done
|
||||
|
||||
win_target=x86_64-pc-windows-gnu
|
||||
win_name="cassady-${TAG}-${win_target}"
|
||||
mkdir -p "dist/${win_name}"
|
||||
cp "target/${win_target}/release/cass.exe" "dist/${win_name}/cass.exe"
|
||||
cp "target/${win_target}/release/cassady.exe" "dist/${win_name}/cassady.exe"
|
||||
cp README.md "dist/${win_name}/README.md"
|
||||
(cd dist && zip -qr "${win_name}.zip" "${win_name}")
|
||||
|
||||
(cd dist && for artifact in cassady-${TAG}-*.tar.gz cassady-${TAG}-*.zip; do
|
||||
shasum -a 256 "$artifact" > "${artifact}.sha256"
|
||||
done)
|
||||
```
|
||||
|
||||
Sanity-check the generated artifacts:
|
||||
|
||||
```sh
|
||||
ls -lh dist/cassady-${TAG}-*.tar.gz dist/cassady-${TAG}-*.zip dist/cassady-${TAG}-*.sha256
|
||||
for sum in dist/cassady-${TAG}-*.sha256; do (cd dist && shasum -a 256 -c "$(basename "$sum")"); done
|
||||
tar -tzf "dist/cassady-${TAG}-aarch64-apple-darwin.tar.gz" | head
|
||||
unzip -l "dist/cassady-${TAG}-x86_64-pc-windows-gnu.zip" | head
|
||||
```
|
||||
|
||||
### 6. Write the release notes
|
||||
|
||||
Create `dist/RELEASE_NOTES_${TAG}.md` using the same structure as prior releases. Use this template and replace the summary/highlights with the actual changes:
|
||||
|
||||
````md
|
||||
## Cassady vX.Y.Z
|
||||
|
||||
Cassady vX.Y.Z ...
|
||||
|
||||
### Downloads
|
||||
|
||||
- macOS Apple Silicon: `cassady-vX.Y.Z-aarch64-apple-darwin.tar.gz`
|
||||
- Linux x86_64: `cassady-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz`
|
||||
- Linux ARM64: `cassady-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz`
|
||||
- Windows x86_64: `cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip`
|
||||
|
||||
Each archive contains both `cass` and `cassady`. SHA-256 checksum files are included for every archive.
|
||||
|
||||
### Highlights
|
||||
|
||||
- ...
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
...
|
||||
|
||||
### Install from source
|
||||
|
||||
```sh
|
||||
cargo install --git https://github.com/owenqwenstarsky/cassady --tag vX.Y.Z
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Built and tested with:
|
||||
|
||||
```sh
|
||||
cargo +stable test --locked --all-targets
|
||||
cargo +stable build --release --locked --target aarch64-apple-darwin
|
||||
cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu
|
||||
cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu
|
||||
cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu
|
||||
```
|
||||
````
|
||||
|
||||
Check the notes before uploading:
|
||||
|
||||
```sh
|
||||
sed -n '1,220p' "dist/RELEASE_NOTES_${TAG}.md"
|
||||
```
|
||||
|
||||
### 7. Tag the release commit
|
||||
|
||||
Only tag after tests pass, artifacts are built, and release notes are ready.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
git status --short # should be clean except generated dist/ files
|
||||
git tag -a "$TAG" -m "Cassady ${TAG}"
|
||||
git push origin "$TAG"
|
||||
```
|
||||
|
||||
If the tag already exists, stop and ask before deleting or moving it.
|
||||
|
||||
### 8. Create the GitHub release as a draft
|
||||
|
||||
Upload only the current version's archives and checksum files. Keep `--draft` and `--verify-tag` in the command.
|
||||
|
||||
```sh
|
||||
gh release create "$TAG" \
|
||||
"dist/cassady-${TAG}-aarch64-apple-darwin.tar.gz" \
|
||||
"dist/cassady-${TAG}-aarch64-apple-darwin.tar.gz.sha256" \
|
||||
"dist/cassady-${TAG}-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
"dist/cassady-${TAG}-x86_64-unknown-linux-gnu.tar.gz.sha256" \
|
||||
"dist/cassady-${TAG}-aarch64-unknown-linux-gnu.tar.gz" \
|
||||
"dist/cassady-${TAG}-aarch64-unknown-linux-gnu.tar.gz.sha256" \
|
||||
"dist/cassady-${TAG}-x86_64-pc-windows-gnu.zip" \
|
||||
"dist/cassady-${TAG}-x86_64-pc-windows-gnu.zip.sha256" \
|
||||
--repo owenqwenstarsky/cassady \
|
||||
--title "Cassady ${TAG}" \
|
||||
--notes-file "dist/RELEASE_NOTES_${TAG}.md" \
|
||||
--draft \
|
||||
--verify-tag
|
||||
```
|
||||
|
||||
Verify the draft:
|
||||
|
||||
```sh
|
||||
gh release view "$TAG" --repo owenqwenstarsky/cassady --json tagName,name,isDraft,isPrerelease,assets --jq .
|
||||
```
|
||||
|
||||
Do **not** publish the draft or mark it as the final/latest release unless the user explicitly asks.
|
||||
|
||||
## Roadmap process: write a new release entry in `ROADMAP.md`
|
||||
|
||||
Use this process when planning a future Cassady release. Follow the existing newest-first format in `ROADMAP.md` so new entries look like prior releases.
|
||||
|
||||
### 1. Review the existing roadmap format
|
||||
|
||||
```sh
|
||||
sed -n '1,220p' ROADMAP.md
|
||||
ls plans
|
||||
```
|
||||
|
||||
Current conventions:
|
||||
|
||||
- Keep the `# Cassady (Cass) Roadmap` title at the top.
|
||||
- Add the newest release immediately below the title, above older releases.
|
||||
- Use headings like `## vX.Y.Z — Short Theme` for planned releases.
|
||||
- Add `✅ Completed` to the heading only after the release is actually complete.
|
||||
- Start with a short paragraph: `This release focuses on ...`.
|
||||
- If there is a detailed plan, reference it with a sentence like: See `plans/PLAN_FILE.md`.
|
||||
- Group work under `###` area headings such as `Interactive Setup`, `Agent Control`, or `Safety and Reviewability`.
|
||||
- Use checklist items: `- [ ]` for planned work, `- [x]` for completed work.
|
||||
- Write main tasks as bold, user-facing outcomes: `**Add a first-run setup wizard.** ...`.
|
||||
- Use indented bullets for scope details, constraints, and explicit deferrals.
|
||||
|
||||
### 2. Decide the release scope
|
||||
|
||||
Before editing `ROADMAP.md`, identify:
|
||||
|
||||
- Version number: `vX.Y.Z`.
|
||||
- Short theme: a concise release name after the em dash.
|
||||
- One-paragraph goal: what the release changes for users.
|
||||
- 2-4 major areas to group the work.
|
||||
- Concrete checklist tasks under each area.
|
||||
- Any intentionally deferred work, especially broad integrations or risky scope.
|
||||
- Optional plan file under `plans/` if the release needs deeper implementation detail.
|
||||
|
||||
Prefer roadmap items that describe outcomes and acceptance criteria, not implementation minutiae. Keep them concise enough to scan.
|
||||
|
||||
### 3. Insert the new entry
|
||||
|
||||
Place the new release section directly under the top-level title:
|
||||
|
||||
```md
|
||||
# Cassady (Cass) Roadmap
|
||||
|
||||
## vX.Y.Z — Short Release Theme
|
||||
|
||||
This release focuses on ... See `plans/VX_Y_Z_SHORT_PLAN.md`.
|
||||
|
||||
### Area Name
|
||||
|
||||
- [ ] **User-facing task title.** Describe the outcome in one sentence.
|
||||
- Add key behavior or acceptance criteria.
|
||||
- Note constraints or non-goals.
|
||||
|
||||
- [ ] **Second task title.** Describe the next outcome.
|
||||
- Include compatibility, docs, tests, or safety requirements when relevant.
|
||||
|
||||
### Another Area
|
||||
|
||||
- [ ] **Another task title.** Describe the outcome.
|
||||
- Keep details specific and checkable.
|
||||
|
||||
## vPrevious — Existing Theme ✅ Completed
|
||||
```
|
||||
|
||||
If there is no detailed plan file yet, omit the `See ...` sentence rather than linking to a nonexistent file.
|
||||
|
||||
### 4. Keep old entries stable
|
||||
|
||||
- Do not reorder completed releases except to insert the new release at the top.
|
||||
- Do not rewrite old completed scopes unless correcting a clear error.
|
||||
- Use `[x]` only for work that has landed.
|
||||
- Add `✅ Completed` only when the release has shipped or the user explicitly asks to mark it complete.
|
||||
- Keep wording consistent with earlier entries: concise headings, bold task names, and nested details.
|
||||
|
||||
### 5. Check the edit
|
||||
|
||||
```sh
|
||||
sed -n '1,180p' ROADMAP.md
|
||||
git diff -- ROADMAP.md
|
||||
```
|
||||
|
||||
## Plan process: write implementation plans in `plans/`
|
||||
|
||||
Use this process when creating or updating an implementation plan. All project plans belong in the `plans/` directory; do not put new plan documents at the repository root.
|
||||
|
||||
### 1. Review previous plans first
|
||||
|
||||
```sh
|
||||
ls plans
|
||||
sed -n '1,240p' plans/V0_2_2_ONBOARDING_SETUP_WIZARD_PLAN.md
|
||||
sed -n '1,220p' plans/V0_2_1_MESSAGE_RENDERING_POLISH_PLAN.md
|
||||
sed -n '1,220p' plans/V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md
|
||||
```
|
||||
|
||||
Also read any plan that is directly related to the new work. For example, read `plans/SECURITY_ACCESS_MODES_PLAN.md` before planning access-control work, or `plans/PROVIDER_MODEL_CONFIG_PLAN.md` before planning provider/config changes.
|
||||
|
||||
### 2. Choose the plan filename
|
||||
|
||||
Write new plans under `plans/` using uppercase snake-case names:
|
||||
|
||||
- Release-scoped plan: `plans/VX_Y_Z_SHORT_THEME_PLAN.md`, e.g. `plans/V0_2_3_CONTEXT_MANAGEMENT_PLAN.md`.
|
||||
- Feature/follow-up plan: `plans/FEATURE_OR_AREA_PLAN.md`, e.g. `plans/SECURITY_ACCESS_MODES_PLAN.md`.
|
||||
- Follow-up to an existing release plan: include the version and specific topic, e.g. `plans/V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md`.
|
||||
|
||||
Prefer one focused plan per coherent feature or release theme. Do not edit `plans/PLAN.md` for new release work; it is the historical MVP plan.
|
||||
|
||||
### 3. Match the existing plan structure
|
||||
|
||||
Most plans should use this shape, adapted to the task size:
|
||||
|
||||
````md
|
||||
# vX.Y.Z Short Theme Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
State the user-facing outcome in a short paragraph. If helpful, add a success statement.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Concrete included work.
|
||||
- Supported behavior and user-visible changes.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Explicit non-goals and deferred work.
|
||||
- Integrations or risky scope that should not be pulled into this plan.
|
||||
|
||||
## Context or Current State
|
||||
|
||||
Describe the relevant files, modules, existing behavior, and constraints.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Principle that guides tradeoffs.
|
||||
2. Safety, compatibility, or UX constraint.
|
||||
3. Simplicity or deferral rule.
|
||||
|
||||
## Design
|
||||
|
||||
Describe the proposed behavior and architecture. Include tables, examples, CLI output, JSON shapes, or module/type sketches when they make implementation clearer.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. First concrete code/docs step.
|
||||
2. Next step.
|
||||
3. Final integration step.
|
||||
|
||||
## Tests
|
||||
|
||||
- Specific unit/integration tests to add or update.
|
||||
- Manual checks when automated tests are not enough.
|
||||
|
||||
## Documentation
|
||||
|
||||
- README, bundled docs, release notes, or roadmap updates required.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Checkable condition that proves the plan is done.
|
||||
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||
````
|
||||
|
||||
For small follow-ups, it is okay to use the shorter style from `V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md`: `Context`, `Goal`, `Scope`, `Design`, `Implementation Steps`, and `Acceptance Criteria`.
|
||||
|
||||
### 4. Writing guidelines
|
||||
|
||||
- Keep plans implementation-oriented but readable by a future agent.
|
||||
- Be explicit about in-scope vs out-of-scope work to prevent scope creep.
|
||||
- Mention exact files/modules when known, such as `src/ui/render.rs` or `src/config.rs`.
|
||||
- Include examples of expected CLI output, JSON, or UI text when behavior matters.
|
||||
- Add compatibility and migration notes when config, storage, provider behavior, or public commands change.
|
||||
- Add docs and tests sections for any user-facing change.
|
||||
- Prefer ordered implementation steps over vague tasks.
|
||||
- Do not mark roadmap items complete just because a plan was written.
|
||||
|
||||
### 5. Check the plan
|
||||
|
||||
```sh
|
||||
sed -n '1,260p' plans/YOUR_PLAN_FILE.md
|
||||
git diff -- plans/YOUR_PLAN_FILE.md ROADMAP.md
|
||||
```
|
||||
|
||||
## General project notes
|
||||
|
||||
- This is a Rust project. Use `cargo test --locked --all-targets` before handing off code changes when practical.
|
||||
- Keep release notes user-facing and concise; avoid dumping raw commit logs.
|
||||
- `dist/` contains generated release artifacts. Rebuild current-version files rather than editing archives by hand.
|
||||
Generated
+974
-102
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
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"
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
# Cassady / Cass
|
||||
|
||||
Cassady (`cass`) is a minimal Rust terminal coding agent with a looped chat UI, filesystem tools, access modes, JSONL conversation persistence, and OpenAI-compatible LLM support. The default endpoint is Fireworks.
|
||||
Cassady (`cass`) is a terminal coding agent written in Rust. It runs an interactive chat in your project, can inspect files, apply exact edits, run shell commands when the active safety mode allows them, and persist sessions for later resume. Cassady talks to OpenAI-compatible providers and a built-in ChatGPT Codex provider preset.
|
||||
|
||||
## Install
|
||||
The project installs two equivalent commands, `cass` and `cassady`; examples use `cass`.
|
||||
|
||||
## Current scope and limitations
|
||||
|
||||
- Provider support includes OpenAI-compatible chat/completions APIs plus the `ChatGPT Codex` preset for users already signed in to Codex.
|
||||
- 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.
|
||||
- `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
|
||||
|
||||
```sh
|
||||
cargo install --path .
|
||||
@@ -10,94 +21,136 @@ cargo install --path .
|
||||
|
||||
This installs both commands:
|
||||
|
||||
```sh
|
||||
cass --version
|
||||
cassady --version
|
||||
```
|
||||
|
||||
## First use
|
||||
|
||||
Start Cassady in a project directory:
|
||||
|
||||
```sh
|
||||
cass
|
||||
cassady
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
By default Cass creates `~/.cass/providers.json` and `~/.cass/models.json` with Fireworks configured:
|
||||
|
||||
- base URL: `https://api.fireworks.ai/inference/v1`
|
||||
- model: `accounts/fireworks/models/qwen3p7-plus`
|
||||
- API key: `"$FIREWORKS_API_KEY"`
|
||||
|
||||
Set your key:
|
||||
|
||||
```sh
|
||||
export FIREWORKS_API_KEY=...
|
||||
```
|
||||
|
||||
User preferences live at `~/.cass/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_model": "accounts/fireworks/models/qwen3p7-plus",
|
||||
"default_access_mode": "read-only",
|
||||
"show_reasoning": false
|
||||
}
|
||||
```
|
||||
|
||||
Provider connection details belong in `~/.cass/providers.json`. Model metadata belongs in `~/.cass/models.json`. API keys may be literal strings or environment-variable references like `"$FIREWORKS_API_KEY"`.
|
||||
|
||||
Validate config with:
|
||||
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup or provider login explicitly:
|
||||
|
||||
```sh
|
||||
cass login
|
||||
cass setup
|
||||
cass check
|
||||
cass update --check
|
||||
cass
|
||||
```
|
||||
|
||||
Extra global instructions can be placed in `~/.cass/global.md`.
|
||||
The setup wizard lets you choose one or more providers. OpenAI-compatible providers use an API key environment variable and can discover models from `GET /models` when the key is available. `ChatGPT Codex` uses your existing local Codex login (`~/.codex/auth.json`) and the Codex responses endpoint instead of a Cassady API-key environment variable.
|
||||
|
||||
Bundled documentation from this build is embedded into the binary and installed to `~/.cass/docs` on startup. See `~/.cass/docs/configuration.md` for full configuration docs.
|
||||
|
||||
## Usage
|
||||
Set your provider key in the shell where you run Cassady. For example, on macOS/Linux:
|
||||
|
||||
```sh
|
||||
cass [--model MODEL] [--base-url URL] [--api-key-env ENV] [--cwd PATH]
|
||||
export OPENAI_API_KEY=...
|
||||
```
|
||||
|
||||
In PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:OPENAI_API_KEY = "..."
|
||||
```
|
||||
|
||||
Run `cass check` any time to validate JSON config, provider/model references, active model resolution, and API key availability.
|
||||
|
||||
## Everyday usage
|
||||
|
||||
```sh
|
||||
cass [--model MODEL] [--cwd PATH]
|
||||
cass --resume <chat-id>
|
||||
cass --resume
|
||||
cass check
|
||||
cass login
|
||||
cass logout
|
||||
cass setup
|
||||
cass update
|
||||
```
|
||||
|
||||
`cass --resume` without an ID lists chats for the current directory.
|
||||
`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.
|
||||
|
||||
## Keys
|
||||
Common in-chat commands:
|
||||
|
||||
- Type `/`: show command autocomplete, including command arguments like `/model <model>` and `/new`
|
||||
- `Up`/`Down`: move through an autocomplete menu
|
||||
- `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
|
||||
- `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
|
||||
- `PageUp`/`PageDown`: transcript scroll
|
||||
- `Ctrl-C` twice within 1.5 seconds: exit
|
||||
- `/branch` or `/restore`: open the branch/restore menu.
|
||||
- `/login`: configure or update provider login settings.
|
||||
- `/logout`: remove saved provider config and associated model entries.
|
||||
- `/model <model>`: switch to a model from `~/.cass/models.json`.
|
||||
- `/new`: create a new chat for the current directory.
|
||||
- `/resume <chat>`: resume a saved chat for the current directory.
|
||||
- `/status`: show chat id, model, mode, cwd, record count, and current status.
|
||||
|
||||
## Commands
|
||||
Helpful keys:
|
||||
|
||||
- `cass check`: validate Cass config files
|
||||
- `/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
|
||||
- `/status`: show current chat status
|
||||
- `/`: show command autocomplete.
|
||||
- `Enter`: send the message or accept an autocomplete item.
|
||||
- `Ctrl-J` or `Ctrl-Enter`: insert a newline.
|
||||
- `Shift-Tab`: cycle access mode while idle.
|
||||
- `Tab`: cycle reasoning effort while idle.
|
||||
- `Ctrl-O`: toggle compact/full tool output display.
|
||||
- `Ctrl-Shift-R` or `Ctrl-R`: toggle reasoning display.
|
||||
- `Esc`: request turn cancellation while a turn is running; while idle, press twice within 1.5 seconds to open branch/restore.
|
||||
- `Ctrl-C` twice within 1.5 seconds: exit.
|
||||
|
||||
On exit Cass prints:
|
||||
## Safety model
|
||||
|
||||
```text
|
||||
Resume this chat with: cass --resume <id>
|
||||
Cassady exposes tools according to the active access mode:
|
||||
|
||||
- `read-only`: read/list/search the workspace and bundled docs. No edits or shell commands.
|
||||
- `workspace-edit`: read/list/search plus write/edit inside the launch workspace. Shell commands require explicit approval.
|
||||
- `full-access`: read/write/edit broadly under your OS permissions and run shell commands without the workspace-edit approval prompt. Bundled docs remain read-only.
|
||||
|
||||
Use `--readonly`, `--workspace-edit`, or `--full-access` to choose a mode at launch, or press `Shift-Tab` while idle.
|
||||
|
||||
## Branch and restore
|
||||
|
||||
Press `Esc` twice while idle, or type `/branch`, to browse the current conversation's branch family. Selecting an earlier user message, assistant message, tool call, or tool result creates a new branch conversation instead of truncating the original chat. The menu also lets you switch back to related branches later.
|
||||
|
||||
Conversation-only branching is the safe default. If you choose file restore, Cassady restores only file changes it tracked from successful `write` and `edit` tools. Shell commands, manual edits, unsupported files, and hash conflicts are reported or skipped rather than overwritten blindly.
|
||||
|
||||
## Configuration and docs
|
||||
|
||||
Cassady stores user-editable files in `~/.cass`:
|
||||
|
||||
- `config.json`: active defaults and preferences.
|
||||
- `providers.json`: provider base URLs and API key references.
|
||||
- `models.json`: model metadata.
|
||||
- `global.md`: optional global instructions added to new chat system prompts when they fit the active request; they cannot override access modes, tool denials, approvals, or workspace boundaries.
|
||||
- `docs/`: bundled documentation installed from the current binary.
|
||||
|
||||
API key references should usually be written as environment variables such as `"$OPENAI_API_KEY"`. The `ChatGPT Codex` provider is different: it stores no key in `~/.cass` and reads the bearer token from local Codex auth at check/request time.
|
||||
|
||||
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?;
|
||||
```
|
||||
|
||||
## Tools
|
||||
See [Experimental Rust embedding API](docs/embedding.md) for session creation, streamed events, approval handling, cancellation, and current limitations.
|
||||
|
||||
Tool calls are shown compactly by default; press `Ctrl-O` to expand full tool output.
|
||||
## More documentation
|
||||
|
||||
Reasoning is hidden by default unless `show_reasoning` is enabled; press `Ctrl-Shift-R` to toggle it. Press `Tab` to choose the reasoning effort for future turns. Model metadata controls whether reasoning is supported or required and how the effort is sent to the provider. When providers stream reasoning fields, Cass persists that reasoning and sends it back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`.
|
||||
|
||||
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.
|
||||
|
||||
The `shell` tool is available in full-access mode and runs shell commands in the launch working directory.
|
||||
- [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)
|
||||
- [Glossary](docs/glossary.md)
|
||||
|
||||
+451
@@ -1,5 +1,358 @@
|
||||
# Cassady (Cass) Roadmap
|
||||
|
||||
## v0.3.0 — ChatGPT Codex Provider
|
||||
|
||||
This release focuses on letting users who are already signed in to Codex with a ChatGPT subscription use that account from Cassady. `ChatGPT Codex` becomes a provider preset that calls the Codex responses endpoint and reads its bearer token from local Codex auth instead of an API-key environment variable. See `plans/V0_3_0_CHATGPT_CODEX_PROVIDER_PLAN.md`.
|
||||
|
||||
### Provider Setup
|
||||
|
||||
- [x] **Add a ChatGPT Codex provider preset.** Make `ChatGPT Codex` available from `cass login`, `/login`, and first-run setup as a distinct provider option.
|
||||
- Use provider id `chatgpt-codex` and endpoint `https://chatgpt.com/backend-api/codex/responses`.
|
||||
- Skip the normal API-key environment-variable prompt for this preset.
|
||||
- Prefer the model configured in local Codex config when available, with manual model entry as a fallback.
|
||||
|
||||
- [x] **Read local Codex auth safely.** Resolve the bearer token from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` at check/request time.
|
||||
- Support the local Codex `tokens.access_token` shape without copying the token into `~/.cass`.
|
||||
- Give clear recovery steps when the user has not run `codex login`, signed in to the Codex app, or has an expired/missing token.
|
||||
|
||||
### Provider Runtime
|
||||
|
||||
- [x] **Add a ChatGPT Codex responses client.** Route `chatgpt-codex` providers to the exact Codex responses endpoint instead of the OpenAI-compatible `/chat/completions` path.
|
||||
- Translate Cassady messages, tools, tool calls, and tool outputs to the endpoint's expected responses format.
|
||||
- Stream assistant text, safe reasoning summaries when available, and function-call deltas back into the existing agent loop.
|
||||
|
||||
- [x] **Keep OpenAI-compatible providers unchanged.** Refactor provider dispatch only as much as needed to support the new provider kind.
|
||||
- Existing provider config, setup, model discovery, API-key env vars, `/model`, and `cass check` behavior should continue to work.
|
||||
- Avoid leaking ChatGPT access tokens in errors, logs, transcripts, or config files.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document ChatGPT Codex prerequisites and caveats.** Update README and bundled docs for setup, config examples, `cass check`, troubleshooting, and the distinction between ChatGPT subscription-backed access and API-key providers.
|
||||
- Make clear that Cassady uses an existing Codex login and does not implement its own browser login or token refresh flow in this release.
|
||||
- Note that the ChatGPT backend endpoint may change outside Cassady's control.
|
||||
|
||||
- [x] **Test Codex auth and provider behavior.** Cover Codex auth fixtures, config validation, setup catalog behavior, provider dispatch, streaming response parsing, tool calls, and secret redaction.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
|
||||
|
||||
## v0.2.9 — Provider Login Management
|
||||
|
||||
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users can add or update OpenAI-compatible provider/model settings with `cass login` or `/login`, and remove saved providers and their associated models with `cass logout` or `/logout`. See `plans/V0_2_9_PROVIDER_LOGIN_MANAGEMENT_PLAN.md`.
|
||||
|
||||
### Login Commands
|
||||
|
||||
- [x] **Add login commands for provider setup.** Make `cass login` and `/login` open the provider setup flow so users can configure providers without remembering that setup is the underlying implementation.
|
||||
- Reuse the existing provider catalog, model discovery, model capability prompts, and safe JSON writes.
|
||||
- Keep `/login` idle-only and reload active provider/model config after the menu closes.
|
||||
|
||||
- [x] **Keep existing setup behavior intact.** Preserve `cass setup` and first-run setup while making login language feel natural for account/provider management.
|
||||
- Direct `cass login` should save configuration and exit rather than unexpectedly starting a chat.
|
||||
- Existing setup validation and missing API key guidance should continue to apply.
|
||||
|
||||
### Logout Commands
|
||||
|
||||
- [x] **Add a safe provider removal menu.** Make `cass logout` and `/logout` let users choose saved providers to remove from `providers.json`.
|
||||
- Remove associated `models.json` entries for selected providers.
|
||||
- Confirm the removal before writing changes.
|
||||
|
||||
- [x] **Repair active defaults after removal.** Ensure `config.json` never points at a provider/model that was just removed.
|
||||
- Select a valid remaining provider/model when possible.
|
||||
- Clear active provider/model defaults when no providers remain so the next startup offers login/setup.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document provider login and logout workflows.** Update command, configuration, and workflow docs with the new shell and in-chat commands.
|
||||
- Clarify that logout removes Cassady provider config, not environment variables or external provider accounts.
|
||||
|
||||
- [x] **Test provider management behavior.** Cover provider/model removal, active default repair, local command parsing, and autocomplete.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
|
||||
|
||||
## v0.2.8 — Conversation Branch and Restore
|
||||
|
||||
This release focuses on making conversation recovery safe and explorable. Pressing `Esc` twice while idle opens a branch/restore menu where users can browse prior user messages, assistant messages, and tool calls, create a new branch from a selected checkpoint, and optionally restore Cassady-tracked file edits without destroying the original conversation. See `plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md`.
|
||||
|
||||
### Branch Navigation
|
||||
|
||||
- [x] **Open a branch/restore menu with double Esc.** Add an idle `Esc`-twice shortcut that mirrors the discoverability of double `Ctrl-C` while preserving current busy-turn cancellation and approval-denial behavior.
|
||||
- Keep draft input intact when the menu is opened or cancelled.
|
||||
- Provide clear status text after the first `Esc` so users know a second press opens branch/restore.
|
||||
|
||||
- [x] **Browse checkpoints across the related branch family.** Show user messages, assistant messages, tool-call requests, and tool results from the current chat and related branches.
|
||||
- Include enough preview text, tool names, paths, timestamps, and branch labels to choose the right point.
|
||||
- Allow switching back to the original conversation or another existing branch from the same menu.
|
||||
|
||||
### Safe Conversation Branching
|
||||
|
||||
- [x] **Create branches instead of destructive reverts.** Selecting a checkpoint should write a new conversation JSONL with parent/checkpoint metadata, leaving the source conversation unchanged.
|
||||
- Support repeated branching so users can return to the menu later and branch or switch again.
|
||||
- Keep older conversations without branch metadata loadable.
|
||||
|
||||
- [x] **Handle tool-call checkpoints cleanly.** Branching at a specific tool call or tool result should preserve a valid provider message history.
|
||||
- Repair partial multi-tool assistant turns with synthetic cancelled/omitted tool results where needed.
|
||||
- Add tests for branching at user, assistant, and tool boundaries.
|
||||
|
||||
### File Edit Restoration
|
||||
|
||||
- [x] **Journal Cassady file edits with restorable snapshots.** Record successful `write` and `edit` tool mutations outside the model-visible transcript with before/after hashes and snapshots.
|
||||
- Keep restore support limited to Cassady-tracked file tools; warn that shell commands and manual edits are not automatically reversible.
|
||||
- Store enough data to restore both backward and forward between tracked checkpoints.
|
||||
|
||||
- [x] **Offer explicit conversation-only or conversation-plus-files restore actions.** Make conversation-only branching the safe default, and require confirmation before changing workspace files.
|
||||
- Preview files to update or delete, detect hash conflicts, and refuse unsafe overwrites by default.
|
||||
- Use atomic writes for restored files and preserve clear status/transcript messages for skipped or conflicted paths.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document branch/restore workflows and limitations.** Update README and bundled docs with the double-`Esc` shortcut, menu controls, branch semantics, and file-restore safety model.
|
||||
- Include troubleshooting for restore conflicts and unsupported shell/manual filesystem changes.
|
||||
|
||||
- [x] **Test the branch and restore model.** Cover branch metadata, checkpoint extraction, tool-call repair, edit journaling, restore planning, and keybinding behavior where practical.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before release.
|
||||
|
||||
## 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`.
|
||||
|
||||
### Prompt Structure and Content
|
||||
|
||||
- [x] **Restructure the prompt into clear sections.** Replace the current compact prompt with a polished, scannable structure that explains identity, operating principles, tool use, editing, safety, and response style in a predictable order.
|
||||
- Keep headings short and model-friendly so the prompt is easy to follow during long sessions.
|
||||
- Preserve the existing split between the reusable base prompt, user global instructions, and runtime constraints.
|
||||
- Avoid duplicating long documentation that already lives in README or bundled docs.
|
||||
|
||||
- [x] **Add enough product context for intuitive behavior.** Teach the model what Cassady is, how the terminal chat works, and what the user can see without over-explaining implementation details.
|
||||
- Explain that tool calls, tool results, diffs, approvals, and streamed assistant text are visible in the transcript.
|
||||
- Clarify that Cassady is a coding assistant for real project work, so it should inspect before changing files, make targeted edits, and summarize outcomes honestly.
|
||||
- Include guidance for asking focused follow-up questions only when necessary, instead of over-planning or guessing.
|
||||
|
||||
- [x] **Keep the prompt intentionally compact.** Aim for roughly 900-1,100 tokens for the normal effective system prompt, including runtime access-mode guidance but excluding user-provided global instructions.
|
||||
- Prefer dense, high-signal instructions over broad lists of examples.
|
||||
- Remove redundant wording when new guidance overlaps with existing safety or response rules.
|
||||
- Add a lightweight test or snapshot check so future prompt changes do not accidentally grow far beyond the intended size.
|
||||
|
||||
### Tool, Editing, and Safety Guidance
|
||||
|
||||
- [x] **Improve tool-use instructions.** Make the prompt explicit about when to read, grep, edit, write, and shell while still letting the model choose the right tool for the task.
|
||||
- Encourage targeted inspection before edits and `grep`/search before opening large or unknown files.
|
||||
- Explain that the model should request tools directly when useful; Cassady will enforce access policy, denials, and approval prompts at runtime.
|
||||
- Remind the model not to claim a tool succeeded until the tool result confirms it.
|
||||
|
||||
- [x] **Sharpen editing guidance.** Make file-change behavior safer and more reliable, especially for exact-text edits.
|
||||
- Prefer `edit` for focused modifications and `write` only for new files or intentional full rewrites.
|
||||
- Instruct the model to keep replacements minimal, unique, and non-overlapping.
|
||||
- Encourage running or suggesting relevant tests after meaningful code changes.
|
||||
|
||||
- [x] **Make access-mode behavior easy for the model to follow.** Rewrite read-only, workspace-edit, and full-access guidance in plain language that maps directly to available tools.
|
||||
- Keep workspace and bundled-doc boundaries clear.
|
||||
- State that shell approval is handled by Cassady's UI rather than by asking for permission in chat.
|
||||
- Preserve conservative behavior when a task requires permissions the current mode does not allow.
|
||||
|
||||
### Validation and Documentation
|
||||
|
||||
- [x] **Add prompt-focused tests.** Verify that the generated prompt includes the required sections, preserves global instructions, reflects the active access mode, and stays within the intended size range.
|
||||
- Cover read-only, workspace-edit, and full-access effective prompts.
|
||||
- Include a regression check for prompt ordering so runtime constraints remain near the end.
|
||||
|
||||
- [x] **Update user-facing references to global instructions.** Refresh docs only where needed to explain how `~/.cass/global.md` fits into the structured prompt.
|
||||
- Avoid exposing the full internal prompt in documentation.
|
||||
- Mention that user global instructions are respected unless they conflict with runtime safety constraints.
|
||||
|
||||
## v0.2.3 — Documentation and README Refresh ✅ Completed
|
||||
|
||||
This release focuses on making Cassady understandable, trustworthy, and easy to operate by rewriting the README and bringing all bundled documentation up to date with the current CLI behavior. The work should cover user-facing documentation only; broad CLI feature work and Windows-specific runtime improvements are deferred to the planned Windows CLI usability work. See `plans/V0_2_3_DOCUMENTATION_README_REFRESH_PLAN.md`.
|
||||
|
||||
### README Rewrite
|
||||
|
||||
- [x] **Rewrite the README around the current Cassady experience.** Replace stale or incomplete sections with a clear, accurate guide to what Cassady is, who it is for, and how to start using it.
|
||||
- Add a concise product summary, core capabilities, supported workflows, and current limitations.
|
||||
- Document both `cass` and `cassady` command names where relevant.
|
||||
- Keep examples aligned with the current setup wizard, active provider/model configuration, access modes, tools, and TUI behavior.
|
||||
- Remove outdated MVP language, obsolete commands, and instructions that no longer match the code.
|
||||
|
||||
- [x] **Add a complete first-use walkthrough.** Make the README guide a new user from launching the CLI to a successful first chat without requiring them to infer missing steps.
|
||||
- Cover first-run setup, `cass setup`, `cass check`, provider selection, API key environment variables, model discovery, and manual model entry fallback.
|
||||
- Include copy/paste-ready examples for common providers without exposing secrets or implying one provider is required.
|
||||
- Explain what happens when setup is incomplete and how the user should recover.
|
||||
|
||||
- [x] **Document everyday workflows.** Add practical examples for the CLI actions users are most likely to perform after setup.
|
||||
- Starting a chat in a project workspace.
|
||||
- Asking Cassady to inspect files, explain code, propose edits, and apply edits.
|
||||
- Reviewing tool calls, collapsed tool output, edit diffs, and assistant Markdown rendering.
|
||||
- Cancelling a turn, recovering after cancellation, and exiting cleanly.
|
||||
|
||||
### Reference Documentation
|
||||
|
||||
- [x] **Create or refresh the CLI command reference.** Document all supported commands, flags, aliases, and expected output modes in one place.
|
||||
- Include `cass`, `cassady`, `setup`, `check`, chat startup behavior, config overrides, access-mode flags, and any non-interactive/script-friendly commands.
|
||||
- Show when commands are interactive versus non-interactive.
|
||||
- Keep examples shell-neutral where possible, and label platform-specific syntax when needed.
|
||||
|
||||
- [x] **Rewrite the configuration reference.** Explain where config lives, how active provider/model selection works, and how users should safely edit or validate config.
|
||||
- Document provider entries, model entries, active defaults, API key environment variable names, custom OpenAI-compatible providers, and model IDs.
|
||||
- Explain precedence between config files, environment variables, command-line overrides, and setup wizard changes.
|
||||
- Include valid example config snippets and common invalid configurations with fixes.
|
||||
|
||||
- [x] **Document providers and model setup thoroughly.** Add a dedicated guide for built-in OpenAI-compatible providers and custom provider setup.
|
||||
- List supported built-in providers, base URLs, expected API key env vars, and any known model-discovery limitations.
|
||||
- Explain the difference between provider configuration, model selection, and API key availability.
|
||||
- Document manual model entry, provider health checks, and how `cass check` reports provider problems.
|
||||
- Explicitly note protocols or providers that are not supported yet to avoid user confusion.
|
||||
|
||||
- [x] **Document access modes and tool safety.** Make the security model understandable before users let Cassady inspect or edit a repository.
|
||||
- Explain `read-only`, `workspace-edit`, and `full-access` in user-facing terms.
|
||||
- Document what read, write, edit, shell, and bundled-doc access mean in each mode.
|
||||
- Explain shell approval prompts, optional destructive-operation confirmation, workspace boundaries, symlink handling, and edit diff review.
|
||||
- Add examples of denied operations and the exact kind of message a user should expect.
|
||||
|
||||
### Usage Guides and Troubleshooting
|
||||
|
||||
- [x] **Add troubleshooting for common failure modes.** Give users actionable fixes for the errors they are most likely to hit.
|
||||
- Missing API keys, invalid env vars, unreachable provider URLs, model discovery failures, unsupported model IDs, and rate/authentication errors.
|
||||
- Broken or incomplete config, unreadable config paths, invalid TOML/JSON if applicable, and permission problems.
|
||||
- Terminal rendering issues, non-interactive terminals, redirected output, shell command failures, and cancellation behavior.
|
||||
- File edit failures, failed exact-text replacements, binary/large files, line ending issues, and workspace access denials.
|
||||
|
||||
- [x] **Add task-oriented examples.** Include short, realistic examples that demonstrate how Cassady should be used on real projects.
|
||||
- Code explanation and navigation.
|
||||
- Safe file editing with diff review.
|
||||
- Running tests or build commands with shell approval.
|
||||
- Updating config or switching providers/models.
|
||||
- Resuming work after a failed provider request or cancelled turn.
|
||||
|
||||
- [x] **Document platform expectations without duplicating future Windows work.** Add accurate notes for macOS, Linux, and Windows users while keeping deep Windows CLI usability improvements scoped to the planned Windows CLI usability work.
|
||||
- Include path, shell, and environment-variable examples for each platform when documentation needs them.
|
||||
- Mark known Windows limitations clearly until the planned Windows CLI usability work lands.
|
||||
- Avoid promising installer, package manager, or auto-update behavior that is not implemented.
|
||||
|
||||
### Documentation Quality and Maintenance
|
||||
|
||||
- [x] **Improve prose quality and readability.** Rewrite docs in clear sentences and paragraphs instead of relying on long, list-heavy outlines.
|
||||
- Use bullets and tables only when they improve scanning, such as setup steps, command references, provider lists, or troubleshooting checklists.
|
||||
- Prefer short explanatory paragraphs for concepts, workflows, tradeoffs, and safety guidance.
|
||||
- Avoid turning every section into nested bullets; the README should feel like polished documentation, not an implementation checklist.
|
||||
|
||||
- [x] **Synchronize README, bundled docs, and CLI help text.** Ensure every user-facing description of commands, modes, providers, setup, and tools says the same thing.
|
||||
- Audit README, docs, inline help, setup prompts, `cass check` guidance, and release notes templates for contradictions.
|
||||
- Update terminology consistently: Cassady/Cass, provider, model, workspace, access mode, tool call, tool result, and session.
|
||||
- Make sure future docs can be updated from one source of truth where practical.
|
||||
|
||||
- [x] **Improve documentation structure and navigation.** Make the docs easy to scan and hard to misuse.
|
||||
- Add a table of contents or clear section links where the document is long.
|
||||
- Move long reference material out of the README when it distracts from first-use guidance, and link to it clearly.
|
||||
- Add a glossary for recurring concepts such as workspace, provider, model, access mode, context, and tool call.
|
||||
- Ensure headings, examples, and filenames follow a consistent style.
|
||||
|
||||
- [x] **Verify documentation against the actual CLI.** Treat docs as tested user experience, not prose written from memory.
|
||||
- Run the documented commands and update examples to match real output.
|
||||
- Check every internal link, file path, command name, provider URL, env var, and config snippet.
|
||||
- Add lightweight docs checks where practical, such as link validation or command-output smoke tests.
|
||||
- Complete the release only when README and bundled docs accurately reflect the shipped CLI.
|
||||
|
||||
## 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
|
||||
@@ -52,3 +405,101 @@ This release focuses on making Cass easier to interrupt, easier to audit, and sa
|
||||
- [x] **Edit diff output.** Make `edit` changes reviewable in the transcript.
|
||||
- First version: show a unified before/after diff after the edit is applied.
|
||||
- Later versions may add pre-apply approval, but that requires a confirmation flow between tools and the TUI.
|
||||
|
||||
## Planned within the next major release
|
||||
|
||||
These sections describe work Cassady intends to complete before or as part of the next major release, but which has not yet been assigned to a specific version. Scope, order, and version numbers may change.
|
||||
|
||||
### Windows CLI Usability
|
||||
|
||||
This work focuses on making Cassady feel reliable and native when the CLI is run on Windows. It covers runtime usability after `cass` or `cassady` is already available on the machine; installers, package managers, PATH setup, code signing, and update delivery are intentionally out of scope.
|
||||
|
||||
#### Terminal Experience
|
||||
|
||||
- [ ] **Make interactive rendering robust in Windows terminals.** Ensure chat, setup, confirmation prompts, streamed output, spinners, diffs, and tool summaries render cleanly in Windows Terminal, PowerShell, Command Prompt, and common VS Code integrated terminals.
|
||||
- Enable or gracefully detect ANSI/VT support instead of emitting broken escape sequences.
|
||||
- Respect `NO_COLOR`, non-interactive output, redirected stdout/stderr, and narrow terminal widths.
|
||||
- Avoid relying on glyphs, emoji, box drawing, or cursor control sequences that render poorly on default Windows fonts.
|
||||
- Keep wrapping and cursor positioning correct for multi-line input, Markdown output, and long tool-call summaries.
|
||||
|
||||
- [ ] **Harden keyboard handling on Windows.** Make the TUI and prompts respond predictably to Windows console input events.
|
||||
- Verify `Enter`, `Backspace`, `Delete`, arrow keys, `Home`, `End`, `PageUp`, `PageDown`, `Tab`, and paste behavior.
|
||||
- Preserve existing `Ctrl-C` cancellation semantics and handle `Ctrl-Break`/console close events gracefully where supported.
|
||||
- Ensure `Esc` cancellation and prompt dismissal work consistently across PowerShell, Command Prompt, and Windows Terminal.
|
||||
|
||||
- [ ] **Improve plain CLI output for Windows users.** Commands such as `cass check`, setup diagnostics, validation errors, and usage text should remain readable without a fully interactive terminal.
|
||||
- Prefer actionable Windows examples using PowerShell syntax when the current platform is Windows.
|
||||
- Avoid POSIX-only command snippets in runtime guidance unless explicitly labeled.
|
||||
- Keep error messages copy/paste-friendly and free of terminal control characters when output is redirected.
|
||||
|
||||
#### Windows Paths and Files
|
||||
|
||||
- [ ] **Support Windows path syntax everywhere the CLI accepts paths.** Normalize and validate paths consistently across arguments, tool calls, diffs, session metadata, and model-visible file references.
|
||||
- Handle drive-letter paths such as `C:\Users\name\project`, rooted paths such as `\temp`, UNC paths such as `\\server\share\repo`, and mixed `/`/`\` separators.
|
||||
- Preserve user-facing paths in a readable Windows form while using canonicalized paths for safety decisions.
|
||||
- Avoid treating `:` in drive letters as URL schemes or command separators.
|
||||
- Add tests for relative path resolution from Windows workspaces and for paths containing spaces, apostrophes, parentheses, brackets, and non-ASCII characters.
|
||||
|
||||
- [ ] **Respect Windows filesystem semantics in workspace policy.** Keep read, write, edit, and shell safety checks correct on NTFS and common Windows filesystems.
|
||||
- Account for case-insensitive path comparisons, symlinks, junctions, directory symlinks, and network shares.
|
||||
- Prevent workspace escapes through `..`, junctions, symlink targets, alternate path spellings, and UNC aliases.
|
||||
- Handle reserved device names, trailing dots/spaces, invalid filename characters, and long-path edge cases with clear errors.
|
||||
- Preserve current access modes (`read-only`, `workspace-edit`, `full-access`) with Windows-specific authorization tests.
|
||||
|
||||
- [ ] **Handle line endings and encodings cleanly.** Make file reads, edits, diffs, and generated files predictable on Windows projects.
|
||||
- Preserve existing CRLF/LF style when editing files where practical.
|
||||
- Render diffs clearly even when files use CRLF line endings.
|
||||
- Avoid corrupting UTF-8 with BOM, UTF-16, or non-UTF-8 files; detect unsupported text encodings and explain the limitation.
|
||||
- Keep binary-file detection reliable for Windows executables, images, archives, and generated build artifacts.
|
||||
|
||||
#### Shell and Process Integration
|
||||
|
||||
- [ ] **Use the right shell behavior on Windows.** Make `shell` tool execution, approval prompts, command summaries, cancellation, and exit status reporting work with Windows process semantics.
|
||||
- Prefer PowerShell-friendly examples and diagnostics while still supporting `cmd.exe`-style commands when users provide them.
|
||||
- Quote paths with spaces safely and avoid POSIX-only escaping in Windows-generated commands.
|
||||
- Surface the actual executable, working directory, exit code, stdout, and stderr in a way users can debug.
|
||||
- Cancel long-running child processes cleanly, including process trees where possible.
|
||||
|
||||
- [ ] **Normalize environment-variable handling.** Ensure provider API key checks, diagnostics, setup guidance, and spawned tools work with Windows environment conventions.
|
||||
- Treat environment variable names consistently despite Windows case-insensitive lookup behavior.
|
||||
- Show PowerShell examples such as `$env:OPENAI_API_KEY = "..."` for temporary values.
|
||||
- Avoid relying on POSIX shell expansion, `export`, `$VAR`, or `~` in Windows-specific guidance.
|
||||
|
||||
- [ ] **Support common Windows external commands and editors.** When Cassady suggests or launches helper commands, make the behavior compatible with typical Windows environments.
|
||||
- Detect missing tools and explain alternatives rather than assuming Unix utilities are present.
|
||||
- Avoid hard dependencies on `sh`, `bash`, `grep`, `sed`, `cat`, `less`, or `/tmp` during normal CLI operation.
|
||||
- Respect configured editor/browser commands and quote file paths correctly when opening files or URLs.
|
||||
|
||||
#### Config, State, and Session Usability
|
||||
|
||||
- [ ] **Use Windows-appropriate runtime locations.** Keep config, logs, caches, sessions, temporary files, and diagnostics in locations that align with Windows conventions.
|
||||
- Prefer the existing cross-platform directory abstraction where available, and verify behavior with `APPDATA`, `LOCALAPPDATA`, `TEMP`, and `USERPROFILE`.
|
||||
- Expand `~` and environment-derived paths consistently in config values.
|
||||
- Keep session history portable enough to display Windows paths without breaking transcript replay.
|
||||
|
||||
- [ ] **Make diagnostics expose Windows-specific context.** Improve `cass check` and error reports so Windows users can understand terminal, filesystem, shell, and config problems quickly.
|
||||
- Include OS, architecture, terminal detection, active shell, config path, workspace path, and access mode when relevant.
|
||||
- Clearly distinguish provider/API-key failures from Windows runtime issues.
|
||||
- Recommend Windows-native remediation steps without mentioning installation tasks.
|
||||
|
||||
- [ ] **Keep aliases and command parsing consistent.** Ensure `cass` and `cassady` subcommands, flags, config overrides, and path arguments behave the same on Windows as on Unix-like systems.
|
||||
- Validate quoting behavior for arguments containing spaces and backslashes.
|
||||
- Ensure help text and examples do not imply shell features unavailable in PowerShell or Command Prompt.
|
||||
- Keep machine-readable output stable across platforms when output is consumed by scripts.
|
||||
|
||||
#### Verification and Documentation
|
||||
|
||||
- [ ] **Add Windows-focused automated coverage.** Add unit and integration tests that exercise Windows path parsing, policy checks, config discovery, line endings, environment variables, and command rendering.
|
||||
- Use platform-gated tests for behavior that can only run on Windows.
|
||||
- Add platform-independent tests for Windows path strings where possible.
|
||||
- Include regression tests for spaces in paths, UNC paths, CRLF edits, and workspace escape attempts.
|
||||
|
||||
- [ ] **Run a manual Windows CLI acceptance pass.** Validate the release on a real Windows environment, not just cross-compilation.
|
||||
- Test PowerShell, Command Prompt, Windows Terminal, and VS Code integrated terminal.
|
||||
- Exercise interactive chat, first-run setup, `cass check`, tool approvals, file read/edit/diff, shell cancellation, and redirected output.
|
||||
- Record any unsupported terminal or shell behavior as explicit known limitations.
|
||||
|
||||
- [ ] **Update runtime documentation for Windows usage.** Refresh README and bundled docs with Windows-specific CLI usage guidance while avoiding installation instructions.
|
||||
- Document PowerShell environment-variable examples, path examples, terminal expectations, and known limitations.
|
||||
- Include troubleshooting for broken colors, bad wrapping, path authorization failures, CRLF diffs, and missing Unix helper commands.
|
||||
- Keep all Windows guidance consistent with existing access modes and safety policies.
|
||||
|
||||
+64
@@ -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.
|
||||
+13
-3
@@ -1,7 +1,17 @@
|
||||
# Cass bundled docs
|
||||
|
||||
These docs are embedded into the `cass` binary at build time and installed to `~/.cass/docs` when Cass starts.
|
||||
These docs are embedded into the `cass`/`cassady` binary at build time and installed to `~/.cass/docs` when Cassady starts.
|
||||
|
||||
Cass tools may list, search, and read this directory. Mutating tools are blocked from writing here, even in full-access mode.
|
||||
Cassady 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`.
|
||||
## Contents
|
||||
|
||||
- [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 provider presets, custom OpenAI-compatible endpoints, ChatGPT Codex auth, 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, Windows, release artifact, and update notes.
|
||||
- [Glossary](glossary.md): short definitions for Cassady terms.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Access modes and tool safety
|
||||
|
||||
Cassady exposes tools according to the active access mode. Choose a mode at startup with `--readonly`, `--workspace-edit`, or `--full-access`, or press `Shift-Tab` while idle to cycle modes.
|
||||
|
||||
The launch cwd is the current directory unless `--cwd PATH` is provided. In read-only and workspace-edit modes, that cwd is the workspace root.
|
||||
|
||||
## Tool matrix
|
||||
|
||||
| Tool area | read-only | workspace-edit | full-access |
|
||||
| --- | --- | --- | --- |
|
||||
| List/read/grep workspace files | yes | yes | yes |
|
||||
| Read bundled docs under `~/.cass/docs` | yes | yes | yes |
|
||||
| Write/edit workspace files | no | yes | yes |
|
||||
| Write/edit bundled docs | no | no | no |
|
||||
| Shell commands | no | approval required | yes |
|
||||
| Read outside workspace/docs | no | no | yes |
|
||||
| Write outside workspace | no | no | yes, except bundled docs |
|
||||
|
||||
## Tools
|
||||
|
||||
- `ls`: list files.
|
||||
- `read`: read file contents.
|
||||
- `grep`: search file contents.
|
||||
- `write`: create or overwrite files when writes are allowed.
|
||||
- `edit`: apply exact old-text/new-text replacements when writes are allowed.
|
||||
- `shell`: run `sh -c` in the launch cwd with an optional timeout, defaulting to 30 seconds.
|
||||
|
||||
Shell output is streamed into the transcript while the command runs. The final shell result includes stdout, stderr, and exit code. Timed-out commands are killed and reported as failures.
|
||||
|
||||
## Read policy
|
||||
|
||||
In `read-only` and `workspace-edit`, Cassady can read only:
|
||||
|
||||
- the launch workspace root; and
|
||||
- the installed bundled docs directory.
|
||||
|
||||
A path that resolves outside those roots is denied with a message like:
|
||||
|
||||
```text
|
||||
path escapes read-only roots: /path/outside (allowed roots: ...)
|
||||
```
|
||||
|
||||
In `full-access`, read/list/search actions are allowed subject to normal OS permissions.
|
||||
|
||||
## Write policy
|
||||
|
||||
In `read-only`, write and edit tools are unavailable.
|
||||
|
||||
In `workspace-edit`, write and edit tools are allowed only inside the launch workspace. Paths that resolve outside the workspace are denied with a message like:
|
||||
|
||||
```text
|
||||
write path escapes workspace-edit root: /path/outside (workspace root: ...)
|
||||
```
|
||||
|
||||
In `full-access`, write and edit tools are allowed broadly subject to OS permissions, but writes under the bundled docs directory are still blocked:
|
||||
|
||||
```text
|
||||
writes are blocked under read-only docs directory: ...
|
||||
```
|
||||
|
||||
`write` uses atomic writes where practical. `edit` requires every `old_text` to match exactly once in the original file and rejects overlapping replacements.
|
||||
|
||||
## Shell approvals and destructive-operation setting
|
||||
|
||||
- `read-only`: shell is unavailable.
|
||||
- `workspace-edit`: shell requires a UI approval prompt. Press `y` to approve, `n` or `Esc` to deny.
|
||||
- `full-access`: shell is allowed by policy without the workspace-edit approval prompt.
|
||||
|
||||
`config.json` accepts `confirm_destructive_operations` as a stored compatibility preference, but the current runtime policy is the access-mode and shell-approval behavior described above.
|
||||
|
||||
If approval is denied, the tool result says:
|
||||
|
||||
```text
|
||||
user denied approval for this tool call
|
||||
```
|
||||
|
||||
## Practical guidance
|
||||
|
||||
- Start in `read-only` when asking for explanations or audits.
|
||||
- Use `workspace-edit` for normal coding work in a repository.
|
||||
- Use `full-access` only when you intentionally want Cassady to operate outside the launch workspace or run shell commands without the approval prompt.
|
||||
- Review tool call output and diffs before continuing after edits.
|
||||
- Keep secrets in environment variables; do not ask Cassady to write literal API keys into project files.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Commands
|
||||
|
||||
Cassady installs two equivalent binaries: `cass` and `cassady`. This page uses `cass`, but the same options and subcommands apply to `cassady`.
|
||||
|
||||
## Top-level forms
|
||||
|
||||
```sh
|
||||
cass [OPTIONS]
|
||||
cassady [OPTIONS]
|
||||
cass check [OPTIONS]
|
||||
cass login [OPTIONS]
|
||||
cass logout [OPTIONS]
|
||||
cass setup [OPTIONS]
|
||||
cass update [OPTIONS]
|
||||
cass --resume [CHAT_ID]
|
||||
```
|
||||
|
||||
Run `cass --help`, `cass check --help`, `cass login --help`, `cass logout --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
|
||||
|
||||
## Startup behavior
|
||||
|
||||
- `cass` starts a new interactive chat in the current directory.
|
||||
- `cass --cwd PATH` starts from `PATH` instead.
|
||||
- `cass --resume CHAT_ID` loads a saved chat.
|
||||
- `cass --resume` lists chats for the current directory.
|
||||
- If setup is incomplete, `cass` offers to run the interactive setup wizard before starting a chat.
|
||||
|
||||
On exit, Cassady prints a command like:
|
||||
|
||||
```text
|
||||
Resume this chat with: cass --resume <id>
|
||||
```
|
||||
|
||||
## Global options
|
||||
|
||||
- `--resume [CHAT_ID]`: resume a chat, or list chats for the current cwd when no id is provided.
|
||||
- `--model MODEL`: use `MODEL` for this session.
|
||||
- `--base-url URL`: override the active provider's base URL or endpoint for this session.
|
||||
- `--api-key-env ENV`: read the API key from environment variable `ENV` for this session.
|
||||
- `--cwd PATH`: use `PATH` as the launch cwd and workspace root.
|
||||
- `--readonly`: force read-only mode.
|
||||
- `--workspace-edit`: force workspace-edit mode.
|
||||
- `--full-access`: force full-access mode.
|
||||
- `--help`: show help.
|
||||
- `--version`: show version.
|
||||
|
||||
The three access-mode flags conflict with one another.
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `cass check`
|
||||
|
||||
Validates Cassady configuration under `~/.cass`:
|
||||
|
||||
- JSON syntax and schema.
|
||||
- duplicate provider/model ids.
|
||||
- model/provider references.
|
||||
- active provider and model resolution.
|
||||
- active authentication availability.
|
||||
|
||||
Missing API keys for inactive OpenAI-compatible providers are warnings. A missing active API key is an error. For `ChatGPT Codex`, missing or expired local Codex auth is an error when it is active. `cass check` exits with a non-zero status when errors are present.
|
||||
|
||||
### `cass login`
|
||||
|
||||
Runs the provider login/configuration wizard. This is the same provider setup flow used by `cass setup`, framed for adding or updating saved provider access. It can configure multiple providers, discover or manually enter models, update active defaults, and validate the saved files.
|
||||
|
||||
`cass login` edits Cassady files under `~/.cass`; it does not create provider accounts. For `ChatGPT Codex`, sign in with `codex login` or the Codex app first; Cassady then uses local Codex auth instead of storing an API key.
|
||||
|
||||
### `cass logout`
|
||||
|
||||
Opens an interactive menu for removing saved providers from Cassady config. Removing a provider also removes its associated `models.json` entries. If the active provider is removed, Cassady chooses a remaining provider/model when possible. If no providers remain, active defaults are cleared and the next chat startup will offer setup/login again.
|
||||
|
||||
`cass logout` does not delete environment variables, API keys stored elsewhere, or external provider accounts.
|
||||
|
||||
### `cass setup`
|
||||
|
||||
Runs the interactive setup wizard in a terminal. It configures providers, authentication sources, and first models. OpenAI-compatible providers use API key environment-variable references; `ChatGPT Codex` uses local Codex auth. 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.
|
||||
|
||||
- `/branch` or `/restore`: open the branch/restore menu for the current conversation family.
|
||||
- `/login`: configure or update provider login settings, then reload active provider/model config.
|
||||
- `/logout`: remove saved providers and their associated models, then reload active provider/model config when any remain.
|
||||
- `/model <model>`: switch the model for future turns. Autocomplete lists models from `~/.cass/models.json`.
|
||||
- `/new`: create a new chat for the current directory.
|
||||
- `/resume <chat>`: resume a saved chat from the current directory. Autocomplete lists matching chats.
|
||||
- `/status`: show chat id, state, model, access mode, cwd, record count, and current status.
|
||||
|
||||
Local commands can be used only when the agent is idle.
|
||||
|
||||
## Keys
|
||||
|
||||
- `Enter`: accept an autocomplete item when a menu is open; otherwise send the current message.
|
||||
- `Ctrl-J` or `Ctrl-Enter`: insert a newline.
|
||||
- `Up`/`Down`: move through autocomplete items when a menu is open; otherwise scroll the transcript.
|
||||
- Mouse wheel: scroll transcript.
|
||||
- `PageUp`/`PageDown`: scroll transcript by larger steps.
|
||||
- `Shift-Tab`: cycle access mode while idle: `read-only` → `workspace-edit` → `full-access`.
|
||||
- `Tab`: cycle reasoning effort while idle. Models that require reasoning skip `off`; models without reasoning metadata start at `off`.
|
||||
- `Ctrl-O`: toggle compact/full tool output display.
|
||||
- `Ctrl-Shift-R` or `Ctrl-R`: toggle reasoning display.
|
||||
- `y`: approve a pending tool approval prompt.
|
||||
- `n` or `Esc`: deny a pending tool approval prompt.
|
||||
- `Esc`: request cancellation while a turn is running.
|
||||
- `Esc` twice while idle: open the branch/restore menu without discarding draft input.
|
||||
- `Ctrl-C`: request cancellation while busy; press twice within 1.5 seconds to exit.
|
||||
|
||||
## Branch/restore notes
|
||||
|
||||
The branch/restore menu lists related conversations plus checkpoints for user messages, assistant messages, tool-call requests, and tool results. Selecting a checkpoint creates a new branch JSONL conversation and leaves the source conversation unchanged. Conversation-only branching leaves files untouched; branch-plus-file restore applies Cassady's tracked `write`/`edit` snapshots and skips unsafe hash conflicts.
|
||||
|
||||
## Output notes
|
||||
|
||||
Tool calls are shown compactly by default. Press `Ctrl-O` to expand full tool output. Provider-streamed reasoning is hidden by default unless `show_reasoning` is enabled in config or toggled at runtime.
|
||||
+101
-38
@@ -1,12 +1,35 @@
|
||||
# Configuration
|
||||
|
||||
Cass reads user-editable config files from `~/.cass`.
|
||||
Cassady reads user-editable config files from `~/.cass`.
|
||||
|
||||
- `config.json`: user preferences, such as the default model and access mode.
|
||||
- `config.json`: user preferences, active defaults, and compatibility fields.
|
||||
- `providers.json`: provider connection definitions.
|
||||
- `models.json`: model metadata.
|
||||
- `global.md`: optional global instructions included in new chat system prompts when they fit the active request; they cannot override access modes, tool denials, approvals, or workspace boundaries.
|
||||
- `conversations/`: saved JSONL chats.
|
||||
- `docs/`: bundled docs installed from the current binary.
|
||||
|
||||
Cass creates `providers.json` and `models.json` automatically if they are missing. The default provider is Fireworks.
|
||||
Cassady creates `providers.json` and `models.json` automatically if they are missing. The default provider is Fireworks.
|
||||
|
||||
## Setup wizard
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
cass setup
|
||||
```
|
||||
|
||||
Cassady also offers setup automatically when `cass` cannot resolve a usable active provider, model, or authentication source before starting a chat.
|
||||
|
||||
For everyday provider management, `cass login` opens the same provider configuration flow with login-oriented wording. Inside an idle chat, `/login` temporarily opens that flow and reloads the active provider/model after it closes.
|
||||
|
||||
To remove saved provider configuration, run `cass logout` or type `/logout` while idle. Logout removes selected providers from `providers.json` and removes their associated entries from `models.json`. It does not remove environment variables, local shell profile exports, or external provider accounts.
|
||||
|
||||
The wizard uses keyboard prompts: `↑`/`↓` moves through choices, `Space` selects providers in the multi-select screen, and `Enter` submits. Text fields use the same prompt style instead of falling back to plain line input.
|
||||
|
||||
The wizard supports configuring multiple providers at once. If more than one provider is configured, setup asks which one should be active first. For OpenAI-compatible providers, if the selected API key environment variable is set, Cassady tries to fetch models from `GET {base_url}/models` and lets you choose one. If discovery fails, it offers a retry before falling back to manual model entry. If the API key is not set, setup asks for a model id manually. For `ChatGPT Codex`, setup skips API-key prompts and reads model defaults from local Codex config when available.
|
||||
|
||||
Setup stores API keys as environment-variable references such as `"$OPENAI_API_KEY"` by default for OpenAI-compatible providers. `ChatGPT Codex` stores no API key in `~/.cass`; it reads local Codex auth at check/request time. After setup, Cassady writes/updates `config.json`, `providers.json`, and `models.json`, validates them, and starts a chat only when the active authentication source is available.
|
||||
|
||||
## `config.json`
|
||||
|
||||
@@ -16,26 +39,31 @@ Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_model": "accounts/fireworks/models/qwen3p7-plus",
|
||||
"default_provider": "openai",
|
||||
"default_model": "gpt-4.1",
|
||||
"default_reasoning_effort": "medium",
|
||||
"default_access_mode": "read-only",
|
||||
"context_message_limit": 80,
|
||||
"model_tool_result_limit": 24000,
|
||||
"ui_tool_result_limit": 4000,
|
||||
"show_reasoning": false
|
||||
"show_reasoning": false,
|
||||
"confirm_destructive_operations": false
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `default_provider`: optional provider id from `providers.json`. If omitted, Cass infers the provider from `default_model` when possible.
|
||||
- `default_provider`: optional provider id from `providers.json`. If omitted, Cassady 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"`.
|
||||
- `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.
|
||||
- `default_reasoning_effort`: optional `off`, `low`, `medium`, or `high`, clamped to model metadata.
|
||||
- `default_access_mode`: `"read-only"`, `"workspace-edit"`, or `"full-access"`.
|
||||
- `context_message_limit`: optional legacy upper bound for recent non-system messages. Cassady primarily budgets context from model metadata and trims 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.
|
||||
- `show_reasoning`: optional boolean, defaults to `false`. Shows provider-streamed reasoning in the transcript. Reasoning is persisted and sent back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`.
|
||||
- `show_reasoning`: optional boolean, defaults to `false`. Shows provider-streamed reasoning in the transcript.
|
||||
- `confirm_destructive_operations`: optional compatibility preference currently stored in config.
|
||||
|
||||
Deprecated compatibility fields from older Cass versions are still accepted: `provider`, `model`, `base_url`, and `api_key_env`. Prefer moving provider connection details to `providers.json`.
|
||||
Deprecated compatibility fields from older Cassady versions are still accepted: `provider`, `model`, `base_url`, and `api_key_env`. Prefer moving provider connection details to `providers.json`.
|
||||
|
||||
## `providers.json`
|
||||
|
||||
@@ -45,15 +73,13 @@ Example:
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"id": "fireworks",
|
||||
"name": "Fireworks",
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"kind": "openai-compatible",
|
||||
"base_url": "https://api.fireworks.ai/inference/v1",
|
||||
"api_key": "$FIREWORKS_API_KEY",
|
||||
"default_model": "accounts/fireworks/models/qwen3p7-plus",
|
||||
"models": [
|
||||
"accounts/fireworks/models/qwen3p7-plus"
|
||||
]
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "$OPENAI_API_KEY",
|
||||
"default_model": "gpt-4.1",
|
||||
"models": ["gpt-4.1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -63,13 +89,32 @@ Fields:
|
||||
|
||||
- `id`: required unique provider id.
|
||||
- `name`: optional display name.
|
||||
- `kind`: required provider kind. Currently only `"openai-compatible"` is supported.
|
||||
- `base_url`: required OpenAI-compatible API base URL.
|
||||
- `api_key`: required string. Use either a literal key or an environment-variable reference like `"$FIREWORKS_API_KEY"`.
|
||||
- `default_model`: optional model id to use when no default model is configured.
|
||||
- `kind`: required provider kind. Supported values are `"openai-compatible"` and `"chatgpt-codex"`.
|
||||
- `base_url`: required API base URL or endpoint. `chatgpt-codex` uses `https://chatgpt.com/backend-api/codex/responses`.
|
||||
- `api_key`: required for `openai-compatible` providers. Use either a literal key or an environment-variable reference like `"$OPENAI_API_KEY"`. Omit it for `chatgpt-codex`; that provider reads local Codex auth instead.
|
||||
- `default_model`: optional model id used when no default model is configured.
|
||||
- `models`: optional list of model ids associated with this provider.
|
||||
|
||||
Only strings that start with `$` are resolved as environment variables. Cass does not expand partial strings or `${NAME}` syntax.
|
||||
Only strings that start with `$` are resolved as environment variables. Cassady does not expand partial strings or `${NAME}` syntax.
|
||||
|
||||
`ChatGPT Codex` example:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"id": "chatgpt-codex",
|
||||
"name": "ChatGPT Codex",
|
||||
"kind": "chatgpt-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex/responses",
|
||||
"default_model": "gpt-5.5",
|
||||
"models": ["gpt-5.5"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Run `codex login` or sign in with the Codex app before using this provider. Cassady reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json` and does not store the Codex access token in `~/.cass`.
|
||||
|
||||
## `models.json`
|
||||
|
||||
@@ -79,10 +124,10 @@ Example:
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "accounts/fireworks/models/qwen3p7-plus",
|
||||
"provider": "fireworks",
|
||||
"display_name": "Qwen 3p7 Plus",
|
||||
"context_length": 262144,
|
||||
"id": "gpt-4.1",
|
||||
"provider": "openai",
|
||||
"display_name": "GPT-4.1",
|
||||
"context_length": 1047576,
|
||||
"max_output_tokens": 32768,
|
||||
"supports_tools": true,
|
||||
"supports_streaming": true,
|
||||
@@ -107,12 +152,22 @@ Fields:
|
||||
- `supports_tools`: optional boolean, defaults to `true`.
|
||||
- `supports_streaming`: optional boolean, defaults to `true`.
|
||||
- `reasoning`: optional object. Defaults to reasoning support enabled with medium effort for model entries.
|
||||
- `supported`: optional boolean, defaults to `true`. Set to `false` for models that do not accept reasoning controls.
|
||||
- `required`: optional boolean, defaults to `false`. If `true`, Cass will not cycle reasoning effort to `off`.
|
||||
- `default_effort`: optional `off`, `low`, `medium`, or `high`; defaults to `medium`. Cannot be `off` when `required` is `true`.
|
||||
- `request_format`: optional `reasoning_effort` or `reasoning_object`; defaults to `reasoning_effort`. `reasoning_effort` sends a top-level `"reasoning_effort": "medium"`; `reasoning_object` sends `"reasoning": { "effort": "medium" }`.
|
||||
- `supported`: optional boolean, defaults to `true`.
|
||||
- `required`: optional boolean, defaults to `false`.
|
||||
- `default_effort`: optional `off`, `low`, `medium`, or `high`; defaults to `medium`. Cannot effectively be `off` when `required` is `true`.
|
||||
- `request_format`: optional `reasoning_effort` or `reasoning_object`; defaults to `reasoning_effort`.
|
||||
|
||||
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`.
|
||||
Reasoning effort is a runtime per-turn setting. Press `Tab` to cycle it while idle. Provider-streamed reasoning is persisted and sent back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`.
|
||||
|
||||
## Precedence
|
||||
|
||||
- CLI access-mode flags override `default_access_mode` for the current session.
|
||||
- `--model` overrides the configured default model for the current session.
|
||||
- `--base-url` overrides the active provider base URL for the current session.
|
||||
- `--api-key-env ENV` makes the active provider read `$ENV` for the current session.
|
||||
- `config.json` preferences override built-in defaults.
|
||||
- Provider defaults in `providers.json` are used when no configured model is selected.
|
||||
- Environment variables provide the actual API key value when `api_key` starts with `$`.
|
||||
|
||||
## Check configuration
|
||||
|
||||
@@ -122,14 +177,22 @@ 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 authentication availability. Missing API keys for inactive OpenAI-compatible providers are warnings; a missing active provider API key is an error. For `ChatGPT Codex`, missing or expired local Codex auth is an active-provider error.
|
||||
|
||||
## Ask Cass to edit config
|
||||
|
||||
Run Cass in full-access mode and ask it to read these docs before editing:
|
||||
When setup is incomplete, `cass check` prints actionable next steps such as:
|
||||
|
||||
```text
|
||||
Read ~/.cass/docs/configuration.md, then add an OpenAI-compatible provider named Together using TOGETHER_API_KEY and add model metadata for meta-llama/Llama-3.1-70B-Instruct-Turbo.
|
||||
export PROVIDER_API_KEY=...
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
After Cass edits the files, run `cass check`.
|
||||
## Safe manual editing
|
||||
|
||||
1. Edit one file at a time.
|
||||
2. Keep provider ids and model provider references in sync.
|
||||
3. Prefer API key env references over literal keys for OpenAI-compatible providers; do not paste Codex tokens into Cassady config.
|
||||
4. Prefer `cass login` and `cass logout` for routine provider changes.
|
||||
5. Run `cass check` before starting a chat.
|
||||
|
||||
Invalid JSON, unknown fields, duplicate ids, and missing provider/model links are reported by `cass check` with the file that failed.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Glossary
|
||||
|
||||
**Access mode**: The safety policy controlling which tools are available. Current modes are `read-only`, `workspace-edit`, and `full-access`.
|
||||
|
||||
**Active provider**: The provider Cassady resolves for the current session after applying config and CLI overrides.
|
||||
|
||||
**Bundled docs**: Markdown files embedded into the binary at build time and installed to `~/.cass/docs`. Cassady can read them; writes under this directory are blocked.
|
||||
|
||||
**Cassady / Cass**: The project name is Cassady. The short command is `cass`; `cassady` is also installed.
|
||||
|
||||
**Chat**: A persisted conversation with a model for one workspace. Chats are saved under `~/.cass/conversations` and can be resumed.
|
||||
|
||||
**Config root**: The `~/.cass` directory containing config, conversations, global instructions, and installed docs.
|
||||
|
||||
**Exact edit**: An `edit` tool replacement where each `old_text` must match exactly once in the original file before anything is written.
|
||||
|
||||
**Global instructions**: Optional text in `~/.cass/global.md` included in new chat system prompts. Cassady follows these instructions when they fit the active request, but they cannot override runtime safety constraints such as access modes, tool denials, approvals, or workspace boundaries.
|
||||
|
||||
**Model metadata**: The `models.json` entry describing a model id, owning provider, display name, context limits, tool/streaming support, and reasoning behavior.
|
||||
|
||||
**OpenAI-compatible provider**: A provider exposing an API compatible with the OpenAI-style chat/completions behavior Cassady uses.
|
||||
|
||||
**Provider**: A connection definition in `providers.json`, including id, base URL, API key reference, and optional default model.
|
||||
|
||||
**Reasoning effort**: Runtime setting (`off`, `low`, `medium`, `high`) used for models with reasoning support. Press `Tab` while idle to cycle it.
|
||||
|
||||
**Tool call**: A model-requested operation such as `ls`, `read`, `grep`, `write`, `edit`, or `shell`.
|
||||
|
||||
**Workspace**: The launch cwd, either the current directory or the path passed with `--cwd`. In workspace-edit mode, writes must stay inside this root.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,73 @@
|
||||
# Platform notes
|
||||
|
||||
Cassady is a terminal CLI. Most behavior is shared across platforms, but environment-variable syntax, paths, and terminal behavior differ.
|
||||
|
||||
## macOS and Linux
|
||||
|
||||
Set an API key for the current shell:
|
||||
|
||||
```sh
|
||||
export OPENAI_API_KEY=...
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
Use normal POSIX paths:
|
||||
|
||||
```sh
|
||||
cass --cwd /Users/alex/project
|
||||
cass --cwd /home/alex/project
|
||||
```
|
||||
|
||||
Shell tools run through `sh -c` from the launch cwd.
|
||||
|
||||
## Windows
|
||||
|
||||
Release builds include a Windows x86_64 binary. Use PowerShell syntax for environment variables:
|
||||
|
||||
```powershell
|
||||
$env:OPENAI_API_KEY = "..."
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
Example path usage:
|
||||
|
||||
```powershell
|
||||
cass --cwd C:\Users\alex\project
|
||||
```
|
||||
|
||||
Current docs and examples are primarily terminal-CLI oriented. Deeper Windows polish for terminal behavior, path handling, shell behavior, filesystem edge cases, and release usability is planned for a later release. Avoid assuming every Windows path or shell edge case is polished in the current version.
|
||||
|
||||
## Config location
|
||||
|
||||
Cassady currently stores config under the home directory at:
|
||||
|
||||
```text
|
||||
~/.cass
|
||||
```
|
||||
|
||||
That directory contains `config.json`, `providers.json`, `models.json`, `global.md`, `conversations/`, and installed bundled docs.
|
||||
|
||||
## 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 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.
|
||||
|
||||
`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`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Providers and models
|
||||
|
||||
Cassady supports OpenAI-compatible providers plus a built-in `ChatGPT Codex` provider preset. A provider supplies the endpoint and authentication source; a model entry supplies metadata for one model id used with that provider.
|
||||
|
||||
## Built-in setup catalog
|
||||
|
||||
The setup wizard offers these provider templates:
|
||||
|
||||
| Provider | Provider id | Base URL / endpoint | Suggested auth source |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenAI | `openai` | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
|
||||
| ChatGPT Codex | `chatgpt-codex` | `https://chatgpt.com/backend-api/codex/responses` | local Codex auth |
|
||||
| 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` |
|
||||
|
||||
There is also a custom OpenAI-compatible option. Custom setup asks for provider name, provider id, base URL, API key environment variable, and first model id.
|
||||
|
||||
## Login and logout
|
||||
|
||||
Use `cass login` to add or update provider configuration from the shell. Inside an idle chat, `/login` opens the same flow and reloads the active provider/model afterward.
|
||||
|
||||
Use `cass logout` or `/logout` to remove saved provider entries from Cassady config. Logout also removes model metadata entries associated with the removed providers and repairs active defaults when other providers remain. It does not delete environment variables, shell profile exports, API keys stored elsewhere, or external provider accounts.
|
||||
|
||||
## ChatGPT Codex preset
|
||||
|
||||
`ChatGPT Codex` is for users who have already signed in with Codex. Run `codex login` or sign in with the Codex app first, then select `ChatGPT Codex` in `cass login` or `cass setup`.
|
||||
|
||||
Cassady reads the bearer token from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` at check/request time. It does not copy the access token or refresh token into `~/.cass`, and `cass check` redacts secret values. Setup prefers the `model` value from `$CODEX_HOME/config.toml` when present and otherwise offers a default/manual model id.
|
||||
|
||||
This preset uses `kind: "chatgpt-codex"` and posts to `https://chatgpt.com/backend-api/codex/responses`. That ChatGPT backend endpoint and Codex auth file format are outside Cassady's control, so users may need to update Cassady if they change.
|
||||
|
||||
## Model discovery
|
||||
|
||||
For OpenAI-compatible providers, when the selected API key environment variable is available, setup tries:
|
||||
|
||||
```text
|
||||
GET {base_url}/models
|
||||
```
|
||||
|
||||
If the provider returns model ids, setup lets you choose one. If discovery fails, setup offers a retry and then falls back to manual model entry. Some OpenAI-compatible providers do not expose `/models` or require different permissions; manual entry is normal in that case.
|
||||
|
||||
## Custom provider requirements
|
||||
|
||||
A custom provider should expose OpenAI-compatible chat completions behavior at the configured base URL. Cassady may use:
|
||||
|
||||
- streamed assistant text;
|
||||
- tool call requests and tool results;
|
||||
- optional reasoning fields or reasoning request controls;
|
||||
- optional `/models` discovery during setup.
|
||||
|
||||
Provider protocols that are not OpenAI-compatible are supported only when Cassady has an explicit provider kind for them, such as `chatgpt-codex`.
|
||||
|
||||
## Provider vs model metadata
|
||||
|
||||
`providers.json` answers: how does Cassady connect?
|
||||
|
||||
- provider id;
|
||||
- base URL;
|
||||
- API key reference or local auth source;
|
||||
- optional default model;
|
||||
- optional list of associated model ids.
|
||||
|
||||
`models.json` answers: what does this model support?
|
||||
|
||||
- model id sent to the provider;
|
||||
- owning provider id;
|
||||
- display name;
|
||||
- context length and max output tokens;
|
||||
- tool and streaming support;
|
||||
- reasoning support and request format.
|
||||
|
||||
`config.json` selects active defaults, such as `default_provider`, `default_model`, and `default_access_mode`.
|
||||
|
||||
## Reasoning metadata
|
||||
|
||||
Reasoning metadata controls how the runtime reasoning effort behaves:
|
||||
|
||||
- `supported: false`: reasoning effort stays `off`.
|
||||
- `required: true`: `Tab` cycles through `low`, `medium`, and `high` without `off`.
|
||||
- `default_effort`: starting effort for the model.
|
||||
- `request_format: "reasoning_effort"`: sends a top-level `reasoning_effort` string.
|
||||
- `request_format: "reasoning_object"`: sends a `reasoning` object with an effort.
|
||||
|
||||
Reasoning display is separate. `show_reasoning` controls whether provider-streamed reasoning is visible in the transcript; press `Ctrl-Shift-R` or `Ctrl-R` to toggle it at runtime.
|
||||
|
||||
## Switching models
|
||||
|
||||
Use one of these approaches:
|
||||
|
||||
```sh
|
||||
cass --model MODEL
|
||||
```
|
||||
|
||||
or inside a chat:
|
||||
|
||||
```text
|
||||
/model MODEL
|
||||
```
|
||||
|
||||
The in-chat model autocomplete lists entries from `~/.cass/models.json`. Switching the model also updates the default model and reasoning effort in `config.json` for future sessions.
|
||||
|
||||
## Health checks
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
cass check
|
||||
```
|
||||
|
||||
This confirms that the active provider and model resolve. For OpenAI-compatible providers it checks API key environment variables; missing inactive-provider keys are warnings and missing active-provider keys are errors. For `ChatGPT Codex`, it checks that local Codex auth contains an access token and prints recovery steps if not.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Troubleshooting
|
||||
|
||||
Use `cass check` first for configuration problems. It validates files, provider/model references, and authentication availability.
|
||||
|
||||
## Missing active API key
|
||||
|
||||
Symptom: `cass check` reports that an environment variable is not set, or chat startup says the API key is not available.
|
||||
|
||||
Likely cause: the active provider's `api_key` is an env reference such as `"$OPENAI_API_KEY"`, but that variable is not set in the current shell.
|
||||
|
||||
Fix on macOS/Linux:
|
||||
|
||||
```sh
|
||||
export OPENAI_API_KEY=...
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
Fix in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:OPENAI_API_KEY = "..."
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
## Missing or expired ChatGPT Codex auth
|
||||
|
||||
Symptom: `cass check` reports `Codex auth` errors, or chat startup says provider authentication is not available for `chatgpt-codex`.
|
||||
|
||||
Likely cause: `ChatGPT Codex` is active but `$CODEX_HOME/auth.json` or `~/.codex/auth.json` is missing, unreadable, lacks `tokens.access_token`, or contains an expired token.
|
||||
|
||||
Fix:
|
||||
|
||||
```sh
|
||||
codex login
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
You can also sign in with the Codex app if that is how your local Codex auth is managed. Cassady does not refresh or store ChatGPT/Codex tokens; it reads local Codex auth at check/request time and redacts secret values.
|
||||
|
||||
## Invalid API key reference
|
||||
|
||||
Symptom: the key is not resolved the way you expect.
|
||||
|
||||
Likely cause: Cassady only treats strings that start with `$` as environment references. It does not expand partial strings or `${NAME}` syntax.
|
||||
|
||||
Fix:
|
||||
|
||||
```json
|
||||
{ "api_key": "$OPENAI_API_KEY" }
|
||||
```
|
||||
|
||||
## Provider URL unreachable
|
||||
|
||||
Symptom: setup model discovery fails or a turn reports a provider error.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- wrong `base_url`;
|
||||
- network or proxy problem;
|
||||
- provider outage;
|
||||
- provider requires a different OpenAI-compatible path;
|
||||
- for `ChatGPT Codex`, the private ChatGPT backend endpoint changed or the selected model is unavailable.
|
||||
|
||||
Fix: verify the base URL in `providers.json`, retry setup, or enter the model id manually if only `/models` discovery is failing. For `ChatGPT Codex`, verify that `base_url` is `https://chatgpt.com/backend-api/codex/responses`, rerun `codex login`, and try a current Codex model id.
|
||||
|
||||
## `/models` discovery fails
|
||||
|
||||
Symptom: setup cannot fetch models.
|
||||
|
||||
Likely cause: some providers do not expose `GET /models`, require extra permissions, or return a non-standard shape.
|
||||
|
||||
Fix: choose retry if the failure is temporary; otherwise enter the model id manually. Then run `cass check`.
|
||||
|
||||
## Unsupported or invalid model id
|
||||
|
||||
Symptom: chat starts but the provider rejects the model.
|
||||
|
||||
Likely cause: the model id in `models.json` or `config.json` is not valid for the provider.
|
||||
|
||||
Fix: update the model id with `cass setup`, edit `models.json`, or launch with:
|
||||
|
||||
```sh
|
||||
cass --model MODEL_ID
|
||||
```
|
||||
|
||||
Then verify with a small prompt.
|
||||
|
||||
## Rate limit or authentication errors
|
||||
|
||||
Symptom: the assistant says the provider returned an error.
|
||||
|
||||
Likely cause: provider-side authentication, quota, billing, or rate limit. For `ChatGPT Codex`, this can also mean your ChatGPT subscription/account does not have the requested Codex model available or the local Codex token needs to be refreshed by Codex.
|
||||
|
||||
Fix: confirm the API key, provider account status, selected model, and provider dashboard. For `ChatGPT Codex`, rerun `codex login` or open Codex to refresh local auth. Cassady forwards provider failures into the chat but cannot resolve account-level issues.
|
||||
|
||||
## Invalid JSON or unknown config fields
|
||||
|
||||
Symptom: `cass check` reports a parsing or schema error for `config.json`, `providers.json`, or `models.json`.
|
||||
|
||||
Likely cause: invalid JSON, comments, trailing commas, misspelled fields, or a field in the wrong file.
|
||||
|
||||
Fix: remove comments/trailing commas, compare against [Configuration](configuration.md), and run:
|
||||
|
||||
```sh
|
||||
cass check
|
||||
```
|
||||
|
||||
## Workspace access denied
|
||||
|
||||
Symptom: tool output says a path escapes allowed roots or workspace-edit root.
|
||||
|
||||
Likely cause: the active mode is `read-only` or `workspace-edit`, and the path resolves outside the launch cwd or bundled docs.
|
||||
|
||||
Fix: start from the intended project directory, pass `--cwd PATH`, or intentionally use `--full-access` when broad filesystem access is needed.
|
||||
|
||||
## Shell unavailable or waiting for approval
|
||||
|
||||
Symptom: shell is denied or Cassady asks for approval.
|
||||
|
||||
Rules:
|
||||
|
||||
- `read-only`: shell is unavailable.
|
||||
- `workspace-edit`: shell requires approval.
|
||||
- `full-access`: shell is allowed by policy.
|
||||
|
||||
Fix: switch mode with `Shift-Tab` while idle or launch with the desired access flag.
|
||||
|
||||
## Shell command failed or timed out
|
||||
|
||||
Symptom: shell result includes stderr, non-zero exit code, or timeout.
|
||||
|
||||
Likely cause: the command itself failed, the working directory is wrong, dependencies are missing, or the timeout was too short.
|
||||
|
||||
Fix: inspect stdout/stderr, verify cwd in `/status`, and ask Cassady to rerun the smallest relevant command.
|
||||
|
||||
## Exact-text edit failed
|
||||
|
||||
Symptom: edit reports `old_text not found`, `old_text is not unique`, or overlapping edits.
|
||||
|
||||
Likely cause: the file changed, whitespace differs, line endings differ, or the replacement text is too broad.
|
||||
|
||||
Fix: ask Cassady to re-read the file and retry with a smaller unique `old_text`. For repeated blocks, include nearby unique context.
|
||||
|
||||
## Binary, large, or unsupported files
|
||||
|
||||
Symptom: read/edit output is confusing or fails.
|
||||
|
||||
Likely cause: the file is binary, too large for useful display, or not valid UTF-8 for text edits.
|
||||
|
||||
Fix: ask Cassady to list metadata or use project-specific tools through approved shell commands. Avoid direct text edits on binary files.
|
||||
|
||||
## CRLF or line-ending confusion
|
||||
|
||||
Symptom: exact-text edits fail even when the text appears to match.
|
||||
|
||||
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.
|
||||
|
||||
## Branch/restore file conflicts
|
||||
|
||||
Symptom: branch-plus-file restore reports conflicts or skips paths.
|
||||
|
||||
Likely cause: the file changed outside Cassady after the tracked `write`/`edit`, the file is unsupported for snapshots, or the change came from a shell command or manual editor rather than a Cassady file tool.
|
||||
|
||||
Fix: review the restore preview, inspect conflicted files manually, and rerun the menu with conversation-only branching if you only need to revisit the chat. Cassady will not overwrite unknown current content by default. Open the branch/restore menu again with double `Esc` or `/branch` to switch back to the original branch.
|
||||
|
||||
## 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.
|
||||
|
||||
Likely cause: unsupported terminal features, redirected stdin/stdout, or platform-specific terminal behavior.
|
||||
|
||||
Fix: run Cassady in an interactive terminal. Use `cass check` for non-interactive validation. Windows runtime polish is planned for a later release.
|
||||
|
||||
## Setup says it is interactive
|
||||
|
||||
Symptom: `cass setup` fails with `setup is interactive; run cass setup in a terminal`.
|
||||
|
||||
Likely cause: stdin is not a terminal.
|
||||
|
||||
Fix: run setup directly in a terminal, not through a non-interactive script or redirected input.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Workflows
|
||||
|
||||
This page shows common Cassady workflows. Exact tool calls depend on the model and the prompt; use these examples as patterns rather than scripts.
|
||||
|
||||
## Start in a workspace
|
||||
|
||||
```sh
|
||||
cd /path/to/project
|
||||
cass
|
||||
```
|
||||
|
||||
Or choose a workspace explicitly:
|
||||
|
||||
```sh
|
||||
cass --cwd /path/to/project
|
||||
```
|
||||
|
||||
Ask for read-only exploration first:
|
||||
|
||||
```text
|
||||
Explain the structure of this repository. Do not edit files yet.
|
||||
```
|
||||
|
||||
## Inspect and explain code
|
||||
|
||||
Start in read-only mode or press `Shift-Tab` until the status shows `read-only`.
|
||||
|
||||
```text
|
||||
Find where configuration is loaded and summarize the precedence rules.
|
||||
```
|
||||
|
||||
Cassady can use `ls`, `read`, and `grep` to inspect the workspace and bundled docs.
|
||||
|
||||
## Apply a focused edit
|
||||
|
||||
Use workspace-edit mode:
|
||||
|
||||
```sh
|
||||
cass --workspace-edit
|
||||
```
|
||||
|
||||
Then ask for a precise change:
|
||||
|
||||
```text
|
||||
Update the README install section to mention both cass and cassady. Keep the rest unchanged.
|
||||
```
|
||||
|
||||
Cassady may use `edit` or `write`. Edit results include a diff-like summary. If an exact-text edit fails, ask Cassady to re-read the file and retry with a smaller unique replacement.
|
||||
|
||||
## Run tests or builds
|
||||
|
||||
Shell is denied in read-only mode and requires approval in workspace-edit mode.
|
||||
|
||||
```text
|
||||
Run the smallest relevant Rust test for this change, then summarize the result.
|
||||
```
|
||||
|
||||
When the approval prompt appears, press `y` to approve or `n`/`Esc` to deny. Shell commands run with `sh -c` from the launch cwd and default to a 30-second timeout unless the model requests another timeout.
|
||||
|
||||
## Manage provider login
|
||||
|
||||
Add or update provider configuration from the shell:
|
||||
|
||||
```sh
|
||||
cass login
|
||||
```
|
||||
|
||||
Inside an idle chat:
|
||||
|
||||
```text
|
||||
/login
|
||||
```
|
||||
|
||||
Remove saved provider configuration:
|
||||
|
||||
```sh
|
||||
cass logout
|
||||
```
|
||||
|
||||
Inside an idle chat:
|
||||
|
||||
```text
|
||||
/logout
|
||||
```
|
||||
|
||||
Logout removes selected providers from Cassady's config and removes their associated model entries. It does not delete environment variables, local Codex auth, or external provider accounts.
|
||||
|
||||
For `ChatGPT Codex`, run `codex login` or sign in with the Codex app before `cass login`. Cassady validates `~/.codex/auth.json` and uses that local token source instead of asking for an API-key environment variable.
|
||||
|
||||
## Switch model
|
||||
|
||||
Inside a chat:
|
||||
|
||||
```text
|
||||
/model MODEL_ID
|
||||
```
|
||||
|
||||
Autocomplete lists models from `~/.cass/models.json`. Switching models is allowed only when idle. Cassady persists the last used model and reasoning effort into `config.json`.
|
||||
|
||||
You can also launch with a model override:
|
||||
|
||||
```sh
|
||||
cass --model MODEL_ID
|
||||
```
|
||||
|
||||
## Resume a chat
|
||||
|
||||
List chats for the current directory:
|
||||
|
||||
```sh
|
||||
cass --resume
|
||||
```
|
||||
|
||||
Resume a specific chat:
|
||||
|
||||
```sh
|
||||
cass --resume CHAT_ID
|
||||
```
|
||||
|
||||
Inside the UI:
|
||||
|
||||
```text
|
||||
/resume CHAT_ID
|
||||
```
|
||||
|
||||
`/resume` autocomplete lists saved chats for the current directory.
|
||||
|
||||
## Start fresh without leaving
|
||||
|
||||
```text
|
||||
/new
|
||||
```
|
||||
|
||||
This creates a new chat for the same cwd and model while preserving your current configuration.
|
||||
|
||||
## Branch or restore a conversation point
|
||||
|
||||
Press `Esc` twice while idle, or type:
|
||||
|
||||
```text
|
||||
/branch
|
||||
```
|
||||
|
||||
Use the menu to select a related branch or a checkpoint from a user message, assistant message, tool-call request, or tool result. Branching creates a new chat from that point and leaves the original chat available in the same menu. Choose conversation-only branching to leave files untouched, or choose branch-plus-files to restore Cassady-tracked `write`/`edit` snapshots with conflict checks.
|
||||
|
||||
## Check status
|
||||
|
||||
```text
|
||||
/status
|
||||
```
|
||||
|
||||
The status block includes chat id, state, model, mode, cwd, record count, and the current status message.
|
||||
|
||||
## Cancel and continue
|
||||
|
||||
While a turn is running:
|
||||
|
||||
- Press `Esc` or `Ctrl-C` to request turn cancellation.
|
||||
- Press `Ctrl-C` twice within 1.5 seconds to exit.
|
||||
|
||||
Cassady records cancelled tool calls and a cancellation message so the conversation can continue cleanly.
|
||||
|
||||
## Ask Cassady to edit its config
|
||||
|
||||
Config files live under `~/.cass`, outside a normal project workspace. To inspect them, use `full-access` or edit them manually. After manual changes, run:
|
||||
|
||||
```sh
|
||||
cass check
|
||||
```
|
||||
|
||||
For OpenAI-compatible providers this checks API key environment variables. For `ChatGPT Codex` this checks local Codex auth and points you back to `codex login` if the token is missing or expired. Prefer `cass setup` or `cass login` for provider/model changes when possible.
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,375 @@
|
||||
# v0.2.3 Documentation and README Refresh Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
v0.2.3 refreshes Cassady's user-facing documentation so a new or returning user can understand what Cassady does, configure it successfully, use it safely in a project, and recover from common failures without reading the source. The README should become the polished entry point, while bundled docs under `docs/` should provide accurate reference material that the CLI can install into `~/.cass/docs`.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A user can start from the README, run the documented setup/check/chat commands, understand the current provider/model/access-mode model, and find accurate troubleshooting guidance for the shipped v0.2.3 CLI.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Rewrite `README.md` around the current Cassady experience.
|
||||
- Refresh bundled docs in `docs/`, which are embedded by `src/docs.rs` and installed to `~/.cass/docs`.
|
||||
- Add or split reference docs for commands, configuration, providers/models, access modes/tool safety, workflows, troubleshooting, platform notes, and glossary terms.
|
||||
- Audit and update CLI help text in `src/cli.rs` only where it contradicts or underspecifies documented behavior.
|
||||
- Verify documented commands and examples against the actual CLI behavior.
|
||||
- Add lightweight documentation tests where practical, especially for bundled-doc presence and link integrity.
|
||||
- Keep documentation for both command names: `cass` and `cassady`.
|
||||
- Clearly describe current limitations and defer deep Windows runtime improvements to v0.2.4.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Broad CLI feature work or behavior changes beyond correcting inaccurate help text.
|
||||
- Windows terminal, filesystem, shell, and process usability fixes planned for v0.2.4.
|
||||
- Installer, package manager, code signing, auto-update, or PATH setup documentation that implies unsupported release channels.
|
||||
- Adding new provider protocols such as Anthropic-native APIs.
|
||||
- Reworking config formats or access-mode policy implementation.
|
||||
- Creating exhaustive model catalogs for providers.
|
||||
|
||||
## Context and Current State
|
||||
|
||||
Relevant files:
|
||||
|
||||
- `README.md`: current root overview; accurate in places but short and reference-heavy.
|
||||
- `docs/README.md`: bundled-doc index installed at runtime.
|
||||
- `docs/configuration.md`: current bundled configuration/setup reference; includes substantial v0.2.2 setup details.
|
||||
- `src/docs.rs`: embeds all files in `docs/`; changing docs changes the build-time docs hash.
|
||||
- `src/cli.rs`: Clap definitions for global flags and `check`/`setup` subcommands.
|
||||
- `src/check.rs`: rendered `cass check` output and next-step wording.
|
||||
- `src/setup.rs`: setup wizard prompts and provider catalog.
|
||||
- `src/config.rs`: config file schema, defaults, precedence, provider/model resolution.
|
||||
- `src/access.rs`, `src/security.rs`, and `src/tools/*`: access-mode/tool behavior that docs must describe accurately.
|
||||
- `src/app.rs`, `src/ui/render.rs`, `src/ui/events.rs`: chat commands, keys, rendering, cancellation, tool display, reasoning toggles.
|
||||
- `tests/docs_tests.rs`: current docs-related test coverage.
|
||||
|
||||
Existing documentation facts to preserve when accurate:
|
||||
|
||||
- Cassady ships two binaries, `cass` and `cassady`.
|
||||
- `cass` starts chat by default; `cass setup` runs the setup wizard; `cass check` validates config non-interactively.
|
||||
- Config lives under `~/.cass` today, with bundled docs installed to `~/.cass/docs`.
|
||||
- Provider/model configuration uses `config.json`, `providers.json`, and `models.json`.
|
||||
- OpenAI-compatible providers are the only supported provider kind.
|
||||
- API keys should usually be environment-variable references such as `"$FIREWORKS_API_KEY"`.
|
||||
- Access modes are `read-only`, `workspace-edit`, and `full-access`.
|
||||
- Tool output is compact by default; `Ctrl-O` toggles full tool output.
|
||||
- Reasoning is hidden by default; `Ctrl-Shift-R` toggles display and `Tab` cycles effort.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Docs are product UX.** Write polished explanatory prose, not a dump of implementation checklists.
|
||||
2. **Verify before claiming.** Every command, flag, env var, provider URL, config field, keybinding, and output snippet should be checked against the code or an actual run.
|
||||
3. **README first, references second.** Keep `README.md` focused on orientation, first use, common workflows, and links; move long tables and detailed references into `docs/`.
|
||||
4. **One terminology set.** Use consistent names: Cassady/Cass, workspace, session/chat, provider, model, access mode, tool call, tool result, setup wizard, bundled docs.
|
||||
5. **Avoid future promises.** Mention Windows limitations and planned v0.2.4 work without promising unimplemented terminal/process/path behavior.
|
||||
6. **Security-forward but practical.** Explain what Cassady can read, write, and run before encouraging users to grant broader access.
|
||||
|
||||
## Documentation Architecture
|
||||
|
||||
Use this structure unless implementation reveals a simpler split is better:
|
||||
|
||||
```text
|
||||
README.md
|
||||
|
||||
docs/README.md
|
||||
docs/commands.md
|
||||
docs/configuration.md
|
||||
docs/providers.md
|
||||
docs/access-modes.md
|
||||
docs/workflows.md
|
||||
docs/troubleshooting.md
|
||||
docs/platforms.md
|
||||
docs/glossary.md
|
||||
```
|
||||
|
||||
### Root README role
|
||||
|
||||
`README.md` should be the public landing page and fast-start guide. Suggested sections:
|
||||
|
||||
1. `# Cassady / Cass`
|
||||
2. Short product summary and current limitations.
|
||||
3. Install from source for development/current release usage.
|
||||
4. First use walkthrough.
|
||||
5. Everyday workflows.
|
||||
6. Safety model summary.
|
||||
7. Commands and key shortcuts summary.
|
||||
8. Configuration and providers summary with links.
|
||||
9. Troubleshooting quick links.
|
||||
10. Bundled docs and repository docs map.
|
||||
|
||||
Keep the README concise enough to read top-to-bottom. Move long provider tables, config schemas, and troubleshooting matrices into bundled docs.
|
||||
|
||||
### Bundled docs role
|
||||
|
||||
Bundled docs are installed to `~/.cass/docs` and are accessible to Cassady's docs tools. They should be self-contained enough to help a user inside a session.
|
||||
|
||||
- `docs/README.md`: index with short descriptions and links to all bundled docs.
|
||||
- `docs/commands.md`: complete CLI command, flag, alias, startup, interactive-vs-non-interactive, resume, and output-mode reference.
|
||||
- `docs/configuration.md`: config file locations, schemas, examples, precedence, validation, safe editing.
|
||||
- `docs/providers.md`: built-in OpenAI-compatible provider catalog, custom providers, model discovery, manual model entry, health checks, unsupported protocols.
|
||||
- `docs/access-modes.md`: read/write/edit/shell/docs access by mode, approvals, denied examples, workspace boundaries, symlink notes, diff review.
|
||||
- `docs/workflows.md`: task-oriented examples for chat, file inspection, edits, test/build commands, model switching, cancellation recovery.
|
||||
- `docs/troubleshooting.md`: actionable fixes for setup, provider/API, config, terminal, shell, edit, access, and line-ending failures.
|
||||
- `docs/platforms.md`: macOS/Linux/Windows notes, env var examples, path examples, known Windows limitations, no installer promises.
|
||||
- `docs/glossary.md`: definitions for recurring concepts.
|
||||
|
||||
## Detailed Content Requirements
|
||||
|
||||
### README rewrite
|
||||
|
||||
Include a current, concise product description:
|
||||
|
||||
```md
|
||||
Cassady (`cass`) is a terminal coding agent written in Rust. It runs an interactive chat in your project, can inspect files, propose and apply edits, run approved shell commands, and persist sessions for later resume. It currently talks to OpenAI-compatible providers.
|
||||
```
|
||||
|
||||
Document limitations explicitly:
|
||||
|
||||
- Only OpenAI-compatible chat/completions-style providers are supported.
|
||||
- Built-in docs and examples assume a terminal CLI workflow.
|
||||
- Windows support exists through cross-built binaries but deep Windows runtime polish is planned for v0.2.4.
|
||||
- Cassady is not an installer/updater/package manager.
|
||||
|
||||
### First-use walkthrough
|
||||
|
||||
Show a linear path:
|
||||
|
||||
```sh
|
||||
cass
|
||||
# or explicitly:
|
||||
cass setup
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
Cover:
|
||||
|
||||
- First-run setup trigger when active provider/model/API key cannot be resolved.
|
||||
- Provider selection from built-ins or custom OpenAI-compatible endpoint.
|
||||
- API key env vars, with POSIX and PowerShell examples labelled clearly.
|
||||
- Model discovery via `GET /models`, retry, and manual model id fallback.
|
||||
- What happens if setup writes config but the API key is still missing.
|
||||
- How to recover with `cass setup` and `cass check`.
|
||||
|
||||
### Command reference
|
||||
|
||||
Document these top-level forms based on `src/cli.rs`:
|
||||
|
||||
```sh
|
||||
cass [OPTIONS]
|
||||
cassady [OPTIONS]
|
||||
cass check [OPTIONS]
|
||||
cass setup [OPTIONS]
|
||||
cass --resume [CHAT_ID]
|
||||
```
|
||||
|
||||
Document global options:
|
||||
|
||||
- `--resume [CHAT_ID]`
|
||||
- `--model MODEL`
|
||||
- `--base-url URL`
|
||||
- `--api-key-env ENV`
|
||||
- `--cwd PATH`
|
||||
- `--readonly`
|
||||
- `--workspace-edit`
|
||||
- `--full-access`
|
||||
- `--help`
|
||||
- `--version`
|
||||
|
||||
Also document in-chat commands and keys from current behavior, including `/model`, `/new`, `/resume`, `/status`, `/`, `Tab`, `Shift-Tab`, `Ctrl-O`, `Ctrl-Shift-R`, scrolling, multiline input, and double `Ctrl-C` exit. Verify exact command names in code before finalizing.
|
||||
|
||||
### Configuration reference
|
||||
|
||||
Keep `docs/configuration.md` as the canonical config reference. It should explain:
|
||||
|
||||
- Location under `~/.cass` and current portability caveat.
|
||||
- `config.json`, `providers.json`, `models.json` responsibilities.
|
||||
- Default provider/model behavior and compatibility fields.
|
||||
- Precedence between config files, CLI overrides, setup wizard changes, and env vars.
|
||||
- API key reference syntax: only strings beginning with `$` are env refs; no partial expansion or `${NAME}` syntax unless code supports it.
|
||||
- Safe manual edits and `cass check` validation.
|
||||
- Valid and invalid JSON examples with fixes.
|
||||
|
||||
### Provider and model guide
|
||||
|
||||
Create `docs/providers.md` and move long provider details there. Include the v0.2.2 provider catalog:
|
||||
|
||||
| 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` |
|
||||
|
||||
Also explain:
|
||||
|
||||
- Provider configuration vs model metadata vs active defaults.
|
||||
- Model discovery limits and manual model id entry.
|
||||
- `supports_tools`, `supports_streaming`, and reasoning metadata in user-facing terms.
|
||||
- Unsupported provider protocols and what a custom OpenAI-compatible provider must implement.
|
||||
|
||||
### Access modes and tool safety
|
||||
|
||||
Create `docs/access-modes.md`. Include a mode/tool matrix such as:
|
||||
|
||||
| Tool area | read-only | workspace-edit | full-access |
|
||||
| --- | --- | --- | --- |
|
||||
| List/read/grep workspace files | yes | yes | yes |
|
||||
| Write/edit workspace files | no | yes | yes |
|
||||
| Read bundled docs | yes | yes | yes |
|
||||
| Write bundled docs | no | no | no |
|
||||
| Shell commands | no or denied unless code says otherwise | approval required | approval/destructive confirmation as implemented |
|
||||
| Outside workspace | no | no | allowed subject to OS permissions |
|
||||
|
||||
Verify exact shell availability and approval behavior from `src/access.rs`, `src/security.rs`, and `src/tools/shell.rs` before publishing this table.
|
||||
|
||||
Include denied-operation examples with realistic wording based on actual errors, not invented output if the code differs.
|
||||
|
||||
### Workflows and examples
|
||||
|
||||
Create `docs/workflows.md` with short examples for:
|
||||
|
||||
- Starting a chat in a workspace.
|
||||
- Asking Cassady to inspect files and explain code.
|
||||
- Asking for a proposed edit, reviewing tool calls, and applying edits.
|
||||
- Running tests or builds with shell approval.
|
||||
- Switching model with `/model <model>`.
|
||||
- Resuming sessions with `cass --resume` and `/resume`.
|
||||
- Cancelling a turn and continuing cleanly.
|
||||
|
||||
Examples should be realistic but not overly long. Avoid implying that Cassady will always make a specific sequence of tool calls.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Create `docs/troubleshooting.md` organized by symptom. Include:
|
||||
|
||||
- Missing active API key.
|
||||
- Invalid env var references.
|
||||
- Provider URL unreachable.
|
||||
- `/models` discovery failure.
|
||||
- Unsupported/invalid model id.
|
||||
- Rate limit/authentication errors.
|
||||
- Invalid JSON or unreadable config files.
|
||||
- Terminal rendering issues and redirected output caveats.
|
||||
- Shell command failures and approval/cancellation behavior.
|
||||
- Exact-text edit failures.
|
||||
- Binary/large/unsupported files.
|
||||
- CRLF/line-ending confusion.
|
||||
- Workspace access denials and symlink/bundled-doc restrictions.
|
||||
|
||||
Each entry should include: symptom, likely cause, fix, and command to verify when applicable.
|
||||
|
||||
### Platform notes
|
||||
|
||||
Create `docs/platforms.md` with careful current-state language:
|
||||
|
||||
- macOS/Linux examples can use POSIX shell syntax such as `export NAME=...`.
|
||||
- Windows examples should be labelled and use PowerShell syntax such as `$env:OPENAI_API_KEY = "..."`.
|
||||
- Document Windows path examples without claiming every Windows path edge case is polished.
|
||||
- State that v0.2.4 is planned to improve Windows terminal, path, shell, and filesystem behavior.
|
||||
- Avoid installation-channel promises beyond current source/release-artifact facts.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Inventory current behavior.**
|
||||
- Run `cargo run -- --help`, `cargo run -- check --help`, and `cargo run -- setup --help`.
|
||||
- Review `src/cli.rs`, `src/setup.rs`, `src/config.rs`, `src/access.rs`, `src/security.rs`, `src/tools/*`, and relevant UI command/key handling.
|
||||
- Record exact command names, flags, config fields, provider catalog entries, access rules, and keybindings.
|
||||
|
||||
2. **Design the docs map.**
|
||||
- Confirm the final bundled docs file list.
|
||||
- Decide which details stay in `README.md` and which move to `docs/`.
|
||||
- Keep `docs/README.md` as the navigable index.
|
||||
|
||||
3. **Rewrite `README.md`.**
|
||||
- Replace stale MVP/default-only language with current v0.2.2+ behavior.
|
||||
- Add first-use walkthrough, everyday workflows, safety summary, limitations, and links to detailed docs.
|
||||
- Keep examples copy/paste-ready and label platform-specific syntax.
|
||||
|
||||
4. **Refresh bundled reference docs.**
|
||||
- Update `docs/configuration.md` instead of duplicating schema details elsewhere.
|
||||
- Add `docs/commands.md`, `docs/providers.md`, `docs/access-modes.md`, `docs/workflows.md`, `docs/troubleshooting.md`, `docs/platforms.md`, and `docs/glossary.md` as needed.
|
||||
- Update `docs/README.md` links and summaries.
|
||||
|
||||
5. **Synchronize CLI help text.**
|
||||
- Make minimal edits to `src/cli.rs` descriptions if help text conflicts with the refreshed docs.
|
||||
- Do not change command behavior in this release unless a documentation verification step uncovers a severe typo or misleading help string.
|
||||
|
||||
6. **Add lightweight docs validation.**
|
||||
- Extend `tests/docs_tests.rs` or add a new docs test to ensure all linked bundled docs exist.
|
||||
- Consider checking that `docs/README.md` links are relative and valid.
|
||||
- If practical, add a test that important terms or command names appear in the bundled docs index.
|
||||
|
||||
7. **Verify examples.**
|
||||
- Run documented help/check/setup commands where safe.
|
||||
- Use a temporary Cass root or environment isolation for config examples when possible.
|
||||
- Verify internal links and fenced command snippets manually or with tests.
|
||||
|
||||
8. **Final consistency pass.**
|
||||
- Search for old terminology, obsolete commands, stale provider data, and outdated MVP wording.
|
||||
- Ensure README, bundled docs, CLI help, and roadmap use the same terms.
|
||||
- Run formatting/tests.
|
||||
|
||||
## Tests and Verification
|
||||
|
||||
Automated checks:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test --locked --all-targets
|
||||
```
|
||||
|
||||
Docs-specific checks to add or perform:
|
||||
|
||||
- `tests/docs_tests.rs` validates bundled docs install/embedding behavior still passes.
|
||||
- New or updated test validates `docs/README.md` links resolve to existing bundled docs files.
|
||||
- `cargo run -- --help` output matches `docs/commands.md`.
|
||||
- `cargo run -- check --help` and `cargo run -- setup --help` output are documented accurately.
|
||||
- `cargo run -- check` behavior is represented accurately, preferably with a temp config root if the code supports test helpers.
|
||||
|
||||
Manual review checklist:
|
||||
|
||||
- Every internal Markdown link works.
|
||||
- Every command name exists.
|
||||
- Every flag is spelled exactly as Clap exposes it.
|
||||
- Every provider URL/env var matches setup's provider catalog.
|
||||
- Every config field in examples is accepted by the current parser.
|
||||
- Access-mode descriptions match current policy code.
|
||||
- Windows notes are cautious and do not include v0.2.4 promises as current behavior.
|
||||
|
||||
## Documentation Deliverables
|
||||
|
||||
Required:
|
||||
|
||||
- Updated `README.md`.
|
||||
- Updated `docs/README.md`.
|
||||
- Updated `docs/configuration.md`.
|
||||
- New command/provider/access/workflow/troubleshooting/platform/glossary docs, unless the implementer chooses a smaller file split and preserves all required content.
|
||||
- Any necessary tiny CLI help text corrections.
|
||||
- Updated docs tests.
|
||||
|
||||
Not required:
|
||||
|
||||
- Release notes, unless the release process is being run.
|
||||
- Website docs.
|
||||
- Generated `dist/` artifacts.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `README.md` accurately describes current Cassady behavior and guides first use from setup through first chat.
|
||||
- Bundled docs provide complete references for commands, config, providers/models, access modes/tool safety, workflows, troubleshooting, platforms, and glossary concepts.
|
||||
- Documentation covers both `cass` and `cassady` command names.
|
||||
- Provider catalog, API key env vars, config examples, access mode descriptions, and keybindings match the code.
|
||||
- Windows documentation is accurate but clearly defers deep Windows runtime polish to v0.2.4.
|
||||
- Obsolete MVP language, stale commands, and misleading defaults are removed.
|
||||
- Internal links resolve and docs tests cover bundled-doc navigation where practical.
|
||||
- `cargo fmt --check` and `cargo test --locked --all-targets` pass before release handoff.
|
||||
@@ -0,0 +1,499 @@
|
||||
# v0.2.4 System Prompt Refinement Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
v0.2.4 refines Cassady's generated system prompt so the model receives clearer, more intuitive operating instructions without turning the prompt into a long manual. The effective prompt should explain Cassady's role, terminal transcript behavior, tool use, editing expectations, runtime safety constraints, and response style in a structured way that models can follow reliably.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A normal effective system prompt, excluding user-provided global instructions, is roughly 900-1,100 tokens and consistently guides the model to inspect before editing, use tools directly when useful, respect access modes, make targeted file changes, and finish each turn with an honest concise response.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Rewrite `src/prompt.rs` prompt text into a clearer sectioned structure.
|
||||
- Preserve the existing prompt-generation model:
|
||||
- `build_base_system_prompt(global)` creates reusable conversation-level instructions.
|
||||
- `build_effective_system_prompt(...)` appends model/workspace/docs/access/tool runtime constraints.
|
||||
- `~/.cass/global.md` text remains embedded as user global instructions when present.
|
||||
- Add product context that helps the model understand Cassady's terminal chat UX.
|
||||
- Improve guidance for tool selection, exact-text edits, use of shell, and test/summarization behavior.
|
||||
- Make access-mode guidance concise and easy to map to the currently allowed tools.
|
||||
- Add focused tests for prompt sections, ordering, global instructions, access modes, and approximate size.
|
||||
- Update docs only where they mention global instructions or prompt behavior.
|
||||
- Keep the prompt provider-agnostic and compatible with all OpenAI-compatible models Cassady supports.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Adding prompt templates, profile selection, or user-selectable prompt modes.
|
||||
- Exposing a CLI command to print or edit the full generated system prompt.
|
||||
- Changing the `~/.cass/global.md` file format or adding layered project instructions.
|
||||
- Changing access-policy enforcement, tool schemas, approval UI, or security decisions.
|
||||
- Adding new tools or changing tool argument formats.
|
||||
- Implementing automatic prompt compression or conversation summarization.
|
||||
- Maintaining separate prompts per provider/model family.
|
||||
- Stuffing large reference documentation, provider catalogs, or CLI help into the system prompt.
|
||||
|
||||
## Context and Current State
|
||||
|
||||
Relevant files:
|
||||
|
||||
- `src/prompt.rs`: builds both the base and effective system prompts. The current prompt is short and functional but sparse.
|
||||
- `src/app.rs`: reads global instructions and stores the base prompt in new conversations.
|
||||
- `src/agent.rs`: calls `build_effective_system_prompt(...)` before provider requests.
|
||||
- `src/conversation.rs`: persists the base system prompt in the conversation record and reuses it when a chat is resumed.
|
||||
- `src/access.rs`: defines `read-only`, `workspace-edit`, and `full-access` modes.
|
||||
- `src/security.rs`: central policy for tool availability, read/write boundaries, shell approval, and denials.
|
||||
- `src/tools/*`: tool implementations and schemas for `ls`, `read`, `grep`, `write`, `edit`, and `shell`.
|
||||
- `docs/glossary.md`: defines global instructions as optional text in `~/.cass/global.md` included in new chat system prompts.
|
||||
- `tests/*`: no dedicated prompt tests exist yet; prompt behavior is only indirectly covered through agent/conversation tests.
|
||||
|
||||
Current prompt behavior to preserve:
|
||||
|
||||
- Cassady identifies itself as Cassady/Cass, a coding agent running in a terminal chat interface.
|
||||
- User global instructions are included only when non-empty after trimming.
|
||||
- Global instructions are subordinate to runtime safety constraints.
|
||||
- The effective prompt includes:
|
||||
- model id,
|
||||
- active access mode,
|
||||
- launch working directory,
|
||||
- bundled docs directory,
|
||||
- allowed tools,
|
||||
- access-mode-specific instructions,
|
||||
- final response behavior.
|
||||
- Tool access is ultimately enforced by runtime policy, not by prompt wording alone.
|
||||
|
||||
Current gaps:
|
||||
|
||||
- The prompt is organized as numbered sections but does not fully explain Cassady's user-visible transcript model.
|
||||
- Tool and editing guidance is too compact for models that need stronger direction on when to inspect, search, edit, write, or run shell.
|
||||
- Access-mode text is accurate but can be made more direct and less repetitive.
|
||||
- There is no automated check that future prompt edits preserve required sections or stay near the intended size.
|
||||
- Documentation mentions global instructions, but not how they relate to runtime safety constraints in the refined prompt.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **High signal, low bulk.** The prompt should contain the instructions most likely to improve model behavior, not a copy of the README.
|
||||
2. **Runtime policy remains authoritative.** Prompt text should guide the model, while `src/security.rs` and tool availability continue to enforce real permissions.
|
||||
3. **Structure beats length.** Use clear headings and dense paragraphs/bullets so models can find instructions during long sessions.
|
||||
4. **Tell the model what the user can see.** Explain streamed output, visible tool calls/results, approvals, and edit diffs so the assistant does not narrate inaccurately.
|
||||
5. **Prefer action over ceremony.** Encourage the model to use tools directly, inspect before changing files, and ask questions only when missing information materially blocks progress.
|
||||
6. **Make editing rules concrete.** Exact-text edits are a core reliability constraint and should be stated plainly.
|
||||
7. **Avoid provider-specific assumptions.** The prompt should work for small and large OpenAI-compatible models without relying on special model behavior.
|
||||
8. **Keep user instructions safe.** Global instructions are important, but they must never override runtime access modes, tool denials, or user requests in the active chat.
|
||||
|
||||
## Prompt Architecture
|
||||
|
||||
Keep the two-stage prompt generation, but make the internal structure more intentional.
|
||||
|
||||
### Base prompt
|
||||
|
||||
`build_base_system_prompt(global)` should contain stable instructions that are true for every session:
|
||||
|
||||
1. Identity and role.
|
||||
2. Operating principles.
|
||||
3. User global instructions, when present.
|
||||
4. Tool-use behavior.
|
||||
5. Editing behavior.
|
||||
6. Response behavior.
|
||||
|
||||
The base prompt is stored in the conversation when a chat is created. Because resumed chats reuse the stored base prompt, changing the base prompt affects new chats but not necessarily existing conversations. That behavior is acceptable and should be documented only if user-facing docs mention prompt changes.
|
||||
|
||||
### Effective prompt
|
||||
|
||||
`build_effective_system_prompt(...)` should append runtime-specific information near the end:
|
||||
|
||||
1. Current runtime context:
|
||||
- model,
|
||||
- access mode,
|
||||
- launch working directory,
|
||||
- bundled docs directory,
|
||||
- allowed tools.
|
||||
2. Access-mode rules for the active mode.
|
||||
3. Final reminder that runtime policy and tool results are authoritative.
|
||||
|
||||
Runtime constraints should remain near the end so they are fresh in the model's context and can override earlier general instructions.
|
||||
|
||||
### Numbering and headings
|
||||
|
||||
Use stable Markdown-like headings rather than fragile sentence-only text. For example:
|
||||
|
||||
```text
|
||||
# Cassady operating instructions
|
||||
|
||||
## Role
|
||||
...
|
||||
|
||||
## Working style
|
||||
...
|
||||
```
|
||||
|
||||
Numbered headings are acceptable if tests are written against section names rather than exact numbers. Avoid deeply nested outlines.
|
||||
|
||||
## Target Prompt Content
|
||||
|
||||
The final wording can change during implementation, but it should cover the following content.
|
||||
|
||||
### 1. Role
|
||||
|
||||
Required ideas:
|
||||
|
||||
- You are Cassady, also called Cass.
|
||||
- You are a coding assistant inside an interactive terminal chat.
|
||||
- You help with real project work: reading code, explaining behavior, editing files, and running relevant commands when allowed.
|
||||
- Work carefully and honestly; do not pretend to have inspected or changed files unless tool results confirm it.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Overly broad claims such as being a general-purpose autonomous system.
|
||||
- Long branding language.
|
||||
- Any implication that prompt instructions can bypass runtime access policy.
|
||||
|
||||
### 2. Working style
|
||||
|
||||
Required ideas:
|
||||
|
||||
- Prefer concrete progress over long speculative plans.
|
||||
- Inspect relevant files before making claims or edits.
|
||||
- Ask a focused follow-up question only when the task is ambiguous or blocked.
|
||||
- Keep explanations concise but include enough context for the user to review the work.
|
||||
- If the user's request is impossible in the current mode, explain the limitation and the next viable step.
|
||||
|
||||
Suggested wording style:
|
||||
|
||||
```text
|
||||
Make the smallest useful plan, then act. Do not over-plan routine code tasks. When information is missing, gather it with tools if possible; ask the user only when a choice or secret is genuinely required.
|
||||
```
|
||||
|
||||
### 3. Transcript and UI awareness
|
||||
|
||||
Required ideas:
|
||||
|
||||
- Assistant text is streamed to the user.
|
||||
- Tool calls and tool results are visible in the transcript.
|
||||
- Edit diffs and approval prompts may be shown by Cassady's UI.
|
||||
- The model should request tools directly rather than asking for chat permission before every tool call.
|
||||
- Cassady handles access denials and approval UI separately.
|
||||
|
||||
This section should reduce behaviors such as:
|
||||
|
||||
- Saying "I will run X" and then not calling the tool.
|
||||
- Asking "May I read the file?" when the tool is available.
|
||||
- Claiming a shell command ran before its result arrives.
|
||||
- Repeating huge summaries of tool output that the user can already see.
|
||||
|
||||
### 4. Tool use
|
||||
|
||||
Required guidance by tool area:
|
||||
|
||||
- `ls`: use for directory orientation.
|
||||
- `grep`: use before reading large or unknown files, or to locate definitions/usages.
|
||||
- `read`: use targeted reads for files or ranges that matter.
|
||||
- `edit`: use for focused changes to existing files.
|
||||
- `write`: use for new files or intentional full rewrites.
|
||||
- `shell`: use for tests, builds, formatting, diagnostics, or project commands when allowed and useful.
|
||||
|
||||
General instructions:
|
||||
|
||||
- Use tools when current filesystem state matters.
|
||||
- Prefer targeted inspection over guessing.
|
||||
- Batch related reads when possible, but avoid reading unrelated files.
|
||||
- Do not use shell for file inspection when `ls`/`grep`/`read` is safer and sufficient.
|
||||
- If a tool is denied, adapt to the denial instead of repeating the same call.
|
||||
|
||||
### 5. Editing
|
||||
|
||||
Required ideas:
|
||||
|
||||
- Prefer `edit` for small and medium modifications to existing files.
|
||||
- `edit` replacements must use exact old text that appears uniquely in the original file.
|
||||
- Keep edits minimal, unique, and non-overlapping.
|
||||
- Combine related replacements for the same file in one `edit` call when practical.
|
||||
- Use `write` only for new files or full rewrites where that is safer and intentional.
|
||||
- After meaningful code changes, run relevant tests/formatters when allowed, or tell the user what should be run.
|
||||
- Mention changed files and verification in the final response.
|
||||
|
||||
### 6. Safety and access modes
|
||||
|
||||
The base prompt should state the general principle:
|
||||
|
||||
- Follow runtime constraints and active access mode.
|
||||
- Do not try to bypass workspace boundaries, docs read-only rules, approvals, or tool denials.
|
||||
|
||||
The effective prompt should include active-mode-specific guidance:
|
||||
|
||||
#### read-only
|
||||
|
||||
- Allowed tools should normally be `ls`, `read`, and `grep`.
|
||||
- Inspect only inside the launch workspace and bundled docs directory.
|
||||
- Do not request `write`, `edit`, or `shell`.
|
||||
- If changes or commands are needed, explain that a more permissive access mode is required.
|
||||
|
||||
#### workspace-edit
|
||||
|
||||
- Read/list/search inside the launch workspace and bundled docs directory.
|
||||
- Write/edit only inside the launch workspace.
|
||||
- Bundled docs are read-only.
|
||||
- Shell may be requested when useful, but Cassady will show the approval UI; do not ask for shell permission in chat first.
|
||||
- If a path escapes the workspace, choose an in-workspace alternative or explain the limitation.
|
||||
|
||||
#### full-access
|
||||
|
||||
- `ls`, `read`, `grep`, `write`, `edit`, and `shell` may be requested when needed.
|
||||
- Shell runs from the launch working directory.
|
||||
- Normal OS permissions still apply.
|
||||
- Bundled docs remain read-only for write/edit.
|
||||
- Even in full-access, keep changes targeted and avoid destructive commands unless the user explicitly requested them and the action is necessary.
|
||||
|
||||
### 7. Final response behavior
|
||||
|
||||
Required ideas:
|
||||
|
||||
- Always end the turn with a concise user-facing response after tool work.
|
||||
- Do not finish with only tool calls.
|
||||
- Summarize what changed, where, and how it was verified.
|
||||
- If no changes were made, summarize findings or blockers.
|
||||
- Be honest about failures, denials, skipped tests, or assumptions.
|
||||
|
||||
## Approximate Prompt Budget
|
||||
|
||||
Target size: roughly 900-1,100 tokens for the normal effective system prompt, excluding user global instructions.
|
||||
|
||||
Because Cassady does not currently include a tokenizer, implement a simple approximate check rather than adding a heavy tokenizer dependency unless the implementer strongly prefers otherwise.
|
||||
|
||||
Recommended helper for tests:
|
||||
|
||||
```rust
|
||||
fn approximate_token_count(s: &str) -> usize {
|
||||
s.split_whitespace().count() * 4 / 3
|
||||
}
|
||||
```
|
||||
|
||||
This heuristic is intentionally rough. The test should prevent accidental prompt bloat, not enforce an exact model-token count. Suggested limits:
|
||||
|
||||
- Base prompt without global instructions: approximately 650-850 heuristic tokens.
|
||||
- Effective prompt in each access mode without global instructions: approximately 900-1,150 heuristic tokens.
|
||||
|
||||
If the final prompt is slightly outside the target but demonstrably better, prefer readability over gaming the heuristic. The acceptance target should remain "around 1,000 tokens," not an exact failure-prone threshold.
|
||||
|
||||
## Proposed Prompt Skeleton
|
||||
|
||||
This skeleton is illustrative, not a required exact implementation.
|
||||
|
||||
```text
|
||||
# Cassady operating instructions
|
||||
|
||||
## Role
|
||||
You are Cassady, also called Cass, a coding assistant running in an interactive terminal chat. Help with real project work: inspect files, explain code, make targeted edits, and run useful commands when allowed. Work carefully and do not claim that files were read, commands ran, or edits succeeded until tool results confirm it.
|
||||
|
||||
## Working style
|
||||
Make the smallest useful plan, then act. Prefer current project evidence over guesses. Use tools to gather missing filesystem context. Ask a focused follow-up question only when a user choice, secret, or missing requirement blocks progress. Keep user-facing explanations concise and practical.
|
||||
|
||||
## Transcript and tools
|
||||
Assistant text is streamed. Tool calls, tool results, approvals, and edit diffs are visible in the transcript. Request tools directly when they are the right next step; Cassady enforces access policy and shows approval prompts separately. If a tool is denied or fails, adapt and explain the limitation.
|
||||
|
||||
## Tool use
|
||||
Use ls for directory orientation, grep to locate text or inspect large/unknown areas, read for relevant files or ranges, edit for targeted changes, write for new files or intentional full rewrites, and shell for tests/builds/diagnostics when allowed. Prefer targeted reads and related batched reads over broad exploration.
|
||||
|
||||
## Editing
|
||||
Inspect before editing. For edit, each old text must match exactly and uniquely in the original file; keep replacements minimal and non-overlapping. Do not use write for small changes to existing files. After meaningful code changes, run relevant verification when allowed or state what should be run.
|
||||
|
||||
## User global instructions
|
||||
...
|
||||
|
||||
## Runtime context
|
||||
Model: ...
|
||||
Access mode: ...
|
||||
Launch working directory: ...
|
||||
Bundled Cass docs directory: ...
|
||||
Allowed tools this turn: ...
|
||||
|
||||
## Access rules for this session
|
||||
...
|
||||
|
||||
## Final response
|
||||
End every turn with a concise response. Summarize changed files and verification, or summarize findings/blockers if no change was made. Do not end with only tool calls.
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Inventory exact current behavior
|
||||
|
||||
- Review `src/prompt.rs`, `src/agent.rs`, `src/app.rs`, `src/conversation.rs`, `src/access.rs`, `src/security.rs`, and `src/tools/schema.rs`.
|
||||
- Confirm current tool names and per-mode availability from `SecurityPolicy::tool_availability`.
|
||||
- Confirm docs directory behavior and blocked write roots from app/tool context construction.
|
||||
- Confirm how global instructions are loaded and trimmed.
|
||||
- Confirm how resumed conversations reuse the stored base prompt.
|
||||
|
||||
### 2. Rewrite `build_base_system_prompt`
|
||||
|
||||
- Replace the current compact numbered prompt with structured, high-signal sections.
|
||||
- Include identity, working style, transcript/tool visibility, general tool guidance, editing guidance, global instructions, and response behavior.
|
||||
- Preserve trimming behavior for `global`.
|
||||
- Keep global instructions clearly labelled and explicitly subordinate to runtime safety constraints.
|
||||
- Avoid including runtime-only values in the base prompt.
|
||||
|
||||
### 3. Rewrite `build_effective_system_prompt`
|
||||
|
||||
- Keep appending to `base.trim_end()`.
|
||||
- Add a clear `Runtime context` section with model, access mode, cwd, docs dir, and allowed tools.
|
||||
- Add one active-mode-specific `Access rules for this session` paragraph/bullet set.
|
||||
- Keep runtime constraints after global/base text.
|
||||
- Keep the final response reminder either at the end of the base prompt or at the end of the effective prompt. If it remains in the base prompt, add a short final runtime-policy reminder after access rules.
|
||||
|
||||
### 4. Add prompt tests
|
||||
|
||||
Create `tests/prompt_tests.rs` or add focused unit tests in `src/prompt.rs`. Prefer integration tests in `tests/prompt_tests.rs` so prompt behavior is covered through the public crate API if exports allow it.
|
||||
|
||||
Recommended tests:
|
||||
|
||||
1. **Base prompt has required sections.**
|
||||
- Build with `None`.
|
||||
- Assert it contains headings/phrases for role, working style, tools, editing, and final response behavior.
|
||||
- Assert it does not contain runtime-only paths or model labels.
|
||||
|
||||
2. **Global instructions are included and trimmed.**
|
||||
- Build with whitespace-wrapped global text.
|
||||
- Assert the exact trimmed content appears.
|
||||
- Assert the prompt says global instructions cannot conflict with runtime safety constraints.
|
||||
|
||||
3. **Empty global instructions are omitted.**
|
||||
- Build with `Some(" \n")`.
|
||||
- Assert the global-instructions heading is absent.
|
||||
|
||||
4. **Effective prompt includes runtime context.**
|
||||
- Use temporary or fixed paths for cwd/docs.
|
||||
- Assert model, mode, cwd, docs dir, and allowed tools are present.
|
||||
|
||||
5. **Each access mode gets correct instructions.**
|
||||
- `read-only`: contains no write/edit/shell request guidance and says more permissive mode is needed for modifications.
|
||||
- `workspace-edit`: says write/edit only inside workspace and shell approval is handled by Cassady UI.
|
||||
- `full-access`: says all tools may be requested, shell runs from cwd, docs remain read-only.
|
||||
|
||||
6. **Runtime constraints stay after global instructions.**
|
||||
- Build base with global text, then effective prompt.
|
||||
- Assert global text index is before runtime context index.
|
||||
- Assert access rules appear after runtime context.
|
||||
|
||||
7. **Prompt size remains intentional.**
|
||||
- Build effective prompts for all modes without global instructions.
|
||||
- Use the approximate token helper.
|
||||
- Assert each is within the chosen guardrail, for example `800..=1250` approximate tokens.
|
||||
|
||||
8. **Allowed tools list reflects caller input.**
|
||||
- Pass a small custom tool list.
|
||||
- Assert the rendered list matches it.
|
||||
|
||||
Test guidance:
|
||||
|
||||
- Avoid asserting the entire prompt as one giant snapshot unless the project already uses snapshot testing.
|
||||
- Prefer stable phrases and section headings so minor copy edits do not make tests brittle.
|
||||
- If a snapshot is added, keep it intentionally small or use one golden prompt plus semantic tests.
|
||||
|
||||
### 5. Update docs references
|
||||
|
||||
Update only docs that need to mention global instructions or prompt behavior.
|
||||
|
||||
Likely files:
|
||||
|
||||
- `docs/glossary.md`: expand `Global instructions` to say they are included in new chat system prompts and followed unless they conflict with runtime safety constraints.
|
||||
- `docs/configuration.md` or `README.md` only if they already mention `~/.cass/global.md` and need clarification.
|
||||
|
||||
Do not publish the full internal system prompt in docs. It is implementation detail and will evolve.
|
||||
|
||||
### 6. Run verification
|
||||
|
||||
Required commands:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test --locked --all-targets
|
||||
```
|
||||
|
||||
If prompt tests use temp paths or platform-dependent path display, run on the current platform and avoid hardcoding separators where possible.
|
||||
|
||||
## Tests
|
||||
|
||||
Automated tests to add:
|
||||
|
||||
- New prompt tests covering base prompt structure, global instruction behavior, effective runtime context, access-mode-specific wording, ordering, and approximate size.
|
||||
- Existing agent/conversation/tool tests should continue to pass unchanged.
|
||||
|
||||
Manual checks:
|
||||
|
||||
- Read one generated prompt for each access mode and verify it is understandable as prose.
|
||||
- Check that the prompt does not repeat the same instruction in several sections.
|
||||
- Check that docs/global-instruction wording matches the generated prompt.
|
||||
- Confirm a normal prompt is around 1,000 tokens by the chosen heuristic or an external tokenizer if one is available.
|
||||
|
||||
## Documentation
|
||||
|
||||
Required documentation updates are intentionally small:
|
||||
|
||||
- `docs/glossary.md`: update the `Global instructions` definition.
|
||||
- Any existing README/configuration references to `~/.cass/global.md`: clarify that these instructions are included in new chat system prompts and cannot override safety constraints.
|
||||
|
||||
No new user guide is required for this release unless implementation adds user-visible commands or configuration, which is out of scope.
|
||||
|
||||
## Compatibility and Migration Notes
|
||||
|
||||
- Existing conversations keep the base system prompt stored when they were created. The refined base prompt will apply to new conversations.
|
||||
- Runtime constraints are still generated at request time, so active access mode, cwd, docs dir, and allowed tools remain current for resumed chats.
|
||||
- `~/.cass/global.md` remains plain text and does not require migration.
|
||||
- No config schema changes are expected.
|
||||
- No provider/model configuration changes are expected.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Risk: prompt grows too large
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Add a size guardrail test.
|
||||
- Keep docs/provider details out of the prompt.
|
||||
- Prefer compact instructions over long examples.
|
||||
|
||||
### Risk: tests become brittle
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Test for section presence and key behavior, not every exact sentence.
|
||||
- Keep exact string assertions limited to stable safety-critical phrases.
|
||||
|
||||
### Risk: prompt implies permissions the runtime denies
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Derive access-mode wording from `SecurityPolicy::tool_availability` and current policy behavior.
|
||||
- Include allowed tools in the runtime context.
|
||||
- Phrase guidance as "may request when allowed" rather than unconditional permission, except in mode-specific sections verified against code.
|
||||
|
||||
### Risk: global instructions appear stronger than safety rules
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Place global instructions in a clearly labelled section.
|
||||
- State that they are followed only when consistent with user requests and runtime safety constraints.
|
||||
- Add a test for this wording.
|
||||
|
||||
### Risk: models ignore concise instructions
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Use direct imperative wording.
|
||||
- Put runtime constraints near the end.
|
||||
- Avoid burying editing and safety rules in long paragraphs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `src/prompt.rs` produces a structured, readable prompt with clear sections for role, working style, transcript/tool behavior, tool use, editing, runtime context, access rules, and final responses.
|
||||
- The effective prompt for each access mode is roughly 900-1,100 tokens excluding user global instructions, with an automated guardrail preventing major accidental bloat.
|
||||
- User global instructions are included only when non-empty, trimmed, clearly labelled, and subordinate to runtime safety constraints.
|
||||
- Runtime context includes model, access mode, launch cwd, bundled docs directory, and allowed tools.
|
||||
- Access-mode guidance matches current policy for `read-only`, `workspace-edit`, and `full-access`.
|
||||
- Editing instructions explicitly cover exact unique old text, minimal non-overlapping replacements, and using `write` only for new files or intentional rewrites.
|
||||
- Tool-use instructions explain when to use `ls`, `grep`, `read`, `edit`, `write`, and `shell` without over-constraining the model.
|
||||
- Final response guidance requires a concise user-facing response after tool work and honest reporting of verification or blockers.
|
||||
- Documentation references to global instructions are accurate and do not expose the full internal prompt.
|
||||
- `cargo fmt --check` and `cargo test --locked --all-targets` pass.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,373 @@
|
||||
# v0.2.8 Conversation Branch and Restore Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
v0.2.8 adds an in-chat branch and restore menu opened by pressing `Esc` twice while Cassady is idle. Users should be able to browse the current conversation timeline, choose a checkpoint at a user message, assistant message, or tool call, and branch from that point without destroying the original conversation. They can optionally restore Cassady-tracked file edits to match the selected checkpoint, or branch the conversation only.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A user can press `Esc` twice, select an earlier message or tool call, create a new branch from that point, optionally restore tracked file edits, and later open the same menu from either branch to switch or branch again from the related conversation history.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add a double-`Esc` idle shortcut that opens a branch/restore menu, similar in feel to the double-`Ctrl-C` exit affordance.
|
||||
- Keep the existing busy `Esc` behavior for turn cancellation and approval denial.
|
||||
- Add a branch-aware conversation model that creates a new conversation file when restoring to a checkpoint instead of truncating or overwriting the original chat.
|
||||
- Let users browse checkpoints for:
|
||||
- user messages,
|
||||
- assistant messages,
|
||||
- assistant tool-call requests,
|
||||
- completed tool results.
|
||||
- Preserve valid model conversation structure when branching at or around tool calls.
|
||||
- Track file mutations made by Cassady's `write` and `edit` tools with enough before/after data to restore workspace files backward or forward between tracked checkpoints.
|
||||
- Offer restore actions that clearly separate conversation-only branching from conversation-plus-file restoration.
|
||||
- Allow users to return to the original conversation or other related branches by opening the menu again.
|
||||
- Add tests for branch metadata, checkpoint extraction, valid tool-call repair, file-edit journaling, workspace restore planning, conflict detection, and keybinding behavior where practical.
|
||||
- Update README and bundled docs for the new shortcut, branch semantics, file-restore limitations, and safety prompts.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Rewriting arbitrary filesystem changes made by shell commands, editors, package managers, test runners, or the user outside Cassady's `write`/`edit` tools.
|
||||
- Git integration, commits, worktrees, or automatic VCS operations.
|
||||
- A visual diff editor for every file restore. v0.2.8 should show a concise restore plan and rely on safe conflict checks.
|
||||
- Merging branches or replaying assistant responses across branches.
|
||||
- Branching while an agent turn is running.
|
||||
- Changing provider message semantics beyond the minimum repair needed for valid branched conversations.
|
||||
|
||||
## Context and Current State
|
||||
|
||||
Relevant files and behavior:
|
||||
|
||||
- `src/app.rs` owns the TUI event loop, current conversation, transcript blocks, double-`Ctrl-C` exit behavior, busy `Esc` cancellation, local `/new` and `/resume` commands, and turn spawning.
|
||||
- `src/conversation.rs` stores conversations as append-only JSONL with `Meta`, `System`, `User`, `Assistant`, and `Tool` records. There is no branch metadata, checkpoint API, or rewrite/create-from-prefix helper yet.
|
||||
- `src/agent.rs` appends user, assistant, and tool records during a turn. Assistant records can contain multiple tool calls, while each tool result is a separate `Record::Tool`.
|
||||
- `src/tools/edit.rs` and `src/tools/write.rs` perform atomic writes and return user-visible summaries/diffs, but they do not persist before/after snapshots that can be used for later restore.
|
||||
- `src/ui/render.rs` renders the main chat. A branch menu should be integrated as an in-TUI modal or state, not by dropping into the setup/update prompt menu in `src/menu.rs`.
|
||||
|
||||
The key design constraint is that restore must not mean destructive truncation. Selecting an old point creates a new branch conversation and leaves the source conversation available.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Branch, do not erase.** Restoring conversation state always creates or switches to a conversation branch; the original JSONL file remains intact.
|
||||
2. **Make file restore explicit.** Conversation branching is safe and default. File restoration is a separate confirmation because it changes the workspace.
|
||||
3. **Keep model history valid.** Branches created at tool boundaries must not leave assistant tool calls without corresponding tool records.
|
||||
4. **Track only what Cassady can prove.** File restore uses durable snapshots from `write`/`edit`; unsupported shell/user changes are detected or warned about, not guessed.
|
||||
5. **Recoverable navigation.** Every branch keeps parent/checkpoint metadata so the menu can show the related branch family and let users switch or branch again.
|
||||
6. **Small, testable modules.** Put checkpoint extraction, branch creation, and file restore planning in dedicated modules rather than expanding the TUI loop with business logic.
|
||||
|
||||
## User Experience
|
||||
|
||||
### Shortcut behavior
|
||||
|
||||
- While idle, first `Esc` sets status text:
|
||||
|
||||
```text
|
||||
press Esc again within 1.5s to branch or restore
|
||||
```
|
||||
|
||||
- A second `Esc` within the same window opens the branch/restore menu.
|
||||
- If the input box is non-empty, do not discard it silently. The first `Esc` should keep the input and show the same status; opening the menu should preserve the draft input if the user cancels.
|
||||
- While an agent turn is running, keep the current behavior: `Esc` cancels the active turn. Do not open the branch menu while busy.
|
||||
- During approval prompts, keep `Esc` as denial for the approval request.
|
||||
|
||||
### Main branch menu
|
||||
|
||||
The menu should show the current branch family, not only the current JSONL prefix:
|
||||
|
||||
```text
|
||||
Branch / Restore
|
||||
|
||||
Current chat: 2026-06-25-101533-abcd
|
||||
|
||||
Branches
|
||||
• current branch
|
||||
• original chat from before restore
|
||||
• earlier branch: "try without refactor"
|
||||
|
||||
Timeline
|
||||
1. user Add tests for config loading
|
||||
2. assistant Proposed plan
|
||||
3. tool read src/config.rs ✓
|
||||
4. assistant Found config parser
|
||||
5. tool edit src/config.rs ✓ file: src/config.rs
|
||||
6. user Make it cleaner
|
||||
```
|
||||
|
||||
Keyboard controls should be consistent with the main TUI: up/down or `j`/`k` move, Enter selects, `Esc` cancels, and an optional `/` filter can be deferred unless cheap.
|
||||
|
||||
### Checkpoint actions
|
||||
|
||||
After selecting a checkpoint, show an action menu:
|
||||
|
||||
```text
|
||||
Branch from checkpoint
|
||||
|
||||
Selected: tool edit src/config.rs at 10:24:11
|
||||
|
||||
1. Branch conversation only
|
||||
2. Branch conversation and restore tracked file edits
|
||||
3. Preview tracked file restore plan
|
||||
4. Cancel
|
||||
```
|
||||
|
||||
Default should be conversation-only. The branch should get a fresh chat id, copy records through the selected checkpoint, and append branch metadata. The status should make the branch explicit:
|
||||
|
||||
```text
|
||||
branched 2026-06-25-110212-wxyz from 2026-06-25-101533-abcd at tool edit src/config.rs
|
||||
```
|
||||
|
||||
### Switching among related branches
|
||||
|
||||
Opening the menu from a branch should show its ancestors and descendants. Users can switch back to an existing branch without creating another branch:
|
||||
|
||||
```text
|
||||
Switch to branch
|
||||
|
||||
original 2026-06-25-101533-abcd 18 records
|
||||
current 2026-06-25-110212-wxyz branched at tool edit src/config.rs
|
||||
```
|
||||
|
||||
Switching branch changes the active conversation/transcript only. It should not change files unless the user explicitly chooses a file restore action.
|
||||
|
||||
### File restore safety
|
||||
|
||||
When the user chooses file restoration, show a concise plan before writing:
|
||||
|
||||
```text
|
||||
Restore tracked file edits to selected checkpoint?
|
||||
|
||||
Will update:
|
||||
src/config.rs current hash matches Cassady snapshot
|
||||
src/app.rs current hash differs; requires confirmation or skip
|
||||
|
||||
Will delete:
|
||||
src/generated.rs created after the checkpoint by Cassady write
|
||||
|
||||
Not tracked:
|
||||
shell command outputs and manual edits cannot be restored automatically
|
||||
|
||||
Proceed? [y/N]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- If the current file hash matches the expected tracked hash, restore automatically after confirmation.
|
||||
- If the file changed outside Cassady since the relevant snapshot, mark it as a conflict and default to skipping or cancelling the whole restore.
|
||||
- For files that did not exist at the target checkpoint, delete only if the current content hash matches the tracked created-file hash.
|
||||
- Never overwrite unknown current content without an explicit conflict confirmation.
|
||||
|
||||
## Design
|
||||
|
||||
### Conversation branch metadata
|
||||
|
||||
Extend the conversation metadata in a backward-compatible way. One acceptable shape is adding optional fields to `Record::Meta` with `#[serde(default)]` and `skip_serializing_if`:
|
||||
|
||||
```rust
|
||||
Record::Meta {
|
||||
chat_id: String,
|
||||
created_at: String,
|
||||
model: String,
|
||||
cwd: String,
|
||||
parent_chat_id: Option<String>,
|
||||
branch_from: Option<BranchPoint>,
|
||||
}
|
||||
|
||||
struct BranchPoint {
|
||||
chat_id: String,
|
||||
record_index: usize,
|
||||
tool_call_id: Option<String>,
|
||||
checkpoint_label: String,
|
||||
}
|
||||
```
|
||||
|
||||
Older conversations load with no parent. Descendants can be discovered by scanning `config.conversations_dir()` for `Meta.parent_chat_id` references.
|
||||
|
||||
Add a `conversation::create_branch(...)` helper that:
|
||||
|
||||
1. loads the source conversation,
|
||||
2. computes a valid record prefix for the selected checkpoint,
|
||||
3. writes a new JSONL file with a fresh chat id and branch metadata,
|
||||
4. preserves the original `System` prompt and source metadata needed for branch navigation,
|
||||
5. returns the new `Conversation` for the TUI to load immediately.
|
||||
|
||||
Do not truncate or rewrite the source conversation.
|
||||
|
||||
### Checkpoint extraction
|
||||
|
||||
Add a focused module such as `src/branch.rs` or `src/conversation_branch.rs` with types like:
|
||||
|
||||
```rust
|
||||
struct Checkpoint {
|
||||
id: String,
|
||||
chat_id: String,
|
||||
record_index: usize,
|
||||
tool_call_id: Option<String>,
|
||||
kind: CheckpointKind,
|
||||
label: String,
|
||||
detail: String,
|
||||
ts: Option<String>,
|
||||
}
|
||||
|
||||
enum CheckpointKind {
|
||||
User,
|
||||
Assistant,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
}
|
||||
```
|
||||
|
||||
Checkpoint rules:
|
||||
|
||||
- A user checkpoint means the branch includes that user record.
|
||||
- An assistant checkpoint means the branch includes that assistant record. If the assistant requested tools, the branch helper must repair or omit incomplete tool-call state before the next provider turn.
|
||||
- A tool-result checkpoint means the branch includes records through that tool result.
|
||||
- A tool-call checkpoint without a completed result should branch to the state immediately before executing that tool call, represented by an assistant record plus synthetic denied/cancelled tool records for any required missing calls.
|
||||
|
||||
Because OpenAI-compatible providers require every assistant tool call to receive a tool message before the next user message, branch creation must repair partial tool-call groups. Reuse or generalize the existing cancellation repair behavior in `src/app.rs` (`finalize_cancelled_turn` and pending tool-call handling) so branched conversations remain valid.
|
||||
|
||||
### File edit journal
|
||||
|
||||
Add a durable edit journal separate from model-visible conversation records, for example:
|
||||
|
||||
```text
|
||||
~/.cass/file-edits/<chat_id>.jsonl
|
||||
~/.cass/file-snapshots/<chat_id>/<tool_call_id>/<hash>.bin
|
||||
```
|
||||
|
||||
Journal entries should be written only for successful `write` and `edit` tool calls:
|
||||
|
||||
```rust
|
||||
struct FileEditJournalEntry {
|
||||
chat_id: String,
|
||||
record_index: usize,
|
||||
tool_call_id: String,
|
||||
tool_name: String, // write | edit
|
||||
path: PathBuf,
|
||||
existed_before: bool,
|
||||
existed_after: bool,
|
||||
before_hash: Option<String>,
|
||||
after_hash: Option<String>,
|
||||
before_snapshot: Option<PathBuf>,
|
||||
after_snapshot: Option<PathBuf>,
|
||||
ts: String,
|
||||
}
|
||||
```
|
||||
|
||||
Implementation approach:
|
||||
|
||||
- Add a `file_edits` module that can capture before/after bytes, hash them, store snapshots, append journal entries, and build restore plans.
|
||||
- Pass chat id / record index / tool call id into tool execution context, or wrap `write`/`edit` execution in `agent.rs` so the agent captures before/after around successful file tools.
|
||||
- Store full bytes, not just unified diffs, so restore works both backward and forward.
|
||||
- Limit snapshots to regular files. If a path is a directory, symlink, binary too large, or otherwise unsafe, skip journaling and note that restore will not cover it.
|
||||
|
||||
The first implementation can treat text and binary bytes uniformly for snapshot storage, while still using existing `write`/`edit` tools for text operations.
|
||||
|
||||
### Restore planning
|
||||
|
||||
File restore should compute a target workspace state from the selected checkpoint and branch lineage:
|
||||
|
||||
1. Determine the selected checkpoint's branch lineage back to the root conversation.
|
||||
2. Load file-edit journal entries along that lineage up to the checkpoint.
|
||||
3. For every path touched by tracked edits in the relevant branch family, compute the desired state at the checkpoint:
|
||||
- absent if no tracked edit existed before the checkpoint and the file was created later,
|
||||
- the last `after_snapshot` at or before the checkpoint,
|
||||
- the `before_snapshot` for paths whose first tracked edit happened after the checkpoint.
|
||||
4. Compare the current workspace file hash to the journal's expected current hash when possible.
|
||||
5. Produce a restore plan with actions: write snapshot, delete file, skip unsupported, conflict.
|
||||
6. Apply only after explicit confirmation.
|
||||
|
||||
For v0.2.8, if cross-branch target-state computation becomes too large, keep the algorithm conservative: support full restore for the current branch's lineage and show a clear unsupported/conflict message for unrelated sibling states. The branch metadata should still be designed so broader cross-branch restore can be added later without changing saved data.
|
||||
|
||||
### TUI integration
|
||||
|
||||
Add branch-menu state to `run_tui` rather than invoking `src/menu.rs` inside the alternate-screen UI. Suggested approach:
|
||||
|
||||
- Add an enum such as `OverlayState::BranchMenu(BranchMenuState)` in `src/app.rs` or a new `src/ui/branch_menu.rs`.
|
||||
- Extend `render::RenderState` to include an optional overlay.
|
||||
- Render a centered modal with title, help text, visible items, selected row, and preview/detail panel.
|
||||
- Route key events to the overlay first while it is open.
|
||||
- On confirmed branch/switch/restore, update:
|
||||
- `conversation`,
|
||||
- `chat_id`,
|
||||
- `transcript = transcript_from_loaded(...)`,
|
||||
- active assistant/tool state,
|
||||
- scroll/stick-to-bottom/status.
|
||||
|
||||
Keep the TUI loop readable by moving branch operations into functions such as:
|
||||
|
||||
```rust
|
||||
open_branch_menu(...)
|
||||
handle_branch_menu_key(...)
|
||||
apply_branch_action(...)
|
||||
```
|
||||
|
||||
### Slash command fallback
|
||||
|
||||
Optionally add a discoverable slash command such as `/branch` or `/restore` that opens the same menu. This is useful for users whose terminals send unusual `Esc` sequences. If added, document it as an alias for the menu rather than a separate workflow.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Add branch metadata and helpers.** Extend `Record::Meta` compatibly, add branch point types, implement branch-family scanning and `create_branch` from a record prefix.
|
||||
2. **Build checkpoint extraction.** Convert conversations into user/assistant/tool checkpoints with labels, previews, timestamps, and valid prefix calculations.
|
||||
3. **Repair tool-call prefixes.** Generalize pending-tool-call repair so branches created around tool calls are valid for future provider requests.
|
||||
4. **Add edit journaling.** Capture successful `write`/`edit` before/after snapshots, append a file-edit journal entry, and keep this separate from model-visible JSONL records.
|
||||
5. **Implement restore planning.** Load journal entries, compute target states, detect conflicts by hash, and apply writes/deletes safely with existing atomic-write behavior.
|
||||
6. **Add the in-TUI menu.** Implement double-`Esc` idle detection, overlay state, rendering, keyboard navigation, action confirmation, and branch/switch application.
|
||||
7. **Wire status and recovery messages.** Make every branch, switch, restore, skip, and conflict result visible in the transcript or status line.
|
||||
8. **Document the feature.** Update README and bundled docs with shortcut behavior, branch semantics, file restore coverage, and limitations around shell/manual edits.
|
||||
9. **Test and polish.** Add unit/integration tests, run formatting, and verify the TUI manually in a small repository.
|
||||
|
||||
## Tests
|
||||
|
||||
- `conversation` tests:
|
||||
- old JSONL conversations without branch metadata still load,
|
||||
- new branch metadata serializes/deserializes,
|
||||
- `create_branch` leaves the source file unchanged,
|
||||
- branch-family scanning finds ancestors and descendants.
|
||||
- Checkpoint tests:
|
||||
- user, assistant, tool-call, and tool-result checkpoints are extracted with stable labels,
|
||||
- branching at a tool result keeps valid assistant/tool ordering,
|
||||
- branching in the middle of multi-tool assistant output repairs missing tool results.
|
||||
- File journal tests:
|
||||
- `write` records absent-to-present and present-to-present snapshots,
|
||||
- `edit` records before/after bytes for successful edits only,
|
||||
- failed or denied tools do not create journal entries.
|
||||
- Restore-plan tests:
|
||||
- restore to an earlier checkpoint rewrites tracked files to prior content,
|
||||
- restore to a later checkpoint can reapply tracked content from snapshots,
|
||||
- created files are deleted only when hashes match,
|
||||
- external modifications are reported as conflicts.
|
||||
- TUI/key tests where practical:
|
||||
- first idle `Esc` sets double-press status,
|
||||
- second idle `Esc` opens the branch menu,
|
||||
- busy `Esc` still cancels a turn,
|
||||
- approval `Esc` still denies approval.
|
||||
|
||||
Manual checks:
|
||||
|
||||
- Start a chat, make a `write` edit, branch conversation-only from before the edit, confirm the original remains available.
|
||||
- Open the menu from the branch and switch back to the original chat.
|
||||
- Branch with file restore and verify the workspace file content matches the chosen checkpoint.
|
||||
- Trigger a conflict by manually editing a tracked file before restore and confirm Cassady refuses to overwrite it by default.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update:
|
||||
|
||||
- `README.md`: everyday workflow section for branching/restoring and a short safety note.
|
||||
- `docs/commands.md` or the relevant TUI guide: double-`Esc`, optional `/branch`, and menu controls.
|
||||
- `docs/troubleshooting.md`: conflicts, unsupported shell/manual edits, and how to switch back to the original branch.
|
||||
- Any keyboard shortcut table maintained in bundled docs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Pressing `Esc` twice while idle opens a branch/restore menu.
|
||||
- Selecting a user, assistant, or tool checkpoint creates a new branch conversation without modifying the source conversation.
|
||||
- The branch menu can be opened from the new branch to switch back to the original or create another branch.
|
||||
- Users can choose conversation-only branching or branch-plus-file restore.
|
||||
- File restore covers successful Cassady `write`/`edit` mutations with before/after snapshots and refuses unsafe overwrites by default.
|
||||
- Branches created around tool calls produce valid future model requests.
|
||||
- Existing conversations remain loadable.
|
||||
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||
@@ -0,0 +1,135 @@
|
||||
# v0.2.9 Provider Login Management Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users should be able to run `cass login` or type `/login` to configure one or more OpenAI-compatible providers, and use `cass logout` or `/logout` to remove saved providers and their model entries without hand-editing JSON files.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A user can add, switch, and remove provider/model configuration from Cassady's normal command surfaces, then continue chatting with a valid active provider.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add `cass login` as an alias-style command for the existing setup wizard.
|
||||
- Add `/login` inside the TUI, available only while idle.
|
||||
- Add `cass logout` with an interactive provider removal menu.
|
||||
- Add `/logout` inside the TUI, available only while idle.
|
||||
- Remove provider definitions and their associated `models.json` entries together.
|
||||
- Update `config.json` active defaults after removal so they do not point at missing providers or models.
|
||||
- Reload active config after login/logout inside the TUI.
|
||||
- Document the new commands in bundled command/config docs.
|
||||
- Add focused unit tests for provider removal and local command parsing/autocomplete.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Browser OAuth or provider-hosted account login flows.
|
||||
- Storing literal API keys from the wizard by default.
|
||||
- Non-OpenAI-compatible provider protocols.
|
||||
- Deleting shell environment variables or secrets outside `~/.cass`.
|
||||
- Publishing, tagging, or preparing release artifacts.
|
||||
|
||||
## Context
|
||||
|
||||
Cassady already has most provider setup primitives:
|
||||
|
||||
- `src/setup.rs` contains the interactive provider/model setup wizard, provider catalog, model discovery, and JSON upsert helpers.
|
||||
- `src/config.rs` owns `config.json`, `providers.json`, `models.json`, active provider/model resolution, and validation.
|
||||
- `src/app.rs` owns top-level CLI dispatch plus in-chat slash command parsing and execution.
|
||||
- `docs/commands.md`, `docs/configuration.md`, and `docs/workflows.md` document the existing `cass setup`, `cass check`, and `/model` behavior.
|
||||
|
||||
The existing setup wizard writes provider connection definitions to `providers.json`, model metadata to `models.json`, and active defaults to `config.json`. The new login command can reuse that flow. Logout needs a new inverse operation that edits all three files consistently.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Reuse setup behavior instead of creating a second provider configuration path.
|
||||
2. Keep removal explicit and reversible by avoiding broad file deletion and by preserving unrelated providers/models.
|
||||
3. Never remove API keys from the user's shell or keychain; Cassady only edits its own config files.
|
||||
4. Keep in-chat provider management idle-only, because active turns depend on a stable provider config.
|
||||
|
||||
## Design
|
||||
|
||||
### CLI commands
|
||||
|
||||
Add these subcommands:
|
||||
|
||||
```text
|
||||
cass login
|
||||
cass logout
|
||||
```
|
||||
|
||||
`cass login` runs the same interactive wizard as `cass setup`, with text that frames the action as adding or updating provider login configuration. It may start a chat afterward when invoked from an otherwise normal chat startup path only if the existing setup outcome says it should; direct `cass login` should save config and exit.
|
||||
|
||||
`cass logout` opens a multi-select menu of saved providers:
|
||||
|
||||
```text
|
||||
Remove saved providers
|
||||
|
||||
[ ] OpenAI openai · gpt-4.1
|
||||
[ ] Groq groq · llama-3.3-70b-versatile
|
||||
```
|
||||
|
||||
After confirmation, Cassady removes the selected providers from `providers.json` and removes `models.json` entries whose `provider` matches a removed provider id. If the active provider was removed, Cassady selects the first remaining provider and one of its models. If no providers remain, `default_provider`, `default_model`, and `default_reasoning_effort` are cleared so the next `cass` run offers setup.
|
||||
|
||||
### In-chat commands
|
||||
|
||||
Add slash commands:
|
||||
|
||||
```text
|
||||
/login
|
||||
/logout
|
||||
```
|
||||
|
||||
Both commands are idle-only. Because the TUI uses the alternate screen and raw input mode, command execution should temporarily leave the TUI, run the existing menu-driven flow in the normal terminal, reload config, then re-enter the TUI and append a status block.
|
||||
|
||||
After `/login`, reload `Config` from disk and keep the current conversation open. If the active provider/model changed, future turns use the new provider and model. The status block should show the active provider and model.
|
||||
|
||||
After `/logout`, reload `Config` when a provider remains. If no provider remains or config cannot resolve, keep the chat open but append a clear status/error telling the user to run `/login` before sending another turn.
|
||||
|
||||
### Provider/model removal helper
|
||||
|
||||
Add reusable setup/config helpers:
|
||||
|
||||
- `configured_providers(root) -> Vec<ProviderLogoutCandidate>`
|
||||
- `remove_providers(root, provider_ids) -> LogoutResult`
|
||||
|
||||
`LogoutResult` should include removed provider ids, removed model count, remaining provider count, and the new active provider/model when one exists. This keeps CLI output, TUI status, and tests deterministic.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the v0.2.9 roadmap entry and this plan.
|
||||
2. Add `Login` and `Logout` CLI variants and dispatch them from `app::run`.
|
||||
3. Refactor setup mode text as needed so `cass login` can share the setup wizard.
|
||||
4. Implement provider removal helpers in `src/setup.rs` using existing config structs.
|
||||
5. Add menu-driven `setup::logout(root)` for CLI and TUI use.
|
||||
6. Add `/login` and `/logout` to local command parsing, autocomplete, and idle command handling.
|
||||
7. Add a small terminal leave/re-enter helper around blocking login/logout menus inside the TUI.
|
||||
8. Update command/config/workflow docs.
|
||||
9. Add focused tests for removal behavior and command parsing/autocomplete.
|
||||
|
||||
## Tests
|
||||
|
||||
- Removing one provider preserves unrelated providers and models.
|
||||
- Removing the active provider chooses a valid remaining provider/model.
|
||||
- Removing all providers clears active defaults.
|
||||
- Removing an unknown provider id is rejected.
|
||||
- `/login` and `/logout` parse only with no arguments.
|
||||
- Command autocomplete lists `/login` and `/logout`.
|
||||
- `cargo fmt` passes.
|
||||
- `cargo test --locked --all-targets` passes when practical.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update `docs/commands.md` with `cass login`, `cass logout`, `/login`, and `/logout`.
|
||||
- Update `docs/configuration.md` to point users toward login/logout for managed provider edits.
|
||||
- Update `docs/workflows.md` with login/logout examples near model/provider workflows.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `cass login` opens the provider setup wizard and exits after saving direct login changes.
|
||||
- `cass logout` removes selected providers and their models with confirmation.
|
||||
- `/login` and `/logout` work from an idle chat and reload active provider config afterward.
|
||||
- Removing the active provider never leaves `config.json` pointing at a missing provider/model.
|
||||
- Existing `cass setup`, first-run setup, `cass check`, and `/model` behavior still work.
|
||||
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||
@@ -0,0 +1,234 @@
|
||||
# v0.3.0 ChatGPT Codex Provider Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
This release adds a first-class `ChatGPT Codex` provider preset so users who are already signed in to Codex with a ChatGPT subscription can run Cassady without creating a separate API-key environment variable. The preset should call `https://chatgpt.com/backend-api/codex/responses` and resolve its bearer token from the local Codex auth config by default.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A user who has already run `codex login` or signed in through the Codex app can select `ChatGPT Codex` in `cass login`, pass `cass check`, and send Cassady turns through their Codex subscription without copying tokens into Cassady config.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add `ChatGPT Codex` as a built-in provider preset in the setup/login catalog.
|
||||
- Add a provider kind/client for the ChatGPT Codex responses endpoint rather than forcing it through `/chat/completions` URL construction.
|
||||
- Read the default access token from the local Codex auth file, normally `$CODEX_HOME/auth.json` or `~/.codex/auth.json`.
|
||||
- Support the observed Codex auth shape with `tokens.access_token`, while keeping token values out of Cassady config, logs, check output, and error text.
|
||||
- Prefer the Codex-configured model from `$CODEX_HOME/config.toml` when available, with a safe manual model fallback.
|
||||
- Teach `cass check` to validate that local Codex auth is present and usable for the active `ChatGPT Codex` provider.
|
||||
- Add docs explaining prerequisites, setup flow, token-source behavior, expiration troubleshooting, and the distinction between ChatGPT subscription access and API-key providers.
|
||||
- Add focused tests with temporary Codex-home fixtures and mocked streaming responses.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Implementing Cassady's own browser OAuth/device-login flow for ChatGPT.
|
||||
- Storing or refreshing ChatGPT/Codex tokens in Cassady-owned config files.
|
||||
- Reverse engineering unrelated ChatGPT backend endpoints beyond the requested Codex responses endpoint.
|
||||
- Guaranteeing compatibility if the private ChatGPT backend endpoint or Codex auth file format changes.
|
||||
- Replacing OpenAI-compatible provider support or changing existing provider presets.
|
||||
- Release tagging, packaging, or GitHub release creation.
|
||||
|
||||
## Context or Current State
|
||||
|
||||
Cassady's provider stack is currently centered on OpenAI-compatible chat completions:
|
||||
|
||||
- `src/setup.rs` owns the built-in provider catalog, setup/login prompts, model discovery via `GET {base_url}/models`, and writes to `providers.json`/`models.json`/`config.json`.
|
||||
- `src/config.rs` defines `ProviderDefinition`, validates provider registries, resolves `api_key` values from literals or environment-variable references, and currently accepts only `kind = "openai-compatible"`.
|
||||
- `src/agent.rs` constructs `OpenAiCompatibleProvider` directly from resolved config.
|
||||
- `src/providers/openai_compatible.rs` appends `/chat/completions`, sends OpenAI-compatible chat payloads, and parses OpenAI-compatible streaming deltas.
|
||||
- `docs/providers.md`, `docs/configuration.md`, `docs/commands.md`, `docs/workflows.md`, and `README.md` describe provider setup as API-key/environment-variable based.
|
||||
|
||||
The new preset differs in two important ways:
|
||||
|
||||
1. Authentication should come from Codex's local login state, not from a Cassady environment-variable API key.
|
||||
2. The endpoint is a Codex-specific responses endpoint (`https://chatgpt.com/backend-api/codex/responses`), so Cassady needs an endpoint-specific provider client or a more general provider dispatch layer.
|
||||
|
||||
Local Codex auth is expected to live under Codex home, normally `~/.codex/auth.json`, with a shape like:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"account_id": "..."
|
||||
},
|
||||
"last_refresh": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Cassady should treat this file as sensitive input: read it only when resolving the active provider token, never copy the access token into Cassady-owned JSON, and never print token contents.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Use Codex login state, do not own ChatGPT auth.** Cassady should integrate with an existing Codex login and tell users to run `codex login` or sign in to Codex when auth is missing or expired.
|
||||
2. **Keep provider protocols explicit.** Do not pretend the ChatGPT Codex endpoint is OpenAI-compatible if it needs different URL construction, request shape, or stream parsing.
|
||||
3. **Avoid token leakage.** Token values must not be stored in `~/.cass`, included in transcripts, surfaced in `cass check`, or embedded in test snapshots.
|
||||
4. **Keep existing setup stable.** Existing providers should continue to use environment-variable API keys and `/models` discovery without extra Codex dependencies.
|
||||
5. **Fail with clear recovery steps.** Missing Codex auth should produce actionable messages, not generic provider errors.
|
||||
|
||||
## Design
|
||||
|
||||
### Provider catalog and setup UX
|
||||
|
||||
Add a built-in catalog entry:
|
||||
|
||||
| Provider | Provider id | Kind | Endpoint | Token source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ChatGPT Codex | `chatgpt-codex` | `chatgpt-codex` | `https://chatgpt.com/backend-api/codex/responses` | Local Codex auth |
|
||||
|
||||
In `cass login`/`cass setup`, selecting this provider should skip the normal `API key environment variable` prompt and instead show a prerequisite check:
|
||||
|
||||
```text
|
||||
ChatGPT Codex uses your local Codex login.
|
||||
|
||||
✓ Found Codex auth at ~/.codex/auth.json
|
||||
```
|
||||
|
||||
If the file or access token is missing:
|
||||
|
||||
```text
|
||||
ChatGPT Codex needs a local Codex login.
|
||||
Run `codex login` or sign in with the Codex app, then run `cass login` again.
|
||||
```
|
||||
|
||||
Model selection should prefer, in order:
|
||||
|
||||
1. The `model` value from `$CODEX_HOME/config.toml` when present.
|
||||
2. A known default Codex model constant only if the project already has a current default available.
|
||||
3. Manual model id entry.
|
||||
|
||||
Do not call `GET /models` for `chatgpt-codex` unless a verified endpoint is added later; model discovery remains an OpenAI-compatible setup behavior.
|
||||
|
||||
### Provider configuration shape
|
||||
|
||||
Extend provider config without breaking existing files. One possible JSON shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatgpt-codex",
|
||||
"name": "ChatGPT Codex",
|
||||
"kind": "chatgpt-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex/responses",
|
||||
"auth": { "type": "codex_local" },
|
||||
"default_model": "gpt-5.5",
|
||||
"models": ["gpt-5.5"]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation may choose an equivalent internal representation, but it should preserve these properties:
|
||||
|
||||
- Existing `api_key` string behavior remains valid for OpenAI-compatible providers.
|
||||
- `chatgpt-codex` providers can omit environment-variable API keys.
|
||||
- `cass check` can distinguish missing local Codex auth from missing API-key env vars.
|
||||
- Serialized config does not contain the Codex access token.
|
||||
|
||||
### Codex auth resolution
|
||||
|
||||
Add a small resolver module, for example `src/codex_auth.rs`, with helpers like:
|
||||
|
||||
- `codex_home() -> PathBuf`: `$CODEX_HOME` when set, otherwise `~/.codex`.
|
||||
- `codex_auth_path() -> PathBuf`: `$CODEX_AUTH_FILE` for tests/overrides when set, otherwise `{codex_home}/auth.json`.
|
||||
- `load_codex_access_token() -> Result<CodexAccessToken>`: parse `tokens.access_token` and return a redacted/display-safe token wrapper.
|
||||
- `check_codex_auth() -> CodexAuthStatus`: report path found, auth mode, access-token presence, optional JWT expiration, and recovery hints.
|
||||
|
||||
If the access token looks like a JWT, parse the `exp` claim without validating the signature so Cassady can warn or fail early when the token is expired. Token refresh itself should stay out of scope unless Codex exposes a stable documented local refresh interface.
|
||||
|
||||
Read the token at request time rather than caching it during setup. This allows a separate Codex process to refresh `auth.json` between Cassady turns.
|
||||
|
||||
### Provider dispatch
|
||||
|
||||
Refactor provider construction so `src/agent.rs` does not instantiate only `OpenAiCompatibleProvider`. A simple first step is an enum:
|
||||
|
||||
```rust
|
||||
enum ProviderClient {
|
||||
OpenAiCompatible(OpenAiCompatibleProvider),
|
||||
ChatGptCodex(ChatGptCodexProvider),
|
||||
}
|
||||
```
|
||||
|
||||
Both variants should expose a common `complete(messages, tools, tx)` async method returning the existing `CompletionResult`. This preserves the current agent loop, tool execution, conversation storage, and TUI behavior.
|
||||
|
||||
### ChatGPT Codex responses client
|
||||
|
||||
Add a new provider module, for example `src/providers/chatgpt_codex.rs`, that:
|
||||
|
||||
- Posts to the exact configured endpoint, defaulting to `https://chatgpt.com/backend-api/codex/responses`.
|
||||
- Sends `Authorization: Bearer <local Codex access token>`.
|
||||
- Includes the active model and converted message/tool context in the endpoint's expected responses format.
|
||||
- Streams assistant text into `AgentEvent::AssistantChunk`.
|
||||
- Streams reasoning summaries into `AgentEvent::ReasoningChunk` only when the endpoint provides a safe reasoning summary field.
|
||||
- Converts function/tool call deltas into Cassady `StoredToolCall` values.
|
||||
- Converts Cassady tool results back into the endpoint's function-call-output input shape on the next turn.
|
||||
- Redacts authentication details from non-success response errors.
|
||||
|
||||
The exact request/stream schema should be verified against the endpoint during implementation and captured in mocked fixtures. If the endpoint rejects a field used by OpenAI-compatible providers, keep the Codex payload minimal rather than adding compatibility shims that risk breaking the flow.
|
||||
|
||||
### Check and troubleshooting behavior
|
||||
|
||||
For active `chatgpt-codex` providers, `cass check` should report status like:
|
||||
|
||||
```text
|
||||
✓ active provider: chatgpt-codex
|
||||
✓ endpoint: https://chatgpt.com/backend-api/codex/responses
|
||||
✓ Codex auth: ~/.codex/auth.json contains an access token
|
||||
```
|
||||
|
||||
Failure should point to recovery:
|
||||
|
||||
```text
|
||||
✗ Codex auth: no access token found in ~/.codex/auth.json
|
||||
Run `codex login` or sign in with the Codex app, then rerun `cass check`.
|
||||
```
|
||||
|
||||
Do not print the token, account id, refresh token, or full auth JSON.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the v0.3.0 roadmap entry and this implementation plan.
|
||||
2. Extend provider config types/validation to support `kind = "chatgpt-codex"` and a non-env local Codex auth source while preserving existing OpenAI-compatible files.
|
||||
3. Add `src/codex_auth.rs` for Codex home discovery, auth-file parsing, redacted status reporting, and optional JWT expiration checks.
|
||||
4. Add `ChatGPT Codex` to `src/setup.rs` provider catalog and branch setup behavior so it skips API-key env prompts and `/models` discovery.
|
||||
5. Refactor provider construction in `src/agent.rs` behind a small provider dispatch enum or trait.
|
||||
6. Implement `src/providers/chatgpt_codex.rs` with endpoint-specific request conversion, streaming parsing, tool-call conversion, and redacted errors.
|
||||
7. Update `cass check` so active and inactive provider checks understand Codex-local auth separately from environment-variable API keys.
|
||||
8. Update README and bundled docs for the new preset, prerequisites, config example, troubleshooting, and known endpoint/auth caveats.
|
||||
9. Add unit tests for config parsing/validation, Codex auth fixtures, setup catalog behavior, and provider dispatch.
|
||||
10. Add mocked streaming tests for the ChatGPT Codex client, including text, tool calls, tool outputs, auth failures, and redaction.
|
||||
|
||||
## Tests
|
||||
|
||||
- `providers.json` with existing OpenAI-compatible providers still parses and validates.
|
||||
- A `chatgpt-codex` provider with local Codex auth validates without `api_key`/env-var availability.
|
||||
- Missing `~/.codex/auth.json` produces a clear `cass check` error for an active `chatgpt-codex` provider.
|
||||
- A fixture `auth.json` with `tokens.access_token` resolves a token but redacts it in display and errors.
|
||||
- Expired JWT-like access tokens are detected when possible and produce a recovery hint.
|
||||
- Setup/login catalog includes `ChatGPT Codex` and skips the API-key env-var prompt for that provider.
|
||||
- Model selection uses `$CODEX_HOME/config.toml` `model` when available, with manual fallback.
|
||||
- Provider dispatch selects `ChatGptCodexProvider` only for `kind = "chatgpt-codex"`.
|
||||
- Mocked Codex streaming responses produce assistant chunks and final `CompletionResult.content`.
|
||||
- Mocked Codex function-call streams produce Cassady `StoredToolCall` values and accept subsequent tool output messages.
|
||||
- Provider error messages never include access tokens, refresh tokens, or raw auth JSON.
|
||||
- `cargo fmt` passes.
|
||||
- `cargo test --locked --all-targets` passes when practical.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update `README.md` setup/provider sections with `ChatGPT Codex` as a subscription-backed option.
|
||||
- Update `docs/providers.md` with the new preset, endpoint, token-source behavior, and private-endpoint caveat.
|
||||
- Update `docs/configuration.md` with the extended provider schema and a safe example that uses local Codex auth.
|
||||
- Update `docs/commands.md` and `docs/workflows.md` for `cass login`, `cass check`, and troubleshooting steps.
|
||||
- Update `docs/troubleshooting.md` with missing/expired Codex auth, unsupported model, and backend endpoint failure guidance.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `cass login` offers `ChatGPT Codex` as a provider preset.
|
||||
- Selecting `ChatGPT Codex` does not ask for an API-key environment variable by default.
|
||||
- Cassady reads the access token from local Codex auth at request/check time and never stores that token under `~/.cass`.
|
||||
- Active `chatgpt-codex` sessions call `https://chatgpt.com/backend-api/codex/responses` instead of appending `/chat/completions`.
|
||||
- Normal OpenAI-compatible providers continue to work unchanged.
|
||||
- `cass check` gives clear success/failure output for local Codex auth without leaking secrets.
|
||||
- README and bundled docs explain the prerequisite of signing in to Codex first.
|
||||
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||
+22
-21
@@ -2,8 +2,8 @@ use crate::access::AccessMode;
|
||||
use crate::config::{Config, ReasoningEffort};
|
||||
use crate::conversation::{now_ts, Conversation, Record, StoredToolCall};
|
||||
use crate::prompt;
|
||||
use crate::providers::openai_compatible::{OpenAiCompatibleProvider, OpenAiCompatibleSettings};
|
||||
use crate::providers::types::ModelMessage;
|
||||
use crate::providers::ProviderClient;
|
||||
use crate::security::PolicyDecision;
|
||||
use crate::tools::{self, ToolContext, ToolRuntimeEvent};
|
||||
use anyhow::Result;
|
||||
@@ -85,34 +85,21 @@ pub async fn run_turn_with_commands(
|
||||
ts: now_ts(),
|
||||
})?;
|
||||
|
||||
let api_key = match settings.config.resolved_api_key() {
|
||||
Ok(api_key) => api_key,
|
||||
let reasoning_effort = settings
|
||||
.reasoning_effort
|
||||
.clamp_for_model(settings.config.model_metadata.as_ref());
|
||||
let provider = match ProviderClient::from_config(&settings.config, reasoning_effort) {
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
append_visible_assistant(
|
||||
&mut conversation,
|
||||
&tx,
|
||||
format!("I couldn't start the turn because the API key is not available: {err}"),
|
||||
format!("I couldn't start the turn because provider authentication is not available: {err}"),
|
||||
)?;
|
||||
let _ = tx.send(AgentEvent::TurnFinished);
|
||||
return Ok(conversation);
|
||||
}
|
||||
};
|
||||
let reasoning_request_format = settings
|
||||
.config
|
||||
.model_metadata
|
||||
.as_ref()
|
||||
.map(|model| model.reasoning.request_format)
|
||||
.unwrap_or_default();
|
||||
let reasoning_effort = settings
|
||||
.reasoning_effort
|
||||
.clamp_for_model(settings.config.model_metadata.as_ref());
|
||||
let provider = OpenAiCompatibleProvider::new(OpenAiCompatibleSettings {
|
||||
model: settings.config.model.clone(),
|
||||
base_url: settings.config.active_provider.base_url.clone(),
|
||||
api_key,
|
||||
reasoning_effort,
|
||||
reasoning_request_format,
|
||||
});
|
||||
|
||||
let docs_dir = settings.config.docs_dir();
|
||||
let tool_ctx = ToolContext {
|
||||
@@ -304,10 +291,19 @@ pub async fn run_turn_with_commands(
|
||||
let (runtime_tx, mut runtime_rx) = mpsc::unbounded_channel::<ToolRuntimeEvent>();
|
||||
let mut call_tool_ctx = tool_ctx.clone();
|
||||
call_tool_ctx.runtime_tx = Some(runtime_tx);
|
||||
let file_edit_snapshot = crate::file_edits::begin_tool_edit(
|
||||
&settings.config.root,
|
||||
&conversation.id,
|
||||
conversation.records.len(),
|
||||
&call_id,
|
||||
&call_name,
|
||||
&call_arguments,
|
||||
&call_tool_ctx,
|
||||
);
|
||||
let output = {
|
||||
let execute = tools::execute_with_approval(
|
||||
&call_name,
|
||||
call_arguments,
|
||||
call_arguments.clone(),
|
||||
&call_tool_ctx,
|
||||
approved,
|
||||
);
|
||||
@@ -325,6 +321,11 @@ pub async fn run_turn_with_commands(
|
||||
}
|
||||
output
|
||||
};
|
||||
if output.ok {
|
||||
if let Some(snapshot) = file_edit_snapshot {
|
||||
let _ = crate::file_edits::finish_tool_edit(&settings.config.root, snapshot);
|
||||
}
|
||||
}
|
||||
let _ = tx.send(AgentEvent::ToolResult {
|
||||
id: call_id.clone(),
|
||||
name: call_name.clone(),
|
||||
|
||||
+753
-6
@@ -1,6 +1,6 @@
|
||||
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
|
||||
use crate::cli::{self, Command};
|
||||
use crate::config::{Config, ModelDefinition, ReasoningEffort};
|
||||
use crate::cli::{self, Cli, Command};
|
||||
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,11 @@ 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 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,7 +34,45 @@ pub async fn run() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = Config::load(&cli)?;
|
||||
if matches!(cli.command, Some(Command::Login)) {
|
||||
let _ = crate::setup::run(&cli, crate::setup::SetupMode::Login).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(cli.command, Some(Command::Logout)) {
|
||||
let _ = crate::setup::logout(&config::cass_root())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(cli.command, Some(Command::Setup)) {
|
||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Explicit).await?;
|
||||
if !outcome.start_session {
|
||||
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,13 +80,21 @@ pub async fn run() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if config.ensure_provider_auth().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 {
|
||||
(create_new_conversation(&config, &cwd)?, None)
|
||||
};
|
||||
|
||||
run_tui(config, cwd, conversation, warning).await
|
||||
run_tui(config, cwd, conversation, warning, cli).await
|
||||
}
|
||||
|
||||
fn resolve_cwd(cwd: Option<PathBuf>) -> Result<PathBuf> {
|
||||
@@ -77,6 +127,40 @@ fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_login_menu_from_tui(
|
||||
terminal: &mut terminal::CassTerminal,
|
||||
cli: &Cli,
|
||||
) -> Result<crate::setup::SetupOutcome> {
|
||||
terminal::suspend(terminal)?;
|
||||
let result = crate::setup::run(cli, crate::setup::SetupMode::Login).await;
|
||||
let resume_result = terminal::resume(terminal);
|
||||
match (result, resume_result) {
|
||||
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||
(Err(err), Ok(())) => Err(err),
|
||||
(Ok(_), Err(err)) => Err(err),
|
||||
(Err(err), Err(resume_err)) => Err(err.context(format!(
|
||||
"also failed to restore the chat screen: {resume_err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_logout_menu_from_tui(
|
||||
terminal: &mut terminal::CassTerminal,
|
||||
root: &Path,
|
||||
) -> Result<crate::setup::LogoutResult> {
|
||||
terminal::suspend(terminal)?;
|
||||
let result = crate::setup::logout(root);
|
||||
let resume_result = terminal::resume(terminal);
|
||||
match (result, resume_result) {
|
||||
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||
(Err(err), Ok(())) => Err(err),
|
||||
(Ok(_), Err(err)) => Err(err),
|
||||
(Err(err), Err(resume_err)) => Err(err.context(format!(
|
||||
"also failed to restore the chat screen: {resume_err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_cancelled_turn(
|
||||
config: &Config,
|
||||
chat_id: &str,
|
||||
@@ -146,6 +230,7 @@ async fn run_tui(
|
||||
cwd: PathBuf,
|
||||
mut conversation: Conversation,
|
||||
warning: Option<String>,
|
||||
cli: Cli,
|
||||
) -> Result<()> {
|
||||
let mut terminal = terminal::enter()?;
|
||||
let mut transcript = Vec::new();
|
||||
@@ -168,6 +253,8 @@ async fn run_tui(
|
||||
let mut reasoning_effort = config.reasoning_effort;
|
||||
let mut scroll: u16 = 0;
|
||||
let mut last_ctrl_c: Option<Instant> = None;
|
||||
let mut last_esc: Option<Instant> = None;
|
||||
let mut branch_menu: Option<BranchMenuState> = None;
|
||||
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
|
||||
let mut cancel_requested = false;
|
||||
let mut current_turn_start_len: Option<usize> = None;
|
||||
@@ -179,6 +266,7 @@ async fn run_tui(
|
||||
let mut chat_id = conversation.id.clone();
|
||||
let mut autofill_selected = 0usize;
|
||||
let mut pending_approval: Option<PendingApproval> = None;
|
||||
let mut provider_ready = true;
|
||||
|
||||
loop {
|
||||
drain_agent_events(
|
||||
@@ -292,6 +380,7 @@ async fn run_tui(
|
||||
)?
|
||||
};
|
||||
|
||||
let overlay_view = branch_menu.as_ref().map(BranchMenuState::overlay_view);
|
||||
terminal.draw(|f| {
|
||||
render::render(
|
||||
f,
|
||||
@@ -309,7 +398,12 @@ async fn run_tui(
|
||||
show_reasoning,
|
||||
reasoning_effort,
|
||||
scroll,
|
||||
autofill: autofill.as_ref(),
|
||||
autofill: if branch_menu.is_some() {
|
||||
None
|
||||
} else {
|
||||
autofill.as_ref()
|
||||
},
|
||||
overlay: overlay_view.as_ref(),
|
||||
},
|
||||
)
|
||||
})?;
|
||||
@@ -318,6 +412,43 @@ async fn run_tui(
|
||||
match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
let busy = handle.is_some();
|
||||
if branch_menu.is_some() {
|
||||
match handle_branch_menu_key(
|
||||
key.code,
|
||||
&mut branch_menu,
|
||||
&config,
|
||||
&cwd,
|
||||
&mut conversation,
|
||||
&mut chat_id,
|
||||
&mut transcript,
|
||||
&mut active_assistant,
|
||||
&mut active_reasoning,
|
||||
&mut active_tools,
|
||||
&mut status,
|
||||
) {
|
||||
Ok(BranchMenuOutcome::None) => {}
|
||||
Ok(BranchMenuOutcome::Changed) => {
|
||||
stick_to_bottom = true;
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
Err(err) => {
|
||||
branch_menu = None;
|
||||
status = format!("branch menu failed: {err}");
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "branch".into(),
|
||||
content: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if busy {
|
||||
if let Some(pending) = pending_approval.clone() {
|
||||
match key.code {
|
||||
@@ -389,11 +520,13 @@ async fn run_tui(
|
||||
}
|
||||
cancel_requested = true;
|
||||
last_ctrl_c = Some(now);
|
||||
last_esc = None;
|
||||
status = "turn cancellation requested; press Ctrl-C again within 1.5s to exit".into();
|
||||
} else {
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
last_ctrl_c = Some(now);
|
||||
last_esc = None;
|
||||
status = "press Ctrl-C again within 1.5s to exit".into();
|
||||
}
|
||||
}
|
||||
@@ -403,8 +536,31 @@ async fn run_tui(
|
||||
}
|
||||
cancel_requested = true;
|
||||
last_ctrl_c = None;
|
||||
last_esc = None;
|
||||
status = "turn cancellation requested".into();
|
||||
}
|
||||
(KeyCode::Esc, _) => {
|
||||
let now = Instant::now();
|
||||
if last_esc
|
||||
.map(|t| now.duration_since(t) <= Duration::from_millis(1500))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
match BranchMenuState::open(&config, &conversation) {
|
||||
Ok(menu) => {
|
||||
branch_menu = Some(menu);
|
||||
last_esc = None;
|
||||
status = "branch/restore menu".into();
|
||||
}
|
||||
Err(err) => {
|
||||
last_esc = None;
|
||||
status = format!("branch menu failed: {err}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
last_esc = Some(now);
|
||||
status = "press Esc again within 1.5s to branch or restore".into();
|
||||
}
|
||||
}
|
||||
(KeyCode::BackTab, _) => {
|
||||
if busy {
|
||||
status = "mode can be changed when idle".into();
|
||||
@@ -540,6 +696,172 @@ async fn run_tui(
|
||||
input.clear();
|
||||
} else if input.trim_start().starts_with('/') {
|
||||
match parse_local_command(&input) {
|
||||
Ok(LocalCommand::Branch) => {
|
||||
if busy {
|
||||
status = "branch menu can be opened when idle".into();
|
||||
} else {
|
||||
match BranchMenuState::open(&config, &conversation) {
|
||||
Ok(menu) => {
|
||||
branch_menu = Some(menu);
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
status = "branch/restore menu".into();
|
||||
}
|
||||
Err(err) => {
|
||||
status = format!("branch menu failed: {err}");
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "branch".into(),
|
||||
content: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LocalCommand::Login) => {
|
||||
if busy {
|
||||
status = "login can be opened when idle".into();
|
||||
} else {
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
match run_login_menu_from_tui(&mut terminal, &cli).await
|
||||
{
|
||||
Ok(_) => match Config::load(&cli) {
|
||||
Ok(updated) => {
|
||||
config = updated;
|
||||
reasoning_effort = config.reasoning_effort;
|
||||
provider_ready = true;
|
||||
let content = format!(
|
||||
"active provider: {}\nactive model: {}",
|
||||
config.provider_id, config.model
|
||||
);
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "login".into(),
|
||||
content,
|
||||
});
|
||||
status = "login updated".into();
|
||||
}
|
||||
Err(err) => {
|
||||
provider_ready = false;
|
||||
status = format!(
|
||||
"login saved with issues: {err}"
|
||||
);
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "login".into(),
|
||||
content: format!(
|
||||
"Provider config could not be loaded: {err}"
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
status = format!("login cancelled: {err}");
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "login".into(),
|
||||
content: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if stick_to_bottom {
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LocalCommand::Logout) => {
|
||||
if busy {
|
||||
status = "logout can be opened when idle".into();
|
||||
} else {
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
match run_logout_menu_from_tui(
|
||||
&mut terminal,
|
||||
&config.root,
|
||||
) {
|
||||
Ok(result) => {
|
||||
if result.removed_provider_ids.is_empty() {
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "logout".into(),
|
||||
content: "logout cancelled".into(),
|
||||
});
|
||||
status = "logout cancelled".into();
|
||||
} else if result.remaining_provider_count == 0 {
|
||||
provider_ready = false;
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "logout".into(),
|
||||
content: format!(
|
||||
"removed providers: {}\nremoved model entries: {}\nno providers remain; run /login before sending another message",
|
||||
result.removed_provider_ids.join(", "),
|
||||
result.removed_model_count
|
||||
),
|
||||
});
|
||||
status = "no provider configured".into();
|
||||
} else {
|
||||
match Config::load(&cli) {
|
||||
Ok(updated) => {
|
||||
config = updated;
|
||||
reasoning_effort =
|
||||
config.reasoning_effort;
|
||||
provider_ready = true;
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "logout".into(),
|
||||
content: format!(
|
||||
"removed providers: {}\nremoved model entries: {}\nactive provider: {}\nactive model: {}",
|
||||
result.removed_provider_ids.join(", "),
|
||||
result.removed_model_count,
|
||||
config.provider_id,
|
||||
config.model
|
||||
),
|
||||
});
|
||||
status = "logout updated".into();
|
||||
}
|
||||
Err(err) => {
|
||||
provider_ready = false;
|
||||
status = format!(
|
||||
"logout saved with issues: {err}"
|
||||
);
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "logout".into(),
|
||||
content: format!(
|
||||
"Provider config could not be loaded: {err}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
status = format!("logout cancelled: {err}");
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "logout".into(),
|
||||
content: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if stick_to_bottom {
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LocalCommand::Status) => {
|
||||
let content = chat_status(
|
||||
&chat_id,
|
||||
@@ -702,6 +1024,24 @@ async fn run_tui(
|
||||
}
|
||||
} else if busy {
|
||||
status = "agent is still running".into();
|
||||
} else if !provider_ready {
|
||||
status = "run /login before sending a message".into();
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "provider".into(),
|
||||
content:
|
||||
"No active provider is configured. Run /login before sending another message."
|
||||
.into(),
|
||||
});
|
||||
if stick_to_bottom {
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
let msg = input.trim_end().to_string();
|
||||
current_turn_start_len = Some(conversation.records.len());
|
||||
@@ -823,6 +1163,12 @@ async fn run_tui(
|
||||
{
|
||||
last_ctrl_c = None;
|
||||
}
|
||||
if last_esc
|
||||
.map(|t| t.elapsed() > Duration::from_millis(1500))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
last_esc = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,6 +1178,337 @@ struct PendingApproval {
|
||||
block_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum BranchMenuMode {
|
||||
Main,
|
||||
Actions(crate::branch::Checkpoint),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BranchMenuState {
|
||||
mode: BranchMenuMode,
|
||||
selected: usize,
|
||||
family: crate::branch::BranchFamily,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum BranchMenuItem {
|
||||
Switch(String),
|
||||
Checkpoint(crate::branch::Checkpoint),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BranchMenuOutcome {
|
||||
None,
|
||||
Changed,
|
||||
}
|
||||
|
||||
impl BranchMenuState {
|
||||
fn open(config: &Config, conversation: &Conversation) -> Result<Self> {
|
||||
let family = crate::branch::load_family(&config.conversations_dir(), conversation)?;
|
||||
Ok(Self {
|
||||
mode: BranchMenuMode::Main,
|
||||
selected: 0,
|
||||
family,
|
||||
})
|
||||
}
|
||||
|
||||
fn overlay_view(&self) -> render::OverlayView {
|
||||
match &self.mode {
|
||||
BranchMenuMode::Main => render::OverlayView {
|
||||
title: "Branch / Restore".into(),
|
||||
help: "Enter select · Esc cancel · ↑/↓ move".into(),
|
||||
selected: self.selected,
|
||||
items: self
|
||||
.main_items()
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
BranchMenuItem::Switch(id) => {
|
||||
let branch = self.family.branches.iter().find(|b| b.id == id);
|
||||
let mut label = if branch.is_some_and(|b| b.current) {
|
||||
format!("current branch {id}")
|
||||
} else {
|
||||
format!("switch to {id}")
|
||||
};
|
||||
if branch.and_then(|b| b.parent_chat_id.as_ref()).is_none() {
|
||||
label.push_str(" (root)");
|
||||
}
|
||||
render::OverlayItem {
|
||||
label,
|
||||
detail: branch.and_then(|b| b.branch_label.clone()).unwrap_or_else(
|
||||
|| {
|
||||
branch
|
||||
.map(|b| format!("{} records", b.record_count))
|
||||
.unwrap_or_default()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
BranchMenuItem::Checkpoint(checkpoint) => render::OverlayItem {
|
||||
label: format!("{} · {}", checkpoint.chat_id, checkpoint.label),
|
||||
detail: checkpoint.detail,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
BranchMenuMode::Actions(checkpoint) => render::OverlayView {
|
||||
title: "Branch from checkpoint".into(),
|
||||
help: format!(
|
||||
"{} · Enter select · Esc back",
|
||||
crate::branch::checkpoint_title(checkpoint)
|
||||
),
|
||||
selected: self.selected,
|
||||
items: vec![
|
||||
render::OverlayItem {
|
||||
label: "Branch conversation only".into(),
|
||||
detail: "safe default; leaves files unchanged".into(),
|
||||
},
|
||||
render::OverlayItem {
|
||||
label: "Branch conversation and restore tracked files".into(),
|
||||
detail: "applies safe Cassady write/edit snapshots; conflicts are skipped"
|
||||
.into(),
|
||||
},
|
||||
render::OverlayItem {
|
||||
label: "Preview tracked file restore plan".into(),
|
||||
detail: "show file actions in transcript".into(),
|
||||
},
|
||||
render::OverlayItem {
|
||||
label: "Cancel".into(),
|
||||
detail: String::new(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn main_items(&self) -> Vec<BranchMenuItem> {
|
||||
let mut items = Vec::new();
|
||||
for branch in &self.family.branches {
|
||||
items.push(BranchMenuItem::Switch(branch.id.clone()));
|
||||
}
|
||||
for checkpoint in &self.family.checkpoints {
|
||||
items.push(BranchMenuItem::Checkpoint(checkpoint.clone()));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
match self.mode {
|
||||
BranchMenuMode::Main => self.main_items().len(),
|
||||
BranchMenuMode::Actions(_) => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_branch_menu_key(
|
||||
code: KeyCode,
|
||||
menu: &mut Option<BranchMenuState>,
|
||||
config: &Config,
|
||||
_cwd: &Path,
|
||||
conversation: &mut Conversation,
|
||||
chat_id: &mut String,
|
||||
transcript: &mut Vec<TranscriptBlock>,
|
||||
active_assistant: &mut Option<usize>,
|
||||
active_reasoning: &mut Option<usize>,
|
||||
active_tools: &mut HashMap<String, usize>,
|
||||
status: &mut String,
|
||||
) -> Result<BranchMenuOutcome> {
|
||||
let Some(state) = menu.as_mut() else {
|
||||
return Ok(BranchMenuOutcome::None);
|
||||
};
|
||||
match code {
|
||||
KeyCode::Esc => match state.mode {
|
||||
BranchMenuMode::Main => {
|
||||
*menu = None;
|
||||
*status = "branch menu cancelled".into();
|
||||
}
|
||||
BranchMenuMode::Actions(_) => {
|
||||
state.mode = BranchMenuMode::Main;
|
||||
state.selected = 0;
|
||||
}
|
||||
},
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
state.selected = state.selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let max = state.len().saturating_sub(1);
|
||||
state.selected = state.selected.saturating_add(1).min(max);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
return apply_branch_menu_selection(
|
||||
menu,
|
||||
config,
|
||||
conversation,
|
||||
chat_id,
|
||||
transcript,
|
||||
active_assistant,
|
||||
active_reasoning,
|
||||
active_tools,
|
||||
status,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(BranchMenuOutcome::None)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_branch_menu_selection(
|
||||
menu: &mut Option<BranchMenuState>,
|
||||
config: &Config,
|
||||
conversation: &mut Conversation,
|
||||
chat_id: &mut String,
|
||||
transcript: &mut Vec<TranscriptBlock>,
|
||||
active_assistant: &mut Option<usize>,
|
||||
active_reasoning: &mut Option<usize>,
|
||||
active_tools: &mut HashMap<String, usize>,
|
||||
status: &mut String,
|
||||
) -> Result<BranchMenuOutcome> {
|
||||
let Some(state) = menu.as_mut() else {
|
||||
return Ok(BranchMenuOutcome::None);
|
||||
};
|
||||
match &state.mode {
|
||||
BranchMenuMode::Main => {
|
||||
let items = state.main_items();
|
||||
let Some(item) = items.get(state.selected).cloned() else {
|
||||
return Ok(BranchMenuOutcome::None);
|
||||
};
|
||||
match item {
|
||||
BranchMenuItem::Switch(id) => {
|
||||
let (loaded, warning) = Conversation::load(&config.conversations_dir(), &id)?;
|
||||
*conversation = loaded;
|
||||
*chat_id = conversation.id.clone();
|
||||
*transcript = transcript_from_loaded(conversation, warning);
|
||||
*active_assistant = None;
|
||||
*active_reasoning = None;
|
||||
active_tools.clear();
|
||||
*status = format!("switched to branch {chat_id}");
|
||||
*menu = None;
|
||||
Ok(BranchMenuOutcome::Changed)
|
||||
}
|
||||
BranchMenuItem::Checkpoint(checkpoint) => {
|
||||
state.mode = BranchMenuMode::Actions(checkpoint);
|
||||
state.selected = 0;
|
||||
Ok(BranchMenuOutcome::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
BranchMenuMode::Actions(checkpoint) => {
|
||||
let selected = state.selected;
|
||||
let checkpoint = checkpoint.clone();
|
||||
match selected {
|
||||
0 => branch_from_checkpoint(
|
||||
menu,
|
||||
config,
|
||||
&checkpoint,
|
||||
false,
|
||||
conversation,
|
||||
chat_id,
|
||||
transcript,
|
||||
active_assistant,
|
||||
active_reasoning,
|
||||
active_tools,
|
||||
status,
|
||||
),
|
||||
1 => branch_from_checkpoint(
|
||||
menu,
|
||||
config,
|
||||
&checkpoint,
|
||||
true,
|
||||
conversation,
|
||||
chat_id,
|
||||
transcript,
|
||||
active_assistant,
|
||||
active_reasoning,
|
||||
active_tools,
|
||||
status,
|
||||
),
|
||||
2 => {
|
||||
let plan = crate::file_edits::plan_restore(
|
||||
&config.root,
|
||||
&checkpoint.chat_id,
|
||||
checkpoint.record_index,
|
||||
)?;
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "restore preview".into(),
|
||||
content: crate::file_edits::summarize_plan(&plan),
|
||||
});
|
||||
*status = "restore plan previewed".into();
|
||||
*menu = None;
|
||||
Ok(BranchMenuOutcome::Changed)
|
||||
}
|
||||
_ => {
|
||||
state.mode = BranchMenuMode::Main;
|
||||
state.selected = 0;
|
||||
Ok(BranchMenuOutcome::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn branch_from_checkpoint(
|
||||
menu: &mut Option<BranchMenuState>,
|
||||
config: &Config,
|
||||
checkpoint: &crate::branch::Checkpoint,
|
||||
restore_files: bool,
|
||||
conversation: &mut Conversation,
|
||||
chat_id: &mut String,
|
||||
transcript: &mut Vec<TranscriptBlock>,
|
||||
active_assistant: &mut Option<usize>,
|
||||
active_reasoning: &mut Option<usize>,
|
||||
active_tools: &mut HashMap<String, usize>,
|
||||
status: &mut String,
|
||||
) -> Result<BranchMenuOutcome> {
|
||||
let (source, _) = Conversation::load(&config.conversations_dir(), &checkpoint.chat_id)?;
|
||||
let branch = crate::branch::create_branch(&config.conversations_dir(), &source, checkpoint)?;
|
||||
let old_id = checkpoint.chat_id.clone();
|
||||
*conversation = branch;
|
||||
*chat_id = conversation.id.clone();
|
||||
*transcript = transcript_from_loaded(conversation, None);
|
||||
*active_assistant = None;
|
||||
*active_reasoning = None;
|
||||
active_tools.clear();
|
||||
|
||||
let mut restore_status = String::new();
|
||||
if restore_files {
|
||||
let plan = crate::file_edits::plan_restore(
|
||||
&config.root,
|
||||
&checkpoint.chat_id,
|
||||
checkpoint.record_index,
|
||||
)?;
|
||||
let summary = crate::file_edits::summarize_plan(&plan);
|
||||
let outcome = crate::file_edits::apply_restore_plan(&plan)?;
|
||||
restore_status = format!(
|
||||
"; restored files: {} applied, {} skipped, {} conflicts",
|
||||
outcome.applied, outcome.skipped, outcome.conflicts
|
||||
);
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: if outcome.conflicts == 0 {
|
||||
TranscriptKind::Status
|
||||
} else {
|
||||
TranscriptKind::Error
|
||||
},
|
||||
title: "file restore".into(),
|
||||
content: format!(
|
||||
"{summary}\n\nApplied: {}\nSkipped: {}\nConflicts: {}",
|
||||
outcome.applied, outcome.skipped, outcome.conflicts
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
*status = format!(
|
||||
"branched {chat_id} from {old_id} at {}{}",
|
||||
crate::branch::checkpoint_title(checkpoint),
|
||||
restore_status
|
||||
);
|
||||
*menu = None;
|
||||
Ok(BranchMenuOutcome::Changed)
|
||||
}
|
||||
|
||||
struct AgentEventContext<'a> {
|
||||
terminal: &'a terminal::CassTerminal,
|
||||
input: &'a str,
|
||||
@@ -1207,6 +1884,9 @@ fn assistant_content_matches(a: &str, b: &str) -> bool {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum LocalCommand {
|
||||
Branch,
|
||||
Login,
|
||||
Logout,
|
||||
Model(String),
|
||||
New,
|
||||
Resume(String),
|
||||
@@ -1221,6 +1901,24 @@ struct CommandSpec {
|
||||
}
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec {
|
||||
name: "branch",
|
||||
usage: "/branch",
|
||||
description: "open branch/restore menu",
|
||||
takes_value: false,
|
||||
},
|
||||
CommandSpec {
|
||||
name: "login",
|
||||
usage: "/login",
|
||||
description: "configure provider login settings",
|
||||
takes_value: false,
|
||||
},
|
||||
CommandSpec {
|
||||
name: "logout",
|
||||
usage: "/logout",
|
||||
description: "remove saved providers and models",
|
||||
takes_value: false,
|
||||
},
|
||||
CommandSpec {
|
||||
name: "model",
|
||||
usage: "/model <model>",
|
||||
@@ -1471,6 +2169,24 @@ fn parse_local_command(input: &str) -> std::result::Result<LocalCommand, String>
|
||||
};
|
||||
|
||||
match command {
|
||||
"/branch" | "/restore" => {
|
||||
if parts.next().is_some() {
|
||||
return Err("usage: /branch".into());
|
||||
}
|
||||
Ok(LocalCommand::Branch)
|
||||
}
|
||||
"/login" => {
|
||||
if parts.next().is_some() {
|
||||
return Err("usage: /login".into());
|
||||
}
|
||||
Ok(LocalCommand::Login)
|
||||
}
|
||||
"/logout" => {
|
||||
if parts.next().is_some() {
|
||||
return Err("usage: /logout".into());
|
||||
}
|
||||
Ok(LocalCommand::Logout)
|
||||
}
|
||||
"/model" => {
|
||||
let Some(model) = parts.next() else {
|
||||
return Err("usage: /model <model>".into());
|
||||
@@ -1914,12 +2630,43 @@ mod tests {
|
||||
assert!(command_autofill("/new", 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_autofill_lists_login_and_logout_commands() {
|
||||
let menu = command_autofill("/log", 0).unwrap();
|
||||
|
||||
let labels = menu
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.label.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(labels, vec!["/login", "/logout"]);
|
||||
assert_eq!(menu.items[0].insert, "/login");
|
||||
assert_eq!(menu.items[1].insert, "/logout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_local_command_accepts_new_without_args() {
|
||||
assert_eq!(parse_local_command("/new").unwrap(), LocalCommand::New);
|
||||
assert_eq!(parse_local_command("/new extra"), Err("usage: /new".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_local_command_accepts_login_and_logout_without_args() {
|
||||
assert_eq!(parse_local_command("/login").unwrap(), LocalCommand::Login);
|
||||
assert_eq!(
|
||||
parse_local_command("/logout").unwrap(),
|
||||
LocalCommand::Logout
|
||||
);
|
||||
assert_eq!(
|
||||
parse_local_command("/login extra"),
|
||||
Err("usage: /login".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_local_command("/logout extra"),
|
||||
Err("usage: /logout".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_turn_repairs_missing_tool_results() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
use crate::conversation::{self, BranchPoint, Conversation, Record, StoredToolCall};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
|
||||
const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CheckpointKind {
|
||||
User,
|
||||
Assistant,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Checkpoint {
|
||||
pub id: String,
|
||||
pub chat_id: String,
|
||||
pub record_index: usize,
|
||||
pub tool_call_id: Option<String>,
|
||||
pub kind: CheckpointKind,
|
||||
pub label: String,
|
||||
pub detail: String,
|
||||
pub ts: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BranchSummary {
|
||||
pub id: String,
|
||||
pub created_at: String,
|
||||
pub parent_chat_id: Option<String>,
|
||||
pub branch_label: Option<String>,
|
||||
pub record_count: usize,
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BranchFamily {
|
||||
pub branches: Vec<BranchSummary>,
|
||||
pub checkpoints: Vec<Checkpoint>,
|
||||
}
|
||||
|
||||
pub fn checkpoint_records(records: &[Record], chat_id: &str) -> Vec<Checkpoint> {
|
||||
let mut checkpoints = Vec::new();
|
||||
for (idx, record) in records.iter().enumerate() {
|
||||
match record {
|
||||
Record::User { content, ts } => checkpoints.push(Checkpoint {
|
||||
id: format!("{chat_id}:{idx}:user"),
|
||||
chat_id: chat_id.to_string(),
|
||||
record_index: idx,
|
||||
tool_call_id: None,
|
||||
kind: CheckpointKind::User,
|
||||
label: "user".into(),
|
||||
detail: preview(content),
|
||||
ts: Some(ts.clone()),
|
||||
}),
|
||||
Record::Assistant {
|
||||
content,
|
||||
reasoning,
|
||||
tool_calls,
|
||||
ts,
|
||||
..
|
||||
} => {
|
||||
let detail = if content.trim().is_empty() {
|
||||
preview(reasoning)
|
||||
} else {
|
||||
preview(content)
|
||||
};
|
||||
checkpoints.push(Checkpoint {
|
||||
id: format!("{chat_id}:{idx}:assistant"),
|
||||
chat_id: chat_id.to_string(),
|
||||
record_index: idx,
|
||||
tool_call_id: None,
|
||||
kind: CheckpointKind::Assistant,
|
||||
label: "assistant".into(),
|
||||
detail,
|
||||
ts: Some(ts.clone()),
|
||||
});
|
||||
for call in tool_calls {
|
||||
checkpoints.push(Checkpoint {
|
||||
id: format!("{chat_id}:{idx}:tool_call:{}", call.id),
|
||||
chat_id: chat_id.to_string(),
|
||||
record_index: idx,
|
||||
tool_call_id: Some(call.id.clone()),
|
||||
kind: CheckpointKind::ToolCall,
|
||||
label: format!("tool call {}", call.name),
|
||||
detail: tool_call_detail(call),
|
||||
ts: Some(ts.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
Record::Tool {
|
||||
tool_call_id,
|
||||
name,
|
||||
ok,
|
||||
content,
|
||||
ts,
|
||||
} => checkpoints.push(Checkpoint {
|
||||
id: format!("{chat_id}:{idx}:tool_result:{tool_call_id}"),
|
||||
chat_id: chat_id.to_string(),
|
||||
record_index: idx,
|
||||
tool_call_id: Some(tool_call_id.clone()),
|
||||
kind: CheckpointKind::ToolResult,
|
||||
label: format!("tool {name} {}", if *ok { "✓" } else { "✗" }),
|
||||
detail: preview(content),
|
||||
ts: Some(ts.clone()),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
checkpoints
|
||||
}
|
||||
|
||||
pub fn load_family(conversations_dir: &Path, current: &Conversation) -> Result<BranchFamily> {
|
||||
let metas = load_all_metas(conversations_dir)?;
|
||||
let root = root_for(¤t.id, &metas);
|
||||
let mut ids = BTreeSet::new();
|
||||
for id in metas.keys() {
|
||||
if root_for(id, &metas) == root {
|
||||
ids.insert(id.clone());
|
||||
}
|
||||
}
|
||||
ids.insert(current.id.clone());
|
||||
|
||||
let mut branches = Vec::new();
|
||||
let mut checkpoints = Vec::new();
|
||||
for id in ids {
|
||||
let Ok((conversation, _)) = Conversation::load(conversations_dir, &id) else {
|
||||
continue;
|
||||
};
|
||||
let meta = conversation.meta();
|
||||
branches.push(BranchSummary {
|
||||
id: conversation.id.clone(),
|
||||
created_at: meta
|
||||
.as_ref()
|
||||
.map(|m| m.created_at.clone())
|
||||
.unwrap_or_default(),
|
||||
parent_chat_id: meta.as_ref().and_then(|m| m.parent_chat_id.clone()),
|
||||
branch_label: meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.branch_from.as_ref().map(|p| p.checkpoint_label.clone())),
|
||||
record_count: conversation.records.len(),
|
||||
current: conversation.id == current.id,
|
||||
});
|
||||
checkpoints.extend(checkpoint_records(&conversation.records, &conversation.id));
|
||||
}
|
||||
branches.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
checkpoints.sort_by(|a, b| {
|
||||
a.chat_id
|
||||
.cmp(&b.chat_id)
|
||||
.then(a.record_index.cmp(&b.record_index))
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
Ok(BranchFamily {
|
||||
branches,
|
||||
checkpoints,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_branch(
|
||||
conversations_dir: &Path,
|
||||
source: &Conversation,
|
||||
checkpoint: &Checkpoint,
|
||||
) -> Result<Conversation> {
|
||||
if source.id != checkpoint.chat_id {
|
||||
bail!(
|
||||
"checkpoint {} belongs to {}, not {}",
|
||||
checkpoint.id,
|
||||
checkpoint.chat_id,
|
||||
source.id
|
||||
);
|
||||
}
|
||||
if checkpoint.record_index >= source.records.len() {
|
||||
bail!("checkpoint record index is out of range");
|
||||
}
|
||||
|
||||
fs::create_dir_all(conversations_dir)?;
|
||||
let id = conversation::new_chat_id();
|
||||
let path = conversations_dir.join(format!("{id}.jsonl"));
|
||||
let mut records = Vec::new();
|
||||
let meta = source
|
||||
.meta()
|
||||
.context("source conversation is missing metadata")?;
|
||||
records.push(Record::Meta {
|
||||
chat_id: id.clone(),
|
||||
created_at: conversation::now_ts(),
|
||||
model: meta.model,
|
||||
cwd: meta.cwd,
|
||||
parent_chat_id: Some(source.id.clone()),
|
||||
branch_from: Some(BranchPoint {
|
||||
chat_id: source.id.clone(),
|
||||
record_index: checkpoint.record_index,
|
||||
tool_call_id: checkpoint.tool_call_id.clone(),
|
||||
checkpoint_label: checkpoint_title(checkpoint),
|
||||
}),
|
||||
});
|
||||
|
||||
let prefix = valid_prefix(source, checkpoint)?;
|
||||
records.extend(prefix);
|
||||
repair_pending_tool_calls(&mut records);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("creating branch conversation {}", path.display()))?;
|
||||
for record in &records {
|
||||
writeln!(file, "{}", serde_json::to_string(record)?)?;
|
||||
}
|
||||
file.flush()?;
|
||||
|
||||
Ok(Conversation { id, path, records })
|
||||
}
|
||||
|
||||
fn valid_prefix(source: &Conversation, checkpoint: &Checkpoint) -> Result<Vec<Record>> {
|
||||
let mut end = checkpoint.record_index + 1;
|
||||
if matches!(checkpoint.kind, CheckpointKind::ToolCall) {
|
||||
end = checkpoint.record_index + 1;
|
||||
}
|
||||
let mut prefix = source.records[..end].to_vec();
|
||||
|
||||
// Drop the source meta; the branch writes its own meta record.
|
||||
if matches!(prefix.first(), Some(Record::Meta { .. })) {
|
||||
prefix.remove(0);
|
||||
}
|
||||
|
||||
// For a tool-call checkpoint, keep the assistant turn but do not copy any
|
||||
// later tool result. The repair step below writes cancelled tool results so
|
||||
// the next provider request remains valid.
|
||||
Ok(prefix)
|
||||
}
|
||||
|
||||
pub fn repair_pending_tool_calls(records: &mut Vec<Record>) {
|
||||
let mut pending: Vec<(String, String)> = Vec::new();
|
||||
for record in records.iter() {
|
||||
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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let seen: HashSet<String> = records
|
||||
.iter()
|
||||
.filter_map(|record| match record {
|
||||
Record::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (id, name) in pending {
|
||||
if seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
records.push(Record::Tool {
|
||||
tool_call_id: id,
|
||||
name,
|
||||
ok: false,
|
||||
content: TOOL_CANCELLED_MESSAGE.to_string(),
|
||||
ts: conversation::now_ts(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn load_all_metas(conversations_dir: &Path) -> Result<BTreeMap<String, (Option<String>, String)>> {
|
||||
let mut out = BTreeMap::new();
|
||||
if !conversations_dir.exists() {
|
||||
return Ok(out);
|
||||
}
|
||||
for entry in fs::read_dir(conversations_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(Some((parent, cwd))) = read_meta_parent_cwd(&path) {
|
||||
out.insert(id.to_string(), (parent, cwd));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_meta_parent_cwd(path: &Path) -> Result<Option<(Option<String>, String)>> {
|
||||
let file = File::open(path)?;
|
||||
for line in BufReader::new(file).lines().take(10) {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let record: Record = serde_json::from_str(&line)?;
|
||||
if let Record::Meta {
|
||||
parent_chat_id,
|
||||
cwd,
|
||||
..
|
||||
} = record
|
||||
{
|
||||
return Ok(Some((parent_chat_id, cwd)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn root_for(id: &str, metas: &BTreeMap<String, (Option<String>, String)>) -> String {
|
||||
let mut current = id.to_string();
|
||||
let mut seen = HashSet::new();
|
||||
while seen.insert(current.clone()) {
|
||||
let Some((Some(parent), _)) = metas.get(¤t) else {
|
||||
break;
|
||||
};
|
||||
current = parent.clone();
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
pub fn checkpoint_title(checkpoint: &Checkpoint) -> String {
|
||||
if checkpoint.detail.is_empty() {
|
||||
checkpoint.label.clone()
|
||||
} else {
|
||||
format!("{}: {}", checkpoint.label, checkpoint.detail)
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_call_detail(call: &StoredToolCall) -> String {
|
||||
let mut detail = String::new();
|
||||
if let Some(path) = call.arguments.get("path").and_then(|v| v.as_str()) {
|
||||
detail = format!("file: {path}");
|
||||
} else if let Some(command) = call.arguments.get("command").and_then(|v| v.as_str()) {
|
||||
detail = command.to_string();
|
||||
}
|
||||
if detail.is_empty() {
|
||||
preview(&call.arguments.to_string())
|
||||
} else {
|
||||
preview(&detail)
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(content: &str) -> String {
|
||||
content
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(96)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn base_records(id: &str) -> Vec<Record> {
|
||||
vec![
|
||||
Record::Meta {
|
||||
chat_id: id.into(),
|
||||
created_at: "now".into(),
|
||||
model: "m".into(),
|
||||
cwd: "/tmp".into(),
|
||||
parent_chat_id: None,
|
||||
branch_from: None,
|
||||
},
|
||||
Record::System {
|
||||
content: "s".into(),
|
||||
},
|
||||
Record::User {
|
||||
content: "u".into(),
|
||||
ts: "t".into(),
|
||||
},
|
||||
Record::Assistant {
|
||||
content: "a".into(),
|
||||
reasoning: String::new(),
|
||||
reasoning_field: None,
|
||||
tool_calls: vec![StoredToolCall {
|
||||
id: "call1".into(),
|
||||
name: "read".into(),
|
||||
arguments: json!({"path":"x"}),
|
||||
}],
|
||||
ts: "t".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoints_include_tool_calls() {
|
||||
let checkpoints = checkpoint_records(&base_records("c"), "c");
|
||||
assert!(checkpoints.iter().any(|c| c.kind == CheckpointKind::User));
|
||||
assert!(checkpoints
|
||||
.iter()
|
||||
.any(|c| c.kind == CheckpointKind::Assistant));
|
||||
assert!(checkpoints
|
||||
.iter()
|
||||
.any(|c| c.kind == CheckpointKind::ToolCall));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_branch_does_not_modify_source_and_repairs_pending_tools() {
|
||||
let dir = tempdir().unwrap();
|
||||
let source = Conversation {
|
||||
id: "source".into(),
|
||||
path: dir.path().join("source.jsonl"),
|
||||
records: base_records("source"),
|
||||
};
|
||||
let checkpoint = checkpoint_records(&source.records, &source.id)
|
||||
.into_iter()
|
||||
.find(|c| c.kind == CheckpointKind::Assistant)
|
||||
.unwrap();
|
||||
let branch = create_branch(dir.path(), &source, &checkpoint).unwrap();
|
||||
assert_ne!(branch.id, source.id);
|
||||
assert!(
|
||||
source
|
||||
.records
|
||||
.iter()
|
||||
.filter(|r| matches!(r, Record::Tool { .. }))
|
||||
.count()
|
||||
== 0
|
||||
);
|
||||
assert!(branch
|
||||
.records
|
||||
.iter()
|
||||
.any(|r| matches!(r, Record::Tool { ok: false, .. })));
|
||||
}
|
||||
}
|
||||
+110
-3
@@ -1,5 +1,9 @@
|
||||
use crate::cli::Cli;
|
||||
use crate::config::{self, ApiKeyReference, Config, ModelsFile, ProvidersFile};
|
||||
use crate::codex_auth;
|
||||
use crate::config::{
|
||||
self, ApiKeyReference, Config, ModelsFile, ProvidersFile, CHATGPT_CODEX_PROVIDER_KIND,
|
||||
DEFAULT_PROVIDER_KIND,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -38,12 +42,57 @@ 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(),
|
||||
];
|
||||
}
|
||||
if error.contains("Codex auth") {
|
||||
return vec!["codex login".into(), "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,25 +198,83 @@ fn check_active_config(report: &mut CheckReport, cfg: &Config, providers: &Provi
|
||||
report
|
||||
.successes
|
||||
.push(format!("active provider: {}", cfg.provider_id));
|
||||
let endpoint_label = if cfg.active_provider.kind == CHATGPT_CODEX_PROVIDER_KIND {
|
||||
"active provider endpoint"
|
||||
} else {
|
||||
"active provider base URL"
|
||||
};
|
||||
report.successes.push(format!(
|
||||
"{endpoint_label}: {}",
|
||||
cfg.active_provider.base_url
|
||||
));
|
||||
report
|
||||
.successes
|
||||
.push(format!("active model: {}", cfg.model));
|
||||
|
||||
check_api_key(report, "api key", &cfg.active_provider.api_key, true);
|
||||
check_provider_auth(
|
||||
report,
|
||||
"api key",
|
||||
&cfg.active_provider.kind,
|
||||
&cfg.active_provider.api_key,
|
||||
true,
|
||||
);
|
||||
|
||||
for provider in &providers.providers {
|
||||
if provider.id == cfg.provider_id {
|
||||
continue;
|
||||
}
|
||||
check_api_key(
|
||||
check_provider_auth(
|
||||
report,
|
||||
&format!("provider `{}` api key", provider.id),
|
||||
&provider.kind,
|
||||
&provider.api_key,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_provider_auth(
|
||||
report: &mut CheckReport,
|
||||
label: &str,
|
||||
kind: &str,
|
||||
api_key: &str,
|
||||
active: bool,
|
||||
) {
|
||||
match kind {
|
||||
DEFAULT_PROVIDER_KIND => check_api_key(report, label, api_key, active),
|
||||
CHATGPT_CODEX_PROVIDER_KIND => check_codex_auth(report, active),
|
||||
_ if active => report
|
||||
.errors
|
||||
.push(format!("provider kind `{kind}` is unsupported")),
|
||||
_ => report
|
||||
.warnings
|
||||
.push(format!("provider kind `{kind}` is unsupported")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_codex_auth(report: &mut CheckReport, active: bool) {
|
||||
let status = codex_auth::check_codex_auth();
|
||||
if status.is_usable() {
|
||||
if active {
|
||||
report
|
||||
.successes
|
||||
.push(format!("Codex auth: {}", status.summary()));
|
||||
}
|
||||
} else if active {
|
||||
report.errors.push(format!(
|
||||
"Codex auth: {}. {}",
|
||||
status.summary(),
|
||||
status.recovery_hint()
|
||||
));
|
||||
} else {
|
||||
report.warnings.push(format!(
|
||||
"Codex auth: {}. {}",
|
||||
status.summary(),
|
||||
status.recovery_hint()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn check_api_key(report: &mut CheckReport, label: &str, spec: &str, active: bool) {
|
||||
match config::api_key_reference(spec) {
|
||||
Ok(ApiKeyReference::Env(name)) => match std::env::var(&name) {
|
||||
|
||||
+36
-1
@@ -1,4 +1,4 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
@@ -44,6 +44,41 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Validate Cass config files.
|
||||
Check,
|
||||
/// Configure or update OpenAI-compatible provider login settings.
|
||||
Login,
|
||||
/// Remove saved providers and their models.
|
||||
Logout,
|
||||
/// Configure an OpenAI-compatible provider and first model.
|
||||
Setup,
|
||||
/// Update Cassady from official GitHub releases.
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodexAccessToken {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl CodexAccessToken {
|
||||
pub fn new(value: String) -> Result<Self> {
|
||||
if value.trim().is_empty() {
|
||||
bail!("Codex access token is empty");
|
||||
}
|
||||
Ok(Self { value })
|
||||
}
|
||||
|
||||
pub fn as_secret(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn redacted(&self) -> &'static str {
|
||||
"<Codex access token>"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CodexAccessToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexAuthStatus {
|
||||
pub path: PathBuf,
|
||||
pub auth_mode: Option<String>,
|
||||
pub has_access_token: bool,
|
||||
pub expires_at: Option<i64>,
|
||||
pub expired: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexAuthStatus {
|
||||
pub fn is_usable(&self) -> bool {
|
||||
self.error.is_none() && self.has_access_token && !self.expired
|
||||
}
|
||||
|
||||
pub fn recovery_hint(&self) -> &'static str {
|
||||
"Run `codex login` or sign in with the Codex app, then rerun `cass check`."
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> String {
|
||||
if self.is_usable() {
|
||||
if let Some(auth_mode) = &self.auth_mode {
|
||||
format!(
|
||||
"{} contains an access token (auth mode: {auth_mode})",
|
||||
pretty_path(&self.path)
|
||||
)
|
||||
} else {
|
||||
format!("{} contains an access token", pretty_path(&self.path))
|
||||
}
|
||||
} else if let Some(error) = &self.error {
|
||||
format!("{}: {error}", pretty_path(&self.path))
|
||||
} else if self.expired {
|
||||
format!(
|
||||
"{} contains an expired access token",
|
||||
pretty_path(&self.path)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} does not contain an access token",
|
||||
pretty_path(&self.path)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CodexAuthFile {
|
||||
auth_mode: Option<String>,
|
||||
tokens: Option<CodexTokens>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CodexTokens {
|
||||
access_token: Option<String>,
|
||||
}
|
||||
|
||||
pub fn codex_home() -> PathBuf {
|
||||
std::env::var_os("CODEX_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
|
||||
.unwrap_or_else(|| PathBuf::from(".codex"))
|
||||
}
|
||||
|
||||
pub fn codex_auth_path() -> PathBuf {
|
||||
std::env::var_os("CODEX_AUTH_FILE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| codex_home().join("auth.json"))
|
||||
}
|
||||
|
||||
pub fn codex_config_path() -> PathBuf {
|
||||
std::env::var_os("CODEX_CONFIG_FILE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| codex_home().join("config.toml"))
|
||||
}
|
||||
|
||||
pub fn load_codex_access_token() -> Result<CodexAccessToken> {
|
||||
load_codex_access_token_from_path(&codex_auth_path())
|
||||
}
|
||||
|
||||
pub fn load_codex_access_token_from_path(path: &Path) -> Result<CodexAccessToken> {
|
||||
let text = fs::read_to_string(path).with_context(|| {
|
||||
format!(
|
||||
"Codex auth not found at {}; run `codex login` or sign in with the Codex app",
|
||||
pretty_path(path)
|
||||
)
|
||||
})?;
|
||||
let parsed: CodexAuthFile = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parsing Codex auth at {}", pretty_path(path)))?;
|
||||
let token = parsed
|
||||
.tokens
|
||||
.and_then(|tokens| tokens.access_token)
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no access token found in {}; run `codex login` or sign in with the Codex app",
|
||||
pretty_path(path)
|
||||
)
|
||||
})?;
|
||||
if jwt_is_expired(&token) == Some(true) {
|
||||
bail!(
|
||||
"Codex access token in {} is expired; run `codex login` or sign in with the Codex app",
|
||||
pretty_path(path)
|
||||
);
|
||||
}
|
||||
CodexAccessToken::new(token)
|
||||
}
|
||||
|
||||
pub fn check_codex_auth() -> CodexAuthStatus {
|
||||
check_codex_auth_at(&codex_auth_path())
|
||||
}
|
||||
|
||||
pub fn check_codex_auth_at(path: &Path) -> CodexAuthStatus {
|
||||
let mut status = CodexAuthStatus {
|
||||
path: path.to_path_buf(),
|
||||
auth_mode: None,
|
||||
has_access_token: false,
|
||||
expires_at: None,
|
||||
expired: false,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
status.error = Some(format!("not readable ({err})"));
|
||||
return status;
|
||||
}
|
||||
};
|
||||
let parsed: CodexAuthFile = match serde_json::from_str(&text) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
status.error = Some(format!("invalid JSON ({err})"));
|
||||
return status;
|
||||
}
|
||||
};
|
||||
status.auth_mode = parsed.auth_mode;
|
||||
let token = parsed
|
||||
.tokens
|
||||
.and_then(|tokens| tokens.access_token)
|
||||
.filter(|token| !token.trim().is_empty());
|
||||
if let Some(token) = token {
|
||||
status.has_access_token = true;
|
||||
status.expires_at = jwt_expiration(&token);
|
||||
status.expired = jwt_is_expired(&token).unwrap_or(false);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
pub fn read_codex_default_model() -> Option<String> {
|
||||
read_codex_default_model_from_path(&codex_config_path())
|
||||
}
|
||||
|
||||
pub fn read_codex_default_model_from_path(path: &Path) -> Option<String> {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') || !line.starts_with("model") {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if key.trim() != "model" {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
let value = value
|
||||
.strip_prefix('"')
|
||||
.and_then(|v| v.strip_suffix('"'))
|
||||
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
|
||||
.unwrap_or(value)
|
||||
.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn jwt_is_expired(token: &str) -> Option<bool> {
|
||||
let exp = jwt_expiration(token)?;
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs() as i64;
|
||||
Some(exp <= now)
|
||||
}
|
||||
|
||||
fn jwt_expiration(token: &str) -> Option<i64> {
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next()?;
|
||||
let payload = parts.next()?;
|
||||
let bytes = base64_url_decode(payload).ok()?;
|
||||
let json: Value = serde_json::from_slice(&bytes).ok()?;
|
||||
json.get("exp")?.as_i64()
|
||||
}
|
||||
|
||||
fn base64_url_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
let mut bits = 0u32;
|
||||
let mut bit_count = 0u8;
|
||||
let mut out = Vec::new();
|
||||
for byte in input.bytes() {
|
||||
let value = match byte {
|
||||
b'A'..=b'Z' => byte - b'A',
|
||||
b'a'..=b'z' => byte - b'a' + 26,
|
||||
b'0'..=b'9' => byte - b'0' + 52,
|
||||
b'-' | b'+' => 62,
|
||||
b'_' | b'/' => 63,
|
||||
b'=' => break,
|
||||
_ => return Err("invalid base64 character".into()),
|
||||
} as u32;
|
||||
bits = (bits << 6) | value;
|
||||
bit_count += 6;
|
||||
if bit_count >= 8 {
|
||||
bit_count -= 8;
|
||||
out.push(((bits >> bit_count) & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn pretty_path(path: &Path) -> String {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
if let Ok(rest) = path.strip_prefix(&home) {
|
||||
return format!("~/{}", rest.display());
|
||||
}
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn reads_access_token_without_displaying_it() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"auth_mode":"chatgpt","tokens":{"access_token":"secret-token"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let token = load_codex_access_token_from_path(&path).unwrap();
|
||||
assert_eq!(token.as_secret(), "secret-token");
|
||||
assert_eq!(token.to_string(), "<Codex access token>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_missing_token_without_secret_fields() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"tokens":{"refresh_token":"refresh-secret","account_id":"acct"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status = check_codex_auth_at(&path);
|
||||
assert!(!status.is_usable());
|
||||
let summary = status.summary();
|
||||
assert!(!summary.contains("refresh-secret"));
|
||||
assert!(!summary.contains("acct"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_model_from_codex_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "model = \"gpt-test\"\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
read_codex_default_model_from_path(&path).as_deref(),
|
||||
Some("gpt-test")
|
||||
);
|
||||
}
|
||||
}
|
||||
+86
-25
@@ -9,6 +9,11 @@ use std::path::{Path, PathBuf};
|
||||
pub const DEFAULT_PROVIDER_ID: &str = "fireworks";
|
||||
pub const DEFAULT_PROVIDER_NAME: &str = "Fireworks";
|
||||
pub const DEFAULT_PROVIDER_KIND: &str = "openai-compatible";
|
||||
pub const CHATGPT_CODEX_PROVIDER_ID: &str = "chatgpt-codex";
|
||||
pub const CHATGPT_CODEX_PROVIDER_NAME: &str = "ChatGPT Codex";
|
||||
pub const CHATGPT_CODEX_PROVIDER_KIND: &str = "chatgpt-codex";
|
||||
pub const CHATGPT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
|
||||
pub const CHATGPT_CODEX_DEFAULT_MODEL: &str = "gpt-5.5";
|
||||
pub const DEFAULT_MODEL: &str = "accounts/fireworks/models/qwen3p7-plus";
|
||||
pub const DEFAULT_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
|
||||
pub const DEFAULT_API_KEY_ENV: &str = "FIREWORKS_API_KEY";
|
||||
@@ -61,6 +66,7 @@ pub struct ProviderDefinition {
|
||||
pub name: Option<String>,
|
||||
pub kind: String,
|
||||
pub base_url: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub api_key: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
@@ -128,7 +134,7 @@ pub struct ResolvedProviderConfig {
|
||||
pub name: Option<String>,
|
||||
pub kind: String,
|
||||
pub base_url: String,
|
||||
/// Either a literal API key or an env-var reference like "$FIREWORKS_API_KEY".
|
||||
/// Either a literal API key, an env-var reference like "$FIREWORKS_API_KEY", or empty for provider kinds that use external local auth.
|
||||
pub api_key: String,
|
||||
pub default_model: Option<String>,
|
||||
pub models: Vec<String>,
|
||||
@@ -151,6 +157,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 +321,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 +376,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 +393,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}");
|
||||
}
|
||||
|
||||
@@ -395,6 +435,14 @@ impl Config {
|
||||
pub fn resolved_api_key(&self) -> Result<String> {
|
||||
resolve_api_key(&self.active_provider.api_key)
|
||||
}
|
||||
|
||||
pub fn ensure_provider_auth(&self) -> Result<()> {
|
||||
match self.active_provider.kind.as_str() {
|
||||
DEFAULT_PROVIDER_KIND => self.resolved_api_key().map(|_| ()),
|
||||
CHATGPT_CODEX_PROVIDER_KIND => crate::codex_auth::load_codex_access_token().map(|_| ()),
|
||||
kind => bail!("unsupported provider kind `{kind}`"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDefinition {
|
||||
@@ -501,6 +549,10 @@ pub fn api_key_reference(spec: &str) -> Result<ApiKeyReference> {
|
||||
Ok(ApiKeyReference::Literal)
|
||||
}
|
||||
|
||||
pub fn is_supported_provider_kind(kind: &str) -> bool {
|
||||
matches!(kind, DEFAULT_PROVIDER_KIND | CHATGPT_CODEX_PROVIDER_KIND)
|
||||
}
|
||||
|
||||
pub fn resolve_api_key(spec: &str) -> Result<String> {
|
||||
match api_key_reference(spec)? {
|
||||
ApiKeyReference::Env(name) => {
|
||||
@@ -542,7 +594,7 @@ pub fn validate_registries(
|
||||
"providers.json: provider `{}` kind must not be empty",
|
||||
provider.id
|
||||
));
|
||||
} else if provider.kind != DEFAULT_PROVIDER_KIND {
|
||||
} else if !is_supported_provider_kind(&provider.kind) {
|
||||
out.errors.push(format!(
|
||||
"providers.json: provider `{}` uses unsupported kind `{}`",
|
||||
provider.id, provider.kind
|
||||
@@ -559,9 +611,18 @@ pub fn validate_registries(
|
||||
provider.id
|
||||
));
|
||||
}
|
||||
if let Err(err) = api_key_reference(&provider.api_key) {
|
||||
out.errors.push(format!(
|
||||
"providers.json: provider `{}` has invalid api_key: {err}",
|
||||
if provider.kind == DEFAULT_PROVIDER_KIND {
|
||||
if let Err(err) = api_key_reference(&provider.api_key) {
|
||||
out.errors.push(format!(
|
||||
"providers.json: provider `{}` has invalid api_key: {err}",
|
||||
provider.id
|
||||
));
|
||||
}
|
||||
} else if provider.kind == CHATGPT_CODEX_PROVIDER_KIND
|
||||
&& !provider.api_key.trim().is_empty()
|
||||
{
|
||||
out.warnings.push(format!(
|
||||
"providers.json: provider `{}` ignores api_key because ChatGPT Codex uses local Codex auth",
|
||||
provider.id
|
||||
));
|
||||
}
|
||||
@@ -706,8 +767,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 +799,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}"))
|
||||
|
||||
@@ -14,6 +14,10 @@ pub enum Record {
|
||||
created_at: String,
|
||||
model: String,
|
||||
cwd: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
parent_chat_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
branch_from: Option<BranchPoint>,
|
||||
},
|
||||
System {
|
||||
content: String,
|
||||
@@ -40,6 +44,15 @@ pub enum Record {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BranchPoint {
|
||||
pub chat_id: String,
|
||||
pub record_index: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
pub checkpoint_label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StoredToolCall {
|
||||
pub id: String,
|
||||
@@ -92,6 +105,8 @@ impl Conversation {
|
||||
created_at: now_ts(),
|
||||
model: model.to_string(),
|
||||
cwd: cwd.display().to_string(),
|
||||
parent_chat_id: None,
|
||||
branch_from: None,
|
||||
})?;
|
||||
convo.append(Record::System {
|
||||
content: base_system,
|
||||
@@ -161,6 +176,37 @@ impl Conversation {
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn meta(&self) -> Option<ConversationMeta> {
|
||||
self.records.iter().find_map(|r| match r {
|
||||
Record::Meta {
|
||||
chat_id,
|
||||
created_at,
|
||||
model,
|
||||
cwd,
|
||||
parent_chat_id,
|
||||
branch_from,
|
||||
} => Some(ConversationMeta {
|
||||
chat_id: chat_id.clone(),
|
||||
created_at: created_at.clone(),
|
||||
model: model.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parent_chat_id: parent_chat_id.clone(),
|
||||
branch_from: branch_from.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConversationMeta {
|
||||
pub chat_id: String,
|
||||
pub created_at: String,
|
||||
pub model: String,
|
||||
pub cwd: String,
|
||||
pub parent_chat_id: Option<String>,
|
||||
pub branch_from: Option<BranchPoint>,
|
||||
}
|
||||
|
||||
pub fn list_chats(conversations_dir: &Path, cwd: &Path) -> Result<Vec<ChatSummary>> {
|
||||
|
||||
@@ -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.ensure_provider_auth().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
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
use crate::tools::{self, ToolContext};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_SNAPSHOT_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FileEditJournalEntry {
|
||||
pub chat_id: String,
|
||||
pub record_index: usize,
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
pub path: PathBuf,
|
||||
pub existed_before: bool,
|
||||
pub existed_after: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub before_hash: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_hash: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub before_snapshot: Option<PathBuf>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_snapshot: Option<PathBuf>,
|
||||
pub ts: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingFileEditSnapshot {
|
||||
pub chat_id: String,
|
||||
pub record_index: usize,
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
pub path: PathBuf,
|
||||
before: SnapshotState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum SnapshotState {
|
||||
Missing,
|
||||
File { bytes: Vec<u8>, hash: String },
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RestorePlan {
|
||||
pub actions: Vec<RestoreAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RestoreAction {
|
||||
Write {
|
||||
path: PathBuf,
|
||||
snapshot: PathBuf,
|
||||
desired_hash: String,
|
||||
expected_current_hash: Option<String>,
|
||||
conflict: bool,
|
||||
},
|
||||
Delete {
|
||||
path: PathBuf,
|
||||
expected_current_hash: Option<String>,
|
||||
conflict: bool,
|
||||
},
|
||||
Skip {
|
||||
path: PathBuf,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RestoreOutcome {
|
||||
pub applied: usize,
|
||||
pub skipped: usize,
|
||||
pub conflicts: usize,
|
||||
}
|
||||
|
||||
pub fn begin_tool_edit(
|
||||
cass_root: &Path,
|
||||
chat_id: &str,
|
||||
record_index: usize,
|
||||
tool_call_id: &str,
|
||||
tool_name: &str,
|
||||
args: &Value,
|
||||
ctx: &ToolContext,
|
||||
) -> Option<PendingFileEditSnapshot> {
|
||||
if !matches!(tool_name, "write" | "edit") {
|
||||
return None;
|
||||
}
|
||||
let path_arg = args.get("path")?.as_str()?;
|
||||
let path =
|
||||
tools::path::resolve_for_write(path_arg, &ctx.cwd, ctx.mode, &ctx.blocked_write_roots)
|
||||
.ok()?;
|
||||
let before = snapshot_state(&path).unwrap_or(SnapshotState::Unsupported);
|
||||
// Ensure journal directories are creatable before executing, but do not fail
|
||||
// the tool if Cassady cannot journal; restore will simply be unavailable.
|
||||
let _ = fs::create_dir_all(cass_root.join("file-edits"));
|
||||
let _ = fs::create_dir_all(cass_root.join("file-snapshots"));
|
||||
Some(PendingFileEditSnapshot {
|
||||
chat_id: chat_id.to_string(),
|
||||
record_index,
|
||||
tool_call_id: tool_call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
path,
|
||||
before,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_tool_edit(cass_root: &Path, pending: PendingFileEditSnapshot) -> Result<()> {
|
||||
let after = snapshot_state(&pending.path).unwrap_or(SnapshotState::Unsupported);
|
||||
if matches!(pending.before, SnapshotState::Unsupported)
|
||||
|| matches!(after, SnapshotState::Unsupported)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if same_state(&pending.before, &after) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (existed_before, before_hash, before_snapshot) = store_snapshot(
|
||||
cass_root,
|
||||
&pending.chat_id,
|
||||
&pending.tool_call_id,
|
||||
"before",
|
||||
&pending.before,
|
||||
)?;
|
||||
let (existed_after, after_hash, after_snapshot) = store_snapshot(
|
||||
cass_root,
|
||||
&pending.chat_id,
|
||||
&pending.tool_call_id,
|
||||
"after",
|
||||
&after,
|
||||
)?;
|
||||
|
||||
let entry = FileEditJournalEntry {
|
||||
chat_id: pending.chat_id.clone(),
|
||||
record_index: pending.record_index,
|
||||
tool_call_id: pending.tool_call_id,
|
||||
tool_name: pending.tool_name,
|
||||
path: pending.path,
|
||||
existed_before,
|
||||
existed_after,
|
||||
before_hash,
|
||||
after_hash,
|
||||
before_snapshot,
|
||||
after_snapshot,
|
||||
ts: crate::conversation::now_ts(),
|
||||
};
|
||||
append_journal(cass_root, &pending.chat_id, &entry)
|
||||
}
|
||||
|
||||
pub fn load_journal(cass_root: &Path, chat_id: &str) -> Result<Vec<FileEditJournalEntry>> {
|
||||
let path = journal_path(cass_root, chat_id);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content =
|
||||
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut out = Vec::new();
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry: FileEditJournalEntry = serde_json::from_str(line)
|
||||
.with_context(|| format!("parsing {} line {}", path.display(), idx + 1))?;
|
||||
out.push(entry);
|
||||
}
|
||||
out.sort_by_key(|entry| entry.record_index);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn plan_restore(
|
||||
cass_root: &Path,
|
||||
chat_id: &str,
|
||||
target_record_index: usize,
|
||||
) -> Result<RestorePlan> {
|
||||
let entries = load_journal(cass_root, chat_id)?;
|
||||
let mut by_path: BTreeMap<PathBuf, Vec<FileEditJournalEntry>> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
by_path.entry(entry.path.clone()).or_default().push(entry);
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
for (path, mut entries) in by_path {
|
||||
entries.sort_by_key(|entry| entry.record_index);
|
||||
let latest = entries.last().cloned();
|
||||
let desired = entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|entry| entry.record_index <= target_record_index)
|
||||
.cloned();
|
||||
let first_after = entries
|
||||
.iter()
|
||||
.find(|entry| entry.record_index > target_record_index)
|
||||
.cloned();
|
||||
|
||||
let (want_exists, want_hash, want_snapshot) = if let Some(entry) = desired {
|
||||
(entry.existed_after, entry.after_hash, entry.after_snapshot)
|
||||
} else if let Some(entry) = first_after {
|
||||
(
|
||||
entry.existed_before,
|
||||
entry.before_hash,
|
||||
entry.before_snapshot,
|
||||
)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let expected_current_hash = latest.and_then(|entry| entry.after_hash);
|
||||
let current_hash = hash_existing_file(&path)?;
|
||||
let conflict = expected_current_hash.is_some()
|
||||
&& current_hash.is_some()
|
||||
&& expected_current_hash != current_hash;
|
||||
|
||||
if want_exists {
|
||||
match (want_hash, want_snapshot) {
|
||||
(Some(desired_hash), Some(snapshot)) => actions.push(RestoreAction::Write {
|
||||
path,
|
||||
snapshot,
|
||||
desired_hash,
|
||||
expected_current_hash,
|
||||
conflict,
|
||||
}),
|
||||
_ => actions.push(RestoreAction::Skip {
|
||||
path,
|
||||
reason: "missing desired snapshot".into(),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
let conflict = conflict
|
||||
|| (current_hash.is_some()
|
||||
&& expected_current_hash.is_none()
|
||||
&& current_hash != expected_current_hash);
|
||||
actions.push(RestoreAction::Delete {
|
||||
path,
|
||||
expected_current_hash,
|
||||
conflict,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RestorePlan { actions })
|
||||
}
|
||||
|
||||
pub fn apply_restore_plan(plan: &RestorePlan) -> Result<RestoreOutcome> {
|
||||
let mut outcome = RestoreOutcome {
|
||||
applied: 0,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
};
|
||||
for action in &plan.actions {
|
||||
match action {
|
||||
RestoreAction::Write {
|
||||
path,
|
||||
snapshot,
|
||||
conflict,
|
||||
..
|
||||
} => {
|
||||
if *conflict {
|
||||
outcome.conflicts += 1;
|
||||
continue;
|
||||
}
|
||||
let bytes = fs::read(snapshot)
|
||||
.with_context(|| format!("reading snapshot {}", snapshot.display()))?;
|
||||
crate::tools::write::atomic_write(path, &bytes)
|
||||
.with_context(|| format!("restoring {}", path.display()))?;
|
||||
outcome.applied += 1;
|
||||
}
|
||||
RestoreAction::Delete { path, conflict, .. } => {
|
||||
if *conflict {
|
||||
outcome.conflicts += 1;
|
||||
continue;
|
||||
}
|
||||
if path.exists() {
|
||||
fs::remove_file(path)
|
||||
.with_context(|| format!("deleting {}", path.display()))?;
|
||||
outcome.applied += 1;
|
||||
} else {
|
||||
outcome.skipped += 1;
|
||||
}
|
||||
}
|
||||
RestoreAction::Skip { .. } => outcome.skipped += 1,
|
||||
}
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub fn summarize_plan(plan: &RestorePlan) -> String {
|
||||
if plan.actions.is_empty() {
|
||||
return "No tracked file edits need restoration for this checkpoint.".into();
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
for action in &plan.actions {
|
||||
match action {
|
||||
RestoreAction::Write { path, conflict, .. } => lines.push(format!(
|
||||
"{} update {}",
|
||||
if *conflict { "CONFLICT" } else { "will" },
|
||||
path.display()
|
||||
)),
|
||||
RestoreAction::Delete { path, conflict, .. } => lines.push(format!(
|
||||
"{} delete {}",
|
||||
if *conflict { "CONFLICT" } else { "will" },
|
||||
path.display()
|
||||
)),
|
||||
RestoreAction::Skip { path, reason } => {
|
||||
lines.push(format!("skip {}: {reason}", path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn snapshot_state(path: &Path) -> Result<SnapshotState> {
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => {
|
||||
if !metadata.is_file() || metadata.len() > MAX_SNAPSHOT_BYTES {
|
||||
return Ok(SnapshotState::Unsupported);
|
||||
}
|
||||
let bytes = fs::read(path)?;
|
||||
let hash = sha256_hex(&bytes);
|
||||
Ok(SnapshotState::File { bytes, hash })
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(SnapshotState::Missing),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn same_state(a: &SnapshotState, b: &SnapshotState) -> bool {
|
||||
match (a, b) {
|
||||
(SnapshotState::Missing, SnapshotState::Missing) => true,
|
||||
(SnapshotState::File { hash: a, .. }, SnapshotState::File { hash: b, .. }) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn store_snapshot(
|
||||
cass_root: &Path,
|
||||
chat_id: &str,
|
||||
tool_call_id: &str,
|
||||
side: &str,
|
||||
state: &SnapshotState,
|
||||
) -> Result<(bool, Option<String>, Option<PathBuf>)> {
|
||||
match state {
|
||||
SnapshotState::Missing => Ok((false, None, None)),
|
||||
SnapshotState::Unsupported => Ok((false, None, None)),
|
||||
SnapshotState::File { bytes, hash } => {
|
||||
let dir = cass_root
|
||||
.join("file-snapshots")
|
||||
.join(chat_id)
|
||||
.join(tool_call_id);
|
||||
fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(format!("{side}-{hash}.bin"));
|
||||
if !path.exists() {
|
||||
fs::write(&path, bytes)?;
|
||||
}
|
||||
Ok((true, Some(hash.clone()), Some(path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_journal(cass_root: &Path, chat_id: &str, entry: &FileEditJournalEntry) -> Result<()> {
|
||||
let path = journal_path(cass_root, chat_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
writeln!(file, "{}", serde_json::to_string(entry)?)?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn journal_path(cass_root: &Path, chat_id: &str) -> PathBuf {
|
||||
cass_root
|
||||
.join("file-edits")
|
||||
.join(format!("{chat_id}.jsonl"))
|
||||
}
|
||||
|
||||
fn hash_existing_file(path: &Path) -> Result<Option<String>> {
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => {
|
||||
if !metadata.is_file() || metadata.len() > MAX_SNAPSHOT_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(sha256_hex(&fs::read(path)?)))
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::access::AccessMode;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn tool_ctx(cwd: &Path) -> ToolContext {
|
||||
ToolContext {
|
||||
mode: AccessMode::WorkspaceEdit,
|
||||
cwd: cwd.to_path_buf(),
|
||||
read_roots: vec![cwd.to_path_buf()],
|
||||
blocked_write_roots: Vec::new(),
|
||||
model_result_limit: 1000,
|
||||
runtime_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_and_restore_rewinds_write() {
|
||||
let root = tempdir().unwrap();
|
||||
let work = tempdir().unwrap();
|
||||
let path = work.path().join("a.txt");
|
||||
fs::write(&path, "old").unwrap();
|
||||
let ctx = tool_ctx(work.path());
|
||||
let pending = begin_tool_edit(
|
||||
root.path(),
|
||||
"chat",
|
||||
3,
|
||||
"call",
|
||||
"write",
|
||||
&serde_json::json!({"path":"a.txt"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&path, "new").unwrap();
|
||||
finish_tool_edit(root.path(), pending).unwrap();
|
||||
|
||||
let plan = plan_restore(root.path(), "chat", 2).unwrap();
|
||||
assert_eq!(plan.actions.len(), 1);
|
||||
let outcome = apply_restore_plan(&plan).unwrap();
|
||||
assert_eq!(outcome.applied, 1);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_detects_external_conflict() {
|
||||
let root = tempdir().unwrap();
|
||||
let work = tempdir().unwrap();
|
||||
let path = work.path().join("a.txt");
|
||||
fs::write(&path, "old").unwrap();
|
||||
let ctx = tool_ctx(work.path());
|
||||
let pending = begin_tool_edit(
|
||||
root.path(),
|
||||
"chat",
|
||||
3,
|
||||
"call",
|
||||
"write",
|
||||
&serde_json::json!({"path":"a.txt"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&path, "new").unwrap();
|
||||
finish_tool_edit(root.path(), pending).unwrap();
|
||||
fs::write(&path, "manual").unwrap();
|
||||
let plan = plan_restore(root.path(), "chat", 2).unwrap();
|
||||
assert!(matches!(
|
||||
&plan.actions[0],
|
||||
RestoreAction::Write { conflict: true, .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
pub mod access;
|
||||
pub mod agent;
|
||||
pub mod app;
|
||||
pub mod branch;
|
||||
pub mod check;
|
||||
pub mod cli;
|
||||
pub mod codex_auth;
|
||||
pub mod config;
|
||||
pub mod conversation;
|
||||
pub mod docs;
|
||||
pub mod embedding;
|
||||
pub mod error;
|
||||
pub mod file_edits;
|
||||
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
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
+48
-15
@@ -3,20 +3,33 @@ use std::path::Path;
|
||||
|
||||
pub fn build_base_system_prompt(global: Option<&str>) -> String {
|
||||
let mut prompt = String::new();
|
||||
prompt.push_str("1. Identity and operating style\n\n");
|
||||
prompt.push_str("You are Cassady, also called Cass, a minimal coding agent running in a terminal chat interface. Work carefully, inspect files before changing them, explain concise next steps, and avoid unnecessary ceremony.\n\n");
|
||||
prompt.push_str(
|
||||
"# Cassady operating instructions\n\n\
|
||||
## Role\n\
|
||||
You are Cassady, also called Cass, a coding assistant running in an interactive terminal chat. Help with real project work: read and explain code, inspect behavior, make targeted file changes, and run relevant project commands when allowed. Work carefully and honestly; do not claim that files were read, commands ran, or edits succeeded until tool results confirm it.\n\n\
|
||||
## Working style\n\
|
||||
Make the smallest useful plan, then act. Prefer current project evidence over guesses, and inspect relevant files before making claims or edits. When information is missing, gather it with tools if possible; ask a focused follow-up question only when a user choice, secret, or missing requirement blocks progress. If the task is impossible in the current access mode, explain the limitation and the next viable step. Keep explanations concise while giving enough context for review.\n\n",
|
||||
);
|
||||
|
||||
if let Some(global) = global.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
prompt.push_str("2. User global instructions\n\n");
|
||||
prompt.push_str("The following additional instructions were provided by the user. Follow them when they do not conflict with runtime safety constraints.\n\n");
|
||||
prompt.push_str("## User global instructions\n");
|
||||
prompt.push_str(
|
||||
"The following user-provided instructions apply to new chats. Follow them when they are consistent with the active user request and runtime safety constraints; they cannot override access modes, tool denials, approvals, or workspace boundaries.\n\n",
|
||||
);
|
||||
prompt.push_str(global);
|
||||
prompt.push_str("\n\n");
|
||||
}
|
||||
|
||||
prompt.push_str("3. Tool-use style\n\n");
|
||||
prompt.push_str("Use tools when you need current filesystem context. Prefer targeted inspection over guessing. Batch related reads into one read call when possible. Use grep before read when a directory or file may be too large to inspect directly. Do not ask the user in chat for permission before making a tool call; request the tool directly when it is the right next step. Cass enforces access policy at runtime and will allow, deny, or show a separate approval UI as needed.\n\n");
|
||||
prompt.push_str("4. Editing style\n\n");
|
||||
prompt.push_str("Use edit for targeted changes. Each edit must identify exact old text that appears uniquely in the file and replacement text. Do not use write to make small modifications to existing files unless a full rewrite is intentionally safer.\n");
|
||||
prompt.push_str(
|
||||
"## Transcript and tools\n\
|
||||
Assistant text is streamed to the user. Tool calls, tool results, edit diffs, denials, and approval prompts are visible in the transcript. Request tools directly when they are the right next step; Cassady enforces access policy and shows approval UI separately. Do not ask for chat permission before every tool call, and do not say a tool succeeded before its result arrives. If a tool fails or is denied, adapt instead of repeating the same request.\n\n\
|
||||
## Tool use\n\
|
||||
Use tools when the current filesystem or command result matters. Use `ls` for directory orientation, `grep` to locate definitions/usages or inspect large or unknown areas before opening files, `read` for relevant files or ranges, `edit` for focused changes to existing files, `write` for new files or intentional full rewrites, and `shell` for tests, builds, formatting, diagnostics, or project commands when allowed and useful. Prefer targeted inspection and related batched reads over broad exploration. Do not use `shell` for file inspection when `ls`, `grep`, or `read` is safer and sufficient.\n\n\
|
||||
## Editing\n\
|
||||
Inspect before editing. Prefer `edit` for small and medium modifications to existing files. For `edit`, each old text must match exactly and uniquely in the original file; keep replacements minimal, unique, and non-overlapping, and combine related replacements for the same file in one call when practical. Use `write` only for new files or full rewrites where that is safer and intentional. After meaningful code changes, run relevant tests or formatters when allowed, or tell the user what should be run.\n\n\
|
||||
## Safety and final response\n\
|
||||
Follow runtime constraints, active access mode, tool availability, and tool results as authoritative. Do not try to bypass workspace boundaries, read-only docs rules, approval requirements, or denials. End every turn with a concise user-facing response after tool work; do not finish with only tool calls. Summarize what changed, where, and how it was verified, or summarize findings, blockers, skipped tests, and assumptions if no change was made.\n",
|
||||
);
|
||||
prompt
|
||||
}
|
||||
|
||||
@@ -30,23 +43,43 @@ pub fn build_effective_system_prompt(
|
||||
) -> String {
|
||||
let mut prompt = String::new();
|
||||
prompt.push_str(base.trim_end());
|
||||
prompt.push_str("\n\n5. Current runtime constraints\n\n");
|
||||
prompt.push_str("\n\n## Runtime context\n");
|
||||
prompt.push_str(&format!("Model: {model}.\n"));
|
||||
prompt.push_str(&format!("Access mode: {}.\n", mode.as_str()));
|
||||
prompt.push_str(&format!("Launch working directory: {}.\n", cwd.display()));
|
||||
prompt.push_str(&format!(
|
||||
"Bundled Cass docs directory: {}. This directory is read-only for tools. Use ls, read, and grep there when you need Cass documentation.\n",
|
||||
"Bundled Cass docs directory: {}. Use this directory for Cass documentation; write/edit are blocked there.\n",
|
||||
docs_dir.display()
|
||||
));
|
||||
prompt.push_str(&format!(
|
||||
"Allowed tools this turn: {}.\n\n",
|
||||
allowed_tools.join(", ")
|
||||
render_allowed_tools(allowed_tools)
|
||||
));
|
||||
|
||||
prompt.push_str("## Access rules for this session\n");
|
||||
match mode {
|
||||
AccessMode::ReadOnly => prompt.push_str("In read-only mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. Do not request write, edit, or shell. If a task requires modification, explain that a more permissive mode is needed.\n\n"),
|
||||
AccessMode::WorkspaceEdit => prompt.push_str("In workspace-edit mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. You may write and edit files only inside the launch working directory. Bundled Cass docs are read-only. You may request shell when useful. Do not ask the user for shell permission in chat; call the shell tool directly and Cass will handle any required approval separately before execution.\n\n"),
|
||||
AccessMode::FullAccess => prompt.push_str("In full-access mode, you may request ls, read, grep, write, edit, and shell when needed. The shell tool runs commands in the launch working directory. Cass does not restrict read paths to the launch directory, but normal operating-system permissions still apply. write and edit are still blocked under the bundled Cass docs directory.\n\n"),
|
||||
AccessMode::ReadOnly => prompt.push_str(
|
||||
"Read-only mode permits inspection only. Use `ls`, `read`, and `grep` only inside the launch workspace or bundled Cass docs directory. Do not request `write`, `edit`, or `shell`. If changes, commands, or out-of-scope paths are needed, explain that a more permissive access mode is required.\n\n",
|
||||
),
|
||||
AccessMode::WorkspaceEdit => prompt.push_str(
|
||||
"Workspace-edit mode permits `ls`, `read`, and `grep` inside the launch workspace and bundled Cass docs directory. Write/edit only inside the launch workspace; bundled docs remain read-only. Shell may be requested when useful, but Cassady handles the approval UI, so do not ask for shell permission in chat first. If a path escapes the workspace, choose an in-workspace alternative or explain the limitation.\n\n",
|
||||
),
|
||||
AccessMode::FullAccess => prompt.push_str(
|
||||
"Full-access mode permits `ls`, `read`, `grep`, `write`, `edit`, and `shell` when needed. Shell runs from the launch working directory, and normal operating-system permissions still apply. Bundled docs remain read-only for write/edit. Even in full-access, keep changes targeted and avoid destructive commands unless the user explicitly requested them and they are necessary.\n\n",
|
||||
),
|
||||
}
|
||||
prompt.push_str("6. Response behavior\n\nAssistant output is streamed to the user. Keep user-facing text direct and useful. Tool calls and results are visible to the user, so avoid claiming work happened until the relevant tool result confirms it. After using tools or completing requested work, always end the turn with a concise final user-facing response. Do not finish a turn with only tool calls.\n");
|
||||
|
||||
prompt.push_str(
|
||||
"## Runtime authority\n\
|
||||
Runtime policy and tool results override general guidance and user global instructions. The allowed-tools list is the source of truth for this turn; if Cassady denies a tool or path, adapt and report the limitation. Always provide a concise final response after tool activity.\n",
|
||||
);
|
||||
prompt
|
||||
}
|
||||
|
||||
fn render_allowed_tools(allowed_tools: &[String]) -> String {
|
||||
if allowed_tools.is_empty() {
|
||||
"<none>".into()
|
||||
} else {
|
||||
allowed_tools.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
use super::types::{CompletionResult, ModelMessage};
|
||||
use crate::agent::AgentEvent;
|
||||
use crate::codex_auth::load_codex_access_token;
|
||||
use crate::config::{ReasoningEffort, CHATGPT_CODEX_RESPONSES_URL};
|
||||
use crate::conversation::StoredToolCall;
|
||||
use crate::tools::ToolSpec;
|
||||
use anyhow::{bail, Result};
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatGptCodexProvider {
|
||||
client: Client,
|
||||
model: String,
|
||||
endpoint: String,
|
||||
reasoning_effort: ReasoningEffort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatGptCodexSettings {
|
||||
pub model: String,
|
||||
pub endpoint: String,
|
||||
pub reasoning_effort: ReasoningEffort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct PartialFunctionCall {
|
||||
call_id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
impl ChatGptCodexProvider {
|
||||
pub fn new(settings: ChatGptCodexSettings) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
model: settings.model,
|
||||
endpoint: normalize_endpoint(&settings.endpoint),
|
||||
reasoning_effort: settings.reasoning_effort,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
&self,
|
||||
messages: Vec<ModelMessage>,
|
||||
tools: Vec<ToolSpec>,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<CompletionResult> {
|
||||
let token = load_codex_access_token()?;
|
||||
let secret = token.as_secret().to_string();
|
||||
let body = responses_body(&self.model, messages, tools, self.reasoning_effort);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&self.endpoint)
|
||||
.bearer_auth(token.as_secret())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!(
|
||||
"ChatGPT Codex returned {status}: {}",
|
||||
redact_secret(&text, &secret)
|
||||
);
|
||||
}
|
||||
|
||||
let mut state = StreamState::default();
|
||||
let mut buf = String::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
let chunk_text = String::from_utf8_lossy(&chunk).replace("\r\n", "\n");
|
||||
buf.push_str(&chunk_text);
|
||||
while let Some(pos) = buf.find("\n\n") {
|
||||
let frame = buf[..pos].to_string();
|
||||
buf = buf[pos + 2..].to_string();
|
||||
process_frame(&frame, &mut state, tx)?;
|
||||
}
|
||||
}
|
||||
if !buf.trim().is_empty() {
|
||||
process_frame(&buf, &mut state, tx)?;
|
||||
}
|
||||
|
||||
Ok(state.finish())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct StreamState {
|
||||
content: String,
|
||||
reasoning: String,
|
||||
reasoning_field: Option<String>,
|
||||
partials: BTreeMap<String, PartialFunctionCall>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
fn finish(self) -> CompletionResult {
|
||||
let tool_calls = self
|
||||
.partials
|
||||
.into_iter()
|
||||
.filter_map(|(key, partial)| {
|
||||
let name = partial.name?;
|
||||
let id = partial.call_id.unwrap_or(key);
|
||||
let arguments = serde_json::from_str(&partial.arguments)
|
||||
.unwrap_or_else(|_| json!({"_raw": partial.arguments}));
|
||||
Some(StoredToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
CompletionResult {
|
||||
content: self.content,
|
||||
reasoning: self.reasoning,
|
||||
reasoning_field: self.reasoning_field,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn responses_body(
|
||||
model: &str,
|
||||
messages: Vec<ModelMessage>,
|
||||
tools: Vec<ToolSpec>,
|
||||
reasoning_effort: ReasoningEffort,
|
||||
) -> Value {
|
||||
let mut instructions = Vec::new();
|
||||
let mut input = Vec::new();
|
||||
for message in messages {
|
||||
match message {
|
||||
ModelMessage::System { content } => instructions.push(content),
|
||||
ModelMessage::User { content } => input.push(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": content}]
|
||||
})),
|
||||
ModelMessage::Assistant {
|
||||
content,
|
||||
reasoning: _,
|
||||
reasoning_field: _,
|
||||
tool_calls,
|
||||
} => {
|
||||
if !content.is_empty() {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": content}]
|
||||
}));
|
||||
}
|
||||
for call in tool_calls {
|
||||
input.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": call.id,
|
||||
"name": call.name,
|
||||
"arguments": call.arguments.to_string()
|
||||
}));
|
||||
}
|
||||
}
|
||||
ModelMessage::Tool {
|
||||
tool_call_id,
|
||||
name: _,
|
||||
content,
|
||||
} => input.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_call_id,
|
||||
"output": content
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
let mut body = json!({
|
||||
"model": model,
|
||||
"input": input,
|
||||
"tools": tools_to_responses(tools),
|
||||
"stream": true,
|
||||
"store": false
|
||||
});
|
||||
if !instructions.is_empty() {
|
||||
body["instructions"] = Value::String(instructions.join("\n\n"));
|
||||
}
|
||||
if let Some(effort) = reasoning_effort.request_value() {
|
||||
body["reasoning"] = json!({"effort": effort, "summary": "auto"});
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn tools_to_responses(tools: Vec<ToolSpec>) -> Vec<Value> {
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn process_frame(
|
||||
frame: &str,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<()> {
|
||||
for line in frame.lines() {
|
||||
let line = line.trim();
|
||||
if !line.starts_with("data:") {
|
||||
continue;
|
||||
}
|
||||
let data = line.trim_start_matches("data:").trim();
|
||||
if data == "[DONE]" || data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value: Value = serde_json::from_str(data)?;
|
||||
handle_event(&value, state, tx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(
|
||||
value: &Value,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<()> {
|
||||
let event_type = value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
match event_type {
|
||||
"response.output_text.delta" | "response.message.delta" | "output_text.delta" => {
|
||||
if let Some(delta) = string_field(value, &["delta", "text"]) {
|
||||
push_content(state, tx, delta);
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_text.delta"
|
||||
| "response.reasoning_text.delta"
|
||||
| "response.reasoning.delta"
|
||||
| "reasoning.delta" => {
|
||||
if let Some(delta) = string_field(value, &["delta", "text"]) {
|
||||
push_reasoning(state, tx, "reasoning_summary", delta);
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.delta" | "function_call_arguments.delta" => {
|
||||
let key = event_key(value);
|
||||
let partial = state.partials.entry(key).or_default();
|
||||
if let Some(delta) = string_field(value, &["delta", "arguments_delta"]) {
|
||||
partial.arguments.push_str(delta);
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.done" | "function_call_arguments.done" => {
|
||||
let key = event_key(value);
|
||||
let partial = state.partials.entry(key).or_default();
|
||||
if let Some(arguments) = string_field(value, &["arguments"]) {
|
||||
partial.arguments = arguments.to_string();
|
||||
}
|
||||
if let Some(call_id) = string_field(value, &["call_id", "id"]) {
|
||||
partial.call_id = Some(call_id.to_string());
|
||||
}
|
||||
if let Some(name) = string_field(value, &["name"]) {
|
||||
partial.name = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
"response.output_item.added"
|
||||
| "response.output_item.done"
|
||||
| "output_item.added"
|
||||
| "output_item.done" => {
|
||||
if let Some(item) = value.get("item") {
|
||||
handle_item(item, state, tx, event_type.ends_with("done"));
|
||||
}
|
||||
}
|
||||
"response.completed" | "response.done" => {
|
||||
if state.content.is_empty() {
|
||||
if let Some(response) = value.get("response") {
|
||||
extract_final_response(response, state, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(item) = value.get("item") {
|
||||
handle_item(item, state, tx, false);
|
||||
} else if let Some(delta) = value
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|_| event_type.contains("output_text"))
|
||||
{
|
||||
push_content(state, tx, delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_item(
|
||||
item: &Value,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
final_item: bool,
|
||||
) {
|
||||
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"message" => {
|
||||
if final_item && state.content.is_empty() {
|
||||
for content in item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if matches!(
|
||||
content.get("type").and_then(Value::as_str),
|
||||
Some("output_text")
|
||||
) {
|
||||
if let Some(text) = content.get("text").and_then(Value::as_str) {
|
||||
push_content(state, tx, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"reasoning" => {
|
||||
for summary in item
|
||||
.get("summary")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(text) = summary.get("text").and_then(Value::as_str) {
|
||||
push_reasoning(state, tx, "reasoning_summary", text);
|
||||
}
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let key = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.or_else(|| item.get("item_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("call")
|
||||
.to_string();
|
||||
let partial = state.partials.entry(key.clone()).or_default();
|
||||
if let Some(call_id) = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
partial.call_id = Some(call_id.to_string());
|
||||
}
|
||||
if let Some(name) = item.get("name").and_then(Value::as_str) {
|
||||
partial.name = Some(name.to_string());
|
||||
}
|
||||
if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
|
||||
partial.arguments = arguments.to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_final_response(
|
||||
response: &Value,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) {
|
||||
for item in response
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
handle_item(item, state, tx, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_content(state: &mut StreamState, tx: &mpsc::UnboundedSender<AgentEvent>, text: &str) {
|
||||
state.content.push_str(text);
|
||||
let _ = tx.send(AgentEvent::AssistantChunk(text.to_string()));
|
||||
}
|
||||
|
||||
fn push_reasoning(
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
field: &'static str,
|
||||
text: &str,
|
||||
) {
|
||||
state
|
||||
.reasoning_field
|
||||
.get_or_insert_with(|| field.to_string());
|
||||
state.reasoning.push_str(text);
|
||||
let _ = tx.send(AgentEvent::ReasoningChunk(text.to_string()));
|
||||
}
|
||||
|
||||
fn string_field<'a>(value: &'a Value, fields: &[&str]) -> Option<&'a str> {
|
||||
fields.iter().find_map(|field| value.get(*field)?.as_str())
|
||||
}
|
||||
|
||||
fn event_key(value: &Value) -> String {
|
||||
string_field(
|
||||
value,
|
||||
&["call_id", "item_id", "output_item_id", "id", "output_index"],
|
||||
)
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "call".to_string())
|
||||
}
|
||||
|
||||
fn normalize_endpoint(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.is_empty() {
|
||||
CHATGPT_CODEX_RESPONSES_URL.to_string()
|
||||
} else {
|
||||
endpoint.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_secret(text: &str, secret: &str) -> String {
|
||||
if secret.is_empty() {
|
||||
text.to_string()
|
||||
} else {
|
||||
text.replace(secret, "<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn responses_body_uses_function_call_items() {
|
||||
let body = responses_body(
|
||||
"gpt-test",
|
||||
vec![
|
||||
ModelMessage::System {
|
||||
content: "system".into(),
|
||||
},
|
||||
ModelMessage::User {
|
||||
content: "hello".into(),
|
||||
},
|
||||
ModelMessage::Assistant {
|
||||
content: String::new(),
|
||||
reasoning: String::new(),
|
||||
reasoning_field: None,
|
||||
tool_calls: vec![StoredToolCall {
|
||||
id: "call_1".into(),
|
||||
name: "read".into(),
|
||||
arguments: json!({"path":"README.md"}),
|
||||
}],
|
||||
},
|
||||
ModelMessage::Tool {
|
||||
tool_call_id: "call_1".into(),
|
||||
name: "read".into(),
|
||||
content: "ok".into(),
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
ReasoningEffort::Off,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], "gpt-test");
|
||||
assert_eq!(body["instructions"], "system");
|
||||
assert!(body["input"].as_array().unwrap().iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parser_collects_text_and_function_call() {
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
let mut state = StreamState::default();
|
||||
process_frame(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}\n\n",
|
||||
&mut state,
|
||||
&tx,
|
||||
)
|
||||
.unwrap();
|
||||
let result = state.finish();
|
||||
|
||||
assert_eq!(result.content, "hi");
|
||||
assert_eq!(result.tool_calls.len(), 1);
|
||||
assert_eq!(result.tool_calls[0].id, "call_1");
|
||||
assert_eq!(result.tool_calls[0].name, "read");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,62 @@
|
||||
pub mod chatgpt_codex;
|
||||
pub mod openai_compatible;
|
||||
pub mod types;
|
||||
|
||||
use crate::agent::AgentEvent;
|
||||
use crate::config::{Config, ReasoningEffort, CHATGPT_CODEX_PROVIDER_KIND, DEFAULT_PROVIDER_KIND};
|
||||
use crate::providers::chatgpt_codex::{ChatGptCodexProvider, ChatGptCodexSettings};
|
||||
use crate::providers::openai_compatible::{OpenAiCompatibleProvider, OpenAiCompatibleSettings};
|
||||
use crate::providers::types::{CompletionResult, ModelMessage};
|
||||
use crate::tools::ToolSpec;
|
||||
use anyhow::{bail, Result};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProviderClient {
|
||||
OpenAiCompatible(OpenAiCompatibleProvider),
|
||||
ChatGptCodex(ChatGptCodexProvider),
|
||||
}
|
||||
|
||||
impl ProviderClient {
|
||||
pub fn from_config(config: &Config, reasoning_effort: ReasoningEffort) -> Result<Self> {
|
||||
match config.active_provider.kind.as_str() {
|
||||
DEFAULT_PROVIDER_KIND => {
|
||||
let api_key = config.resolved_api_key()?;
|
||||
let reasoning_request_format = config
|
||||
.model_metadata
|
||||
.as_ref()
|
||||
.map(|model| model.reasoning.request_format)
|
||||
.unwrap_or_default();
|
||||
Ok(Self::OpenAiCompatible(OpenAiCompatibleProvider::new(
|
||||
OpenAiCompatibleSettings {
|
||||
model: config.model.clone(),
|
||||
base_url: config.active_provider.base_url.clone(),
|
||||
api_key,
|
||||
reasoning_effort,
|
||||
reasoning_request_format,
|
||||
},
|
||||
)))
|
||||
}
|
||||
CHATGPT_CODEX_PROVIDER_KIND => Ok(Self::ChatGptCodex(ChatGptCodexProvider::new(
|
||||
ChatGptCodexSettings {
|
||||
model: config.model.clone(),
|
||||
endpoint: config.active_provider.base_url.clone(),
|
||||
reasoning_effort,
|
||||
},
|
||||
))),
|
||||
kind => bail!("unsupported provider kind `{kind}`"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
&self,
|
||||
messages: Vec<ModelMessage>,
|
||||
tools: Vec<ToolSpec>,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<CompletionResult> {
|
||||
match self {
|
||||
Self::OpenAiCompatible(provider) => provider.complete(messages, tools, tx).await,
|
||||
Self::ChatGptCodex(provider) => provider.complete(messages, tools, tx).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1105
File diff suppressed because it is too large
Load Diff
+81
-17
@@ -5,7 +5,7 @@ use crate::ui::theme;
|
||||
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Paragraph, Wrap};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
@@ -26,7 +26,20 @@ pub struct TranscriptBlock {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OverlayView {
|
||||
pub title: String,
|
||||
pub help: String,
|
||||
pub items: Vec<OverlayItem>,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OverlayItem {
|
||||
pub label: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
pub struct RenderState<'a> {
|
||||
pub app_name: &'a str,
|
||||
pub chat_id: &'a str,
|
||||
@@ -42,6 +55,7 @@ pub struct RenderState<'a> {
|
||||
pub reasoning_effort: ReasoningEffort,
|
||||
pub scroll: u16,
|
||||
pub autofill: Option<&'a AutoFillMenu>,
|
||||
pub overlay: Option<&'a OverlayView>,
|
||||
}
|
||||
|
||||
pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) {
|
||||
@@ -74,6 +88,10 @@ pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) {
|
||||
|
||||
let footer = truncate_end(&footer_text(state), chunks[3].width as usize);
|
||||
f.render_widget(Paragraph::new(footer).style(theme::footer()), chunks[3]);
|
||||
|
||||
if let Some(overlay) = state.overlay {
|
||||
render_overlay(f, f.area(), overlay);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcript_area(area: Rect, input: &str) -> Rect {
|
||||
@@ -116,6 +134,61 @@ fn autofill_height(menu: Option<&AutoFillMenu>) -> u16 {
|
||||
menu.map(|menu| menu.items.len().min(6) as u16).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn render_overlay(f: &mut Frame<'_>, area: Rect, overlay: &OverlayView) {
|
||||
let max_width = area.width.max(1);
|
||||
let preferred_width = area.width.saturating_mul(4).saturating_div(5).max(40);
|
||||
let width = preferred_width.min(max_width);
|
||||
let max_height = area.height.saturating_sub(2).max(1);
|
||||
let preferred_height = (overlay.items.len() as u16 + 5).max(8);
|
||||
let height = preferred_height.min(max_height);
|
||||
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||
let rect = Rect::new(x, y, width, height);
|
||||
f.render_widget(Clear, rect);
|
||||
let block = Block::default()
|
||||
.title(overlay.title.clone())
|
||||
.borders(Borders::ALL)
|
||||
.style(theme::menu());
|
||||
let inner = block.inner(rect);
|
||||
f.render_widget(block, rect);
|
||||
|
||||
let visible = inner.height.saturating_sub(2) as usize;
|
||||
let selected = overlay.selected.min(overlay.items.len().saturating_sub(1));
|
||||
let start = if selected >= visible && visible > 0 {
|
||||
selected + 1 - visible
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = (start + visible).min(overlay.items.len());
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::styled(
|
||||
truncate_end(&overlay.help, inner.width as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
for idx in start..end {
|
||||
let item = &overlay.items[idx];
|
||||
let marker = if idx == selected { "›" } else { " " };
|
||||
let mut text = format!("{marker} {}", item.label);
|
||||
if !item.detail.is_empty() {
|
||||
text.push_str(" ");
|
||||
text.push_str(&item.detail);
|
||||
}
|
||||
let style = if idx == selected {
|
||||
theme::selection()
|
||||
} else {
|
||||
theme::menu()
|
||||
};
|
||||
lines.push(Line::styled(
|
||||
truncate_end(&text, inner.width as usize),
|
||||
style,
|
||||
));
|
||||
}
|
||||
f.render_widget(
|
||||
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_autofill_menu(f: &mut Frame<'_>, area: Rect, menu: &AutoFillMenu) {
|
||||
if area.height == 0 || menu.items.is_empty() {
|
||||
return;
|
||||
@@ -165,10 +238,6 @@ fn transcript_lines_from(
|
||||
continue;
|
||||
}
|
||||
|
||||
if should_hide_collapsed_tool_block(block, show_full_tools) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = style_for(&block.kind);
|
||||
lines.push(Line::styled(display_heading(block, show_full_tools), style));
|
||||
|
||||
@@ -595,13 +664,6 @@ fn display_content(block: &TranscriptBlock, show_full_tools: bool, show_reasonin
|
||||
}
|
||||
}
|
||||
|
||||
fn should_hide_collapsed_tool_block(block: &TranscriptBlock, show_full_tools: bool) -> bool {
|
||||
!show_full_tools
|
||||
&& matches!(block.kind, TranscriptKind::Tool)
|
||||
&& !is_live_tool_output(block)
|
||||
&& block.title.starts_with("ls ✓")
|
||||
}
|
||||
|
||||
fn is_collapsed_successful_tool_result(block: &TranscriptBlock, show_full_tools: bool) -> bool {
|
||||
!show_full_tools
|
||||
&& matches!(block.kind, TranscriptKind::Tool)
|
||||
@@ -837,17 +899,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_ls_is_hidden_when_tools_are_collapsed() {
|
||||
fn successful_ls_shows_summary_when_tools_are_collapsed() {
|
||||
let transcript = vec![TranscriptBlock {
|
||||
kind: TranscriptKind::Tool,
|
||||
title: "ls ✓ (call_1)".into(),
|
||||
content: "file1\nfile2".into(),
|
||||
}];
|
||||
|
||||
let text = rendered_text(&transcript_lines_from(&transcript, false, false));
|
||||
let rendered = transcript_lines_from(&transcript, false, false);
|
||||
let text = rendered_text(&rendered);
|
||||
|
||||
assert!(!text.contains("ls ✓"));
|
||||
assert!(text.contains("Ask a question"));
|
||||
assert_eq!(rendered.len(), 2); // heading plus spacer
|
||||
assert!(text.contains("ls ✓ (call_1) · 2 lines"));
|
||||
assert!(!text.contains("file1\nfile2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,3 +27,26 @@ pub fn leave(mut terminal: CassTerminal) -> Result<()> {
|
||||
terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn suspend(terminal: &mut CassTerminal) -> Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resume(terminal: &mut CassTerminal) -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture
|
||||
)?;
|
||||
terminal.clear()?;
|
||||
terminal.hide_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+1255
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,37 @@ fn reasoning_defaults_to_supported_medium_for_model_metadata() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_accepts_chatgpt_codex_without_api_key() {
|
||||
let providers = ProvidersFile {
|
||||
providers: vec![ProviderDefinition {
|
||||
id: config::CHATGPT_CODEX_PROVIDER_ID.into(),
|
||||
name: Some(config::CHATGPT_CODEX_PROVIDER_NAME.into()),
|
||||
kind: config::CHATGPT_CODEX_PROVIDER_KIND.into(),
|
||||
base_url: config::CHATGPT_CODEX_RESPONSES_URL.into(),
|
||||
api_key: String::new(),
|
||||
default_model: Some(config::CHATGPT_CODEX_DEFAULT_MODEL.into()),
|
||||
models: vec![config::CHATGPT_CODEX_DEFAULT_MODEL.into()],
|
||||
}],
|
||||
};
|
||||
let models = ModelsFile {
|
||||
models: vec![config::ModelDefinition {
|
||||
id: config::CHATGPT_CODEX_DEFAULT_MODEL.into(),
|
||||
provider: config::CHATGPT_CODEX_PROVIDER_ID.into(),
|
||||
display_name: None,
|
||||
context_length: None,
|
||||
max_output_tokens: None,
|
||||
supports_tools: true,
|
||||
supports_streaming: true,
|
||||
reasoning: Default::default(),
|
||||
}],
|
||||
};
|
||||
|
||||
let summary = config::validate_registries(None, &providers, &models);
|
||||
|
||||
assert!(summary.errors.is_empty(), "{:?}", summary.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_duplicate_provider_ids() {
|
||||
let providers = ProvidersFile {
|
||||
|
||||
@@ -23,3 +23,22 @@ fn conversation_appends_loads_and_lists_by_cwd() {
|
||||
assert_eq!(chats[0].id, convo.id);
|
||||
assert_eq!(chats[0].first_user_preview, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_meta_without_branch_fields_still_loads() {
|
||||
let root = tempdir().unwrap();
|
||||
let id = "legacy";
|
||||
std::fs::write(
|
||||
root.path().join(format!("{id}.jsonl")),
|
||||
r#"{"type":"meta","chat_id":"legacy","created_at":"now","model":"m","cwd":"/tmp"}
|
||||
{"type":"system","content":"base"}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (loaded, warning) = Conversation::load(root.path(), id).unwrap();
|
||||
assert!(warning.is_none());
|
||||
let meta = loaded.meta().unwrap();
|
||||
assert_eq!(meta.parent_chat_id, None);
|
||||
assert_eq!(meta.branch_from, None);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -18,3 +19,72 @@ fn install_extracts_bundled_docs_with_stamp() {
|
||||
let second_install = cassady::docs::install(root.path()).unwrap();
|
||||
assert_eq!(second_install, docs_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_docs_links_resolve() {
|
||||
let docs_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
|
||||
|
||||
for entry in std::fs::read_dir(&docs_root).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
for target in markdown_links(&text) {
|
||||
if target.starts_with("http://")
|
||||
|| target.starts_with("https://")
|
||||
|| target.starts_with('#')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let target_path = target.split('#').next().unwrap_or(target.as_str());
|
||||
if target_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
docs_root.join(target_path).is_file(),
|
||||
"{} links to missing bundled doc: {target}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_bundled_docs_exist() {
|
||||
let docs_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs");
|
||||
for file in [
|
||||
"README.md",
|
||||
"commands.md",
|
||||
"configuration.md",
|
||||
"providers.md",
|
||||
"access-modes.md",
|
||||
"embedding.md",
|
||||
"workflows.md",
|
||||
"troubleshooting.md",
|
||||
"platforms.md",
|
||||
"glossary.md",
|
||||
] {
|
||||
assert!(docs_root.join(file).is_file(), "missing docs/{file}");
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown_links(text: &str) -> Vec<String> {
|
||||
let mut links = Vec::new();
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'[' {
|
||||
if let Some(close) = text[i..].find("](") {
|
||||
let start = i + close + 2;
|
||||
if let Some(end) = text[start..].find(')') {
|
||||
links.push(text[start..start + end].to_string());
|
||||
i = start + end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use cassady::access::AccessMode;
|
||||
use cassady::prompt::{build_base_system_prompt, build_effective_system_prompt};
|
||||
use std::path::Path;
|
||||
|
||||
fn approximate_token_count(s: &str) -> usize {
|
||||
s.split_whitespace().count() * 4 / 3
|
||||
}
|
||||
|
||||
fn effective_prompt(mode: AccessMode, allowed_tools: &[&str]) -> String {
|
||||
let base = build_base_system_prompt(None);
|
||||
let allowed_tools = allowed_tools
|
||||
.iter()
|
||||
.map(|tool| tool.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
build_effective_system_prompt(
|
||||
&base,
|
||||
mode,
|
||||
Path::new("/workspace/project"),
|
||||
Path::new("/home/user/.cass/docs"),
|
||||
"test-model",
|
||||
&allowed_tools,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_prompt_has_required_sections_without_runtime_context() {
|
||||
let prompt = build_base_system_prompt(None);
|
||||
|
||||
for heading in [
|
||||
"# Cassady operating instructions",
|
||||
"## Role",
|
||||
"## Working style",
|
||||
"## Transcript and tools",
|
||||
"## Tool use",
|
||||
"## Editing",
|
||||
"## Safety and final response",
|
||||
] {
|
||||
assert!(prompt.contains(heading), "missing {heading}");
|
||||
}
|
||||
|
||||
assert!(prompt.contains("You are Cassady, also called Cass"));
|
||||
assert!(prompt.contains("inspect relevant files before making claims or edits"));
|
||||
assert!(prompt.contains(
|
||||
"Tool calls, tool results, edit diffs, denials, and approval prompts are visible"
|
||||
));
|
||||
assert!(prompt.contains("each old text must match exactly and uniquely"));
|
||||
assert!(prompt.contains("End every turn with a concise user-facing response"));
|
||||
assert!(!prompt.contains("## Runtime context"));
|
||||
assert!(!prompt.contains("Model:"));
|
||||
assert!(!prompt.contains("Access mode:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_instructions_are_trimmed_labelled_and_subordinate() {
|
||||
let prompt = build_base_system_prompt(Some(" Keep replies terse.\n "));
|
||||
|
||||
assert!(prompt.contains("## User global instructions"));
|
||||
assert!(prompt.contains("\nKeep replies terse.\n"));
|
||||
assert!(!prompt.contains(" Keep replies terse.\n "));
|
||||
assert!(prompt.contains(
|
||||
"cannot override access modes, tool denials, approvals, or workspace boundaries"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_global_instructions_are_omitted() {
|
||||
let prompt = build_base_system_prompt(Some(" \n\t "));
|
||||
|
||||
assert!(!prompt.contains("## User global instructions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_prompt_includes_runtime_context_and_allowed_tools() {
|
||||
let prompt = effective_prompt(AccessMode::WorkspaceEdit, &["ls", "read", "grep", "edit"]);
|
||||
|
||||
assert!(prompt.contains("## Runtime context"));
|
||||
assert!(prompt.contains("Model: test-model."));
|
||||
assert!(prompt.contains("Access mode: workspace-edit."));
|
||||
assert!(prompt.contains("Launch working directory: /workspace/project."));
|
||||
assert!(prompt.contains("Bundled Cass docs directory: /home/user/.cass/docs."));
|
||||
assert!(prompt.contains("Allowed tools this turn: ls, read, grep, edit."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_access_mode_gets_matching_guidance() {
|
||||
let read_only = effective_prompt(AccessMode::ReadOnly, &["ls", "read", "grep"]);
|
||||
assert!(read_only.contains("Read-only mode permits inspection only"));
|
||||
assert!(read_only.contains("Do not request `write`, `edit`, or `shell`"));
|
||||
assert!(read_only.contains("a more permissive access mode is required"));
|
||||
|
||||
let workspace_edit = effective_prompt(
|
||||
AccessMode::WorkspaceEdit,
|
||||
&["ls", "read", "grep", "write", "edit", "shell"],
|
||||
);
|
||||
assert!(workspace_edit.contains("Write/edit only inside the launch workspace"));
|
||||
assert!(workspace_edit.contains("bundled docs remain read-only"));
|
||||
assert!(workspace_edit.contains("Cassady handles the approval UI"));
|
||||
assert!(workspace_edit.contains("do not ask for shell permission in chat first"));
|
||||
|
||||
let full_access = effective_prompt(
|
||||
AccessMode::FullAccess,
|
||||
&["ls", "read", "grep", "write", "edit", "shell"],
|
||||
);
|
||||
assert!(full_access
|
||||
.contains("Full-access mode permits `ls`, `read`, `grep`, `write`, `edit`, and `shell`"));
|
||||
assert!(full_access.contains("Shell runs from the launch working directory"));
|
||||
assert!(full_access.contains("Bundled docs remain read-only for write/edit"));
|
||||
assert!(full_access
|
||||
.contains("avoid destructive commands unless the user explicitly requested them"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_constraints_stay_after_global_instructions() {
|
||||
let base = build_base_system_prompt(Some("Prefer bullet summaries."));
|
||||
let prompt = build_effective_system_prompt(
|
||||
&base,
|
||||
AccessMode::WorkspaceEdit,
|
||||
Path::new("/workspace/project"),
|
||||
Path::new("/home/user/.cass/docs"),
|
||||
"test-model",
|
||||
&["ls".into()],
|
||||
);
|
||||
|
||||
let global_index = prompt.find("Prefer bullet summaries.").unwrap();
|
||||
let runtime_index = prompt.find("## Runtime context").unwrap();
|
||||
let access_index = prompt.find("## Access rules for this session").unwrap();
|
||||
let authority_index = prompt.find("## Runtime authority").unwrap();
|
||||
|
||||
assert!(global_index < runtime_index);
|
||||
assert!(runtime_index < access_index);
|
||||
assert!(access_index < authority_index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_prompt_size_remains_intentional() {
|
||||
for mode in [
|
||||
AccessMode::ReadOnly,
|
||||
AccessMode::WorkspaceEdit,
|
||||
AccessMode::FullAccess,
|
||||
] {
|
||||
let prompt = effective_prompt(mode, &["ls", "read", "grep", "write", "edit", "shell"]);
|
||||
let tokens = approximate_token_count(&prompt);
|
||||
assert!(
|
||||
(800..=1250).contains(&tokens),
|
||||
"{mode} prompt had {tokens} approximate tokens"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_allowed_tools_list_is_explicit() {
|
||||
let prompt = effective_prompt(AccessMode::ReadOnly, &[]);
|
||||
|
||||
assert!(prompt.contains("Allowed tools this turn: <none>."));
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
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_providers() {
|
||||
let catalog = setup::provider_catalog();
|
||||
let ids: Vec<_> = catalog.iter().map(|entry| entry.id).collect();
|
||||
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"openai",
|
||||
"chatgpt-codex",
|
||||
"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_setup_writes_chatgpt_codex_without_api_key() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
setup::apply_setup(
|
||||
root.path(),
|
||||
&SetupSelection {
|
||||
provider_id: config::CHATGPT_CODEX_PROVIDER_ID.into(),
|
||||
provider_name: config::CHATGPT_CODEX_PROVIDER_NAME.into(),
|
||||
base_url: config::CHATGPT_CODEX_RESPONSES_URL.into(),
|
||||
api_key_env: String::new(),
|
||||
model_id: config::CHATGPT_CODEX_DEFAULT_MODEL.into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let providers: ProvidersFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
|
||||
.unwrap();
|
||||
let provider = &providers.providers[0];
|
||||
assert_eq!(provider.id, config::CHATGPT_CODEX_PROVIDER_ID);
|
||||
assert_eq!(provider.kind, config::CHATGPT_CODEX_PROVIDER_KIND);
|
||||
assert!(provider.api_key.is_empty());
|
||||
}
|
||||
|
||||
#[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 remove_providers_removes_models_and_preserves_active_provider() {
|
||||
let root = tempdir().unwrap();
|
||||
let selections = vec![
|
||||
SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
SetupSelection {
|
||||
provider_id: "groq".into(),
|
||||
provider_name: "Groq".into(),
|
||||
base_url: "https://api.groq.com/openai/v1".into(),
|
||||
api_key_env: "GROQ_API_KEY".into(),
|
||||
model_id: "llama-3.3-70b-versatile".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
setup::apply_setups(root.path(), &selections, 0).unwrap();
|
||||
|
||||
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(result.removed_provider_ids, vec!["groq"]);
|
||||
assert_eq!(result.removed_model_count, 1);
|
||||
assert_eq!(result.remaining_provider_count, 1);
|
||||
assert_eq!(result.active_provider.as_deref(), Some("openai"));
|
||||
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
|
||||
|
||||
let providers: ProvidersFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(providers.providers.len(), 1);
|
||||
assert_eq!(providers.providers[0].id, "openai");
|
||||
|
||||
let models: ModelsFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(models.models.len(), 1);
|
||||
assert_eq!(models.models[0].provider, "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_active_provider_selects_remaining_provider_and_model() {
|
||||
let root = tempdir().unwrap();
|
||||
let selections = vec![
|
||||
SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
SetupSelection {
|
||||
provider_id: "groq".into(),
|
||||
provider_name: "Groq".into(),
|
||||
base_url: "https://api.groq.com/openai/v1".into(),
|
||||
api_key_env: "GROQ_API_KEY".into(),
|
||||
model_id: "llama-3.3-70b-versatile".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
setup::apply_setups(root.path(), &selections, 1).unwrap();
|
||||
|
||||
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(result.active_provider.as_deref(), Some("openai"));
|
||||
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
|
||||
let config: ConfigFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(config.default_provider.as_deref(), Some("openai"));
|
||||
assert_eq!(config.default_model.as_deref(), Some("gpt-4.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_all_providers_clears_active_defaults() {
|
||||
let root = tempdir().unwrap();
|
||||
setup::apply_setup(
|
||||
root.path(),
|
||||
&SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = setup::remove_providers(root.path(), &["openai".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(result.remaining_provider_count, 0);
|
||||
assert!(result.active_provider.is_none());
|
||||
assert!(result.active_model.is_none());
|
||||
|
||||
let config: ConfigFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(config.default_provider.is_none());
|
||||
assert!(config.default_model.is_none());
|
||||
|
||||
let models: ModelsFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(models.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_unknown_provider_fails() {
|
||||
let root = tempdir().unwrap();
|
||||
setup::apply_setup(
|
||||
root.path(),
|
||||
&SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = setup::remove_providers(root.path(), &["missing".to_string()]).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("provider `missing` is not configured"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_initial_setup_detects_empty_and_default_only_roots() {
|
||||
let root = tempdir().unwrap();
|
||||
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