mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
Initial scaffolding and docs
This commit is contained in:
parent
8a07524724
commit
d4268bd3cd
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug
|
||||||
|
target
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Generated by cargo mutants
|
||||||
|
# Contains mutation testing data
|
||||||
|
**/mutants.out*/
|
||||||
|
|
||||||
|
# rustc will dump stack traces when hitting an internal compiler error to PWD
|
||||||
|
rustc-ice-*.txt
|
||||||
|
|
||||||
|
.vs
|
||||||
|
docs/devlog/*
|
||||||
|
docs/tasks
|
||||||
|
docs/questions.md
|
||||||
|
|
||||||
|
# RustRover
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Instructions for AI agents contributing to Exoshell.
|
||||||
|
|
||||||
|
## Engineering
|
||||||
|
|
||||||
|
* Prefer the simplest solution that satisfies the requirement.
|
||||||
|
* Do not implement speculative future requirements.
|
||||||
|
* Extend existing systems before creating new ones.
|
||||||
|
* Determine whether an existing implementation can be extended before introducing a new subsystem, abstraction, or dependency.
|
||||||
|
* Always defer to the user when requirements are unclear or tradeoffs are uncertain.
|
||||||
|
* New dependencies require justification.
|
||||||
|
* Never duplicate functionality unless doing so avoids unnecessary dependency complexity.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
* Use direct, humble technical language, Write like an engineer, not a marketer.
|
||||||
|
* Be accurate and honest about capabilities and limitations.
|
||||||
|
* Record known bugs, limitations, and technical debt in `docs/BUGS.md`.
|
||||||
|
* If there is information worth communicating after completing work, append it to `docs/devlog/YYYY-MM-DD.md`.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
* Bump the version for any user-visible change or major feature.
|
||||||
|
* Follow `docs/versioning.md`.
|
||||||
|
|
||||||
|
## Source of Truth
|
||||||
|
Review before significant changes:
|
||||||
|
|
||||||
|
* `docs/DESIGN.md`
|
||||||
|
* `docs/PHASES.md`
|
||||||
|
* `docs/tasks/*`
|
||||||
|
* `docs/versioning.md`
|
||||||
|
|
||||||
|
If instructions conflict:
|
||||||
|
|
||||||
|
1. AGENTS.md
|
||||||
|
2. Documentation
|
||||||
|
3. Code
|
||||||
1792
Cargo.lock
generated
Normal file
1792
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "exoshell"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||||
|
toml = "0.9"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
0
docs/BUGS.md
Normal file
0
docs/BUGS.md
Normal file
161
docs/PHASES.md
Normal file
161
docs/PHASES.md
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Exoshell Phases
|
||||||
|
|
||||||
|
This roadmap turns the project vision into concrete build phases. The phases are intentionally ordered from the smallest useful shell-adjacent assistant toward the broader cognitive shell described in `README.md` and `docs/DESIGN.md`.
|
||||||
|
|
||||||
|
## Phase 1: Shell-Adjacent Model Chat
|
||||||
|
|
||||||
|
Goal: ship a usable terminal program that lets a user talk to a model from PowerShell, bash, zsh, and similar shells without replacing the shell.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- CLI entry point, likely `exoshell`, that runs as a normal terminal application.
|
||||||
|
- Provider configuration for at least one OpenAI-compatible backend.
|
||||||
|
- Local-first-compatible model adapter interface, even if the first implementation targets one backend.
|
||||||
|
- Interactive chat loop with streaming responses.
|
||||||
|
- Basic command suggestion formatting that distinguishes prose, shell commands, and warnings.
|
||||||
|
- Explicit copy/paste-oriented workflow: Exoshell suggests commands, the user remains responsible for execution.
|
||||||
|
- Cross-platform terminal behavior on Windows, macOS, and Linux.
|
||||||
|
- Shell-family awareness for PowerShell versus POSIX-like shells.
|
||||||
|
- Basic session transcript saved as markdown.
|
||||||
|
- Minimal config file for provider, model, default shell family, and notebook location.
|
||||||
|
- Clear error handling for missing API keys, provider failures, and interrupted requests.
|
||||||
|
|
||||||
|
User experience:
|
||||||
|
- User should be able to run "exo this is a prompt" and get a response, the program exiting
|
||||||
|
- User should be able to run exo (without a prompt) and have an open session, or "exo this is a prompt --stay" (or a more appropriate flag) to keep the shell/conversation open
|
||||||
|
- User should be able to pipe (bash) or powershell equivalent into exo and have it treated as a prompt, with the additional context of the command run that sent the pipe, cwd, etc.
|
||||||
|
- For example, "ls | exo" should send a prompt similar to "{"command": "ls", "stdout": "foo bar baz", "cwd": "/foo/bar"}" and any relevant system prompts
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- PTY control of the user's shell.
|
||||||
|
- Hotkeys that inject commands into the shell.
|
||||||
|
- Ambient context collection.
|
||||||
|
- Tree-sitter repo indexing.
|
||||||
|
- Long-term memory.
|
||||||
|
- Stances and personalities beyond a fixed default behavior.
|
||||||
|
- Autonomous command execution.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- A user can install/run Exoshell, configure a model backend, ask for help from a terminal, receive command suggestions appropriate to PowerShell or bash/zsh, and save the session transcript.
|
||||||
|
- The implementation has a small automated test suite for provider abstraction, shell-family prompt behavior, config loading, and transcript writing.
|
||||||
|
|
||||||
|
## Phase 2: Context, Modes, and Operator Controls
|
||||||
|
|
||||||
|
Goal: make Exoshell feel like an operational overlay instead of a generic chat client.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- Explicit context commands for adding files, command output, directory summaries, and pasted logs.
|
||||||
|
- Session-scoped context panel or context summary visible in the TUI.
|
||||||
|
- Stances such as `operator`, `audit`, `teach`, and `quiet`.
|
||||||
|
- Hotkeys for accepting, copying, explaining, and discarding suggested commands.
|
||||||
|
- Safer command suggestion UI with destructive-command detection and confirmation language.
|
||||||
|
- Context budget management with visible token or character estimates.
|
||||||
|
- Shell-specific command rendering and explanation for PowerShell and POSIX-like shells.
|
||||||
|
- Notebook improvements: title, timestamps, model metadata, commands suggested, and user-marked discoveries.
|
||||||
|
- Basic configuration profiles for different providers and shells.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- Passive screen watching.
|
||||||
|
- Full PTY embedding.
|
||||||
|
- Repository-wide semantic indexing.
|
||||||
|
- Long-term memory outside user-controlled markdown/log files.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- A user can deliberately attach context, switch stance, inspect what context is being sent, and act on suggestions through predictable terminal controls.
|
||||||
|
|
||||||
|
## Phase 3: Repo Awareness and Operational Notebooks
|
||||||
|
|
||||||
|
Goal: give Exoshell useful project awareness while keeping memory inspectable and scoped.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- Repo detection and project-root selection.
|
||||||
|
- File tree summaries with ignore rules.
|
||||||
|
- Structural parsing for common languages using tree-sitter where practical.
|
||||||
|
- Commands to add source files, symbols, recent git changes, and test output to context.
|
||||||
|
- Markdown notebooks scoped by global, repo, task, and session.
|
||||||
|
- Searchable operational notes and discoveries.
|
||||||
|
- Runbook generation from session notes.
|
||||||
|
- Diff-oriented patch suggestions that require user review.
|
||||||
|
- Better uncertainty reporting through explicit signal strength.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- Hidden long-term behavioral profiling.
|
||||||
|
- Blind patch application.
|
||||||
|
- Autonomous multi-file rewrites.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- A user can open a repo, ask informed questions about local code and recent command output, preserve useful notes, and generate reviewable runbooks or patch suggestions.
|
||||||
|
|
||||||
|
## Phase 4: Follow Mode and Shell Integration
|
||||||
|
|
||||||
|
Goal: let Exoshell observe explicit shell activity and provide contextual help without becoming a replacement shell.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- PTY or shell-hook integration strategy for supported environments.
|
||||||
|
- Follow mode that can ingest selected command history, stdout/stderr snippets, current directory, and exit status.
|
||||||
|
- User-visible controls for what is observed and retained.
|
||||||
|
- Hotkey workflows for sending command output to Exoshell.
|
||||||
|
- Optional paste-to-shell and paste-and-run flows with clear confirmation boundaries.
|
||||||
|
- Command outcome interpretation and next-step suggestions.
|
||||||
|
- Per-shell integration docs for PowerShell, bash, zsh, and fish.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- Covert monitoring.
|
||||||
|
- Unreviewed execution.
|
||||||
|
- Replacing the user's shell configuration.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- A user can work in their normal shell while Exoshell follows explicitly permitted context and offers useful, nonintrusive operational guidance.
|
||||||
|
|
||||||
|
## Phase 5: Tuning, Extensibility, and Local-First Depth
|
||||||
|
|
||||||
|
Goal: make Exoshell configurable, hackable, and effective with weaker local models.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- Robust local model support through OpenAI-compatible local servers and/or dedicated adapters.
|
||||||
|
- Prompt and stance customization.
|
||||||
|
- User-editable command policies and safety rules.
|
||||||
|
- Extension points for tools, context providers, and notebook processors.
|
||||||
|
- Model routing by task type.
|
||||||
|
- Context compression and retrieval tuned for smaller models.
|
||||||
|
- Export/import of user configuration and notebooks.
|
||||||
|
- Advanced visual themes that remain restrained and terminal-native.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- Cloud lock-in.
|
||||||
|
- Manager analytics.
|
||||||
|
- Personality behavior that compromises operational clarity.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- A technical user can tune Exoshell to their environment, run local-first workflows, and extend context/tool behavior without modifying core code.
|
||||||
|
|
||||||
|
## Phase 6: Mature Cognitive Shell Overlay
|
||||||
|
|
||||||
|
Goal: converge on the broader vision: a calm, inspectable, practitioner-oriented cognitive shell environment.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- Mature cockpit-style TUI with dense but readable operational instrumentation.
|
||||||
|
- Stable shell integrations across major platforms.
|
||||||
|
- High-quality repo, session, task, and notebook workflows.
|
||||||
|
- Reviewable command, patch, and runbook pipelines.
|
||||||
|
- Strong safety posture around destructive commands and hidden state.
|
||||||
|
- Comprehensive docs for installation, configuration, shell integration, model backends, stances, notebooks, and local-first operation.
|
||||||
|
- Packaging and release automation.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- Exoshell is a dependable daily-driver assistant for terminal-native practitioners who want AI augmentation while preserving control and understanding.
|
||||||
97
docs/phase1_run.md
Normal file
97
docs/phase1_run.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Phase 1 Local Run
|
||||||
|
|
||||||
|
Exoshell Phase 1 is a shell-adjacent model chat REPL. It suggests commands and explanations, but it does not execute commands.
|
||||||
|
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a config file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -- --config path/to/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Select the command dialect from the CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -- --shell powershell
|
||||||
|
cargo run -- --shell posix
|
||||||
|
```
|
||||||
|
|
||||||
|
Control transcript behavior:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -- --no-transcript
|
||||||
|
cargo run -- --transcript-dir transcripts
|
||||||
|
```
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[provider]
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
api_key_env = "OPENAI_API_KEY"
|
||||||
|
model = "gpt-4.1-mini"
|
||||||
|
|
||||||
|
[shell]
|
||||||
|
family = "powershell"
|
||||||
|
|
||||||
|
[transcript]
|
||||||
|
enabled = true
|
||||||
|
directory = "transcripts"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported shell families in this first pass are `powershell` and `posix`.
|
||||||
|
|
||||||
|
PowerShell example:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:OPENAI_API_KEY = "..."
|
||||||
|
cargo run -- --shell powershell
|
||||||
|
```
|
||||||
|
|
||||||
|
POSIX example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export OPENAI_API_KEY="..."
|
||||||
|
cargo run -- --shell posix
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the REPL:
|
||||||
|
|
||||||
|
- `/exit` quits.
|
||||||
|
- `/quit` quits.
|
||||||
|
- `/multi` starts multi-line input. Finish with a single `.` line.
|
||||||
|
|
||||||
|
Phase 1 behavior:
|
||||||
|
|
||||||
|
- Exoshell suggests commands, but does not execute them.
|
||||||
|
- Suggested command blocks are labeled for review in terminal output.
|
||||||
|
- Risky commands should be reviewed manually before use.
|
||||||
|
- Session transcripts are markdown files when transcripts are enabled.
|
||||||
|
- API keys are read from environment variables and are not written to transcripts.
|
||||||
|
|
||||||
|
Current limitations:
|
||||||
|
|
||||||
|
- No PTY integration.
|
||||||
|
- No hotkey command injection.
|
||||||
|
- No ambient context collection.
|
||||||
|
- No repo indexing.
|
||||||
|
- No autonomous execution.
|
||||||
|
|
||||||
|
Quality commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt
|
||||||
|
cargo test
|
||||||
|
cargo clippy --all-targets --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual startup smoke test on Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts\manual_phase1_startup.ps1
|
||||||
|
```
|
||||||
102
docs/versioning.md
Normal file
102
docs/versioning.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
Exoshell uses Semantic Versioning (SemVer) for all machine-readable version numbers.
|
||||||
|
|
||||||
|
Version numbers follow:
|
||||||
|
|
||||||
|
MAJOR.MINOR.PATCH
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
0.1.0
|
||||||
|
0.3.3
|
||||||
|
1.0.0
|
||||||
|
|
||||||
|
The numeric version is the authoritative version used by Cargo, Git tags, releases, package managers, automation, and compatibility decisions.
|
||||||
|
|
||||||
|
## Release Codenames
|
||||||
|
|
||||||
|
Each release also receives a human-readable codename.
|
||||||
|
|
||||||
|
Codenames exist for display purposes only and have no semantic meaning. They do not indicate compatibility, stability, feature scope, or release type.
|
||||||
|
|
||||||
|
Example display:
|
||||||
|
|
||||||
|
Exoshell v0.3.3
|
||||||
|
codename: packet-goblin
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
Exoshell 0.3.3 "Packet Goblin"
|
||||||
|
|
||||||
|
The codename should be shown in the UI wherever version information is displayed.
|
||||||
|
|
||||||
|
## Codename Generation
|
||||||
|
|
||||||
|
Release codenames should be generated using a constrained naming scheme.
|
||||||
|
|
||||||
|
Recommended format:
|
||||||
|
|
||||||
|
<tech-term>-<artifact>
|
||||||
|
|
||||||
|
Example tech terms:
|
||||||
|
|
||||||
|
kernel
|
||||||
|
packet
|
||||||
|
daemon
|
||||||
|
socket
|
||||||
|
cache
|
||||||
|
cipher
|
||||||
|
terminal
|
||||||
|
process
|
||||||
|
satellite
|
||||||
|
router
|
||||||
|
|
||||||
|
Example artifacts:
|
||||||
|
|
||||||
|
goblin
|
||||||
|
wizard
|
||||||
|
familiar
|
||||||
|
relic
|
||||||
|
oracle
|
||||||
|
ghost
|
||||||
|
hotdog
|
||||||
|
cult
|
||||||
|
seance
|
||||||
|
rat-king
|
||||||
|
|
||||||
|
The lists above are examples only.
|
||||||
|
|
||||||
|
Future releases may introduce additional terms as long as they remain consistent with the project's tone.
|
||||||
|
|
||||||
|
## Codename Rules
|
||||||
|
|
||||||
|
A codename must:
|
||||||
|
|
||||||
|
be lowercase
|
||||||
|
use hyphens as separators
|
||||||
|
be safe for public display
|
||||||
|
be memorable
|
||||||
|
be mildly absurd
|
||||||
|
be unique across all releases
|
||||||
|
|
||||||
|
A codename must not:
|
||||||
|
|
||||||
|
contain version numbers
|
||||||
|
imply compatibility guarantees
|
||||||
|
contain offensive or discriminatory language
|
||||||
|
be reused
|
||||||
|
|
||||||
|
## Naming Guidance
|
||||||
|
|
||||||
|
When creating a release:
|
||||||
|
|
||||||
|
Increment the semantic version according to SemVer rules.
|
||||||
|
Generate a new codename.
|
||||||
|
Verify that the codename has not been used previously.
|
||||||
|
Add the codename to release notes and changelog entries.
|
||||||
|
Preserve existing codenames in project history.
|
||||||
|
|
||||||
|
|
||||||
|
## Historical Codenames
|
||||||
|
Historical codenames should be tracked in docs/versioning.md below
|
||||||
|
|
||||||
|
* 0.1.0 packet-kobold
|
||||||
17
scripts/manual_phase1_startup.ps1
Normal file
17
scripts/manual_phase1_startup.ps1
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$env:OPENAI_API_KEY = "manual-startup-placeholder"
|
||||||
|
$configPath = Join-Path $env:TEMP "exoshell-manual-startup.toml"
|
||||||
|
@"
|
||||||
|
[provider]
|
||||||
|
api_key_env = "OPENAI_API_KEY"
|
||||||
|
model = "manual-startup-model"
|
||||||
|
|
||||||
|
[shell]
|
||||||
|
family = "powershell"
|
||||||
|
|
||||||
|
[transcript]
|
||||||
|
enabled = false
|
||||||
|
"@ | Set-Content -Path $configPath -Encoding utf8
|
||||||
|
|
||||||
|
"/exit" | cargo run -- --config $configPath
|
||||||
5
scripts/quality.ps1
Normal file
5
scripts/quality.ps1
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test
|
||||||
|
cargo clippy --all-targets --all-features
|
||||||
187
src/app.rs
Normal file
187
src/app.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::prompts::phase1_system_prompt;
|
||||||
|
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
|
||||||
|
use crate::repl::ReplError;
|
||||||
|
use crate::shell::ShellFamily;
|
||||||
|
use crate::transcripts::{Transcript, TranscriptError};
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
config: Config,
|
||||||
|
provider: Box<dyn Provider>,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
transcript: Transcript,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(config: Config, provider: Box<dyn Provider>) -> Self {
|
||||||
|
let transcript = Transcript::new(
|
||||||
|
"openai-compatible".into(),
|
||||||
|
config.provider.model.clone(),
|
||||||
|
config.shell.family,
|
||||||
|
);
|
||||||
|
let messages = vec![ChatMessage::new(
|
||||||
|
ChatRole::System,
|
||||||
|
phase1_system_prompt(config.shell.family),
|
||||||
|
)];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
provider,
|
||||||
|
messages,
|
||||||
|
transcript,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&mut self, input: String) -> Result<String, AppError> {
|
||||||
|
self.messages
|
||||||
|
.push(ChatMessage::new(ChatRole::User, input.clone()));
|
||||||
|
self.transcript.record_user(&input);
|
||||||
|
|
||||||
|
let request = ChatRequest {
|
||||||
|
messages: self.messages.clone(),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match self.provider.chat(request).await {
|
||||||
|
Ok(ChatResponse::Complete(response)) => response,
|
||||||
|
Ok(ChatResponse::Stream(chunks)) => chunks.concat(),
|
||||||
|
Err(error) => {
|
||||||
|
self.transcript.record_error(&error.to_string());
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.messages
|
||||||
|
.push(ChatMessage::new(ChatRole::Assistant, response.clone()));
|
||||||
|
self.transcript.record_assistant(&response);
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_transcript(&self) -> Result<Option<PathBuf>, AppError> {
|
||||||
|
if !self.config.transcript.enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = self
|
||||||
|
.transcript
|
||||||
|
.write_to_dir(&self.config.transcript.directory)?;
|
||||||
|
Ok(Some(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Config(#[from] crate::config::ConfigError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Provider(#[from] ProviderError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Repl(#[from] ReplError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Transcript(#[from] TranscriptError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct CliOptions {
|
||||||
|
pub config_path: Option<PathBuf>,
|
||||||
|
pub shell_family: Option<ShellFamily>,
|
||||||
|
pub transcript_enabled: Option<bool>,
|
||||||
|
pub transcript_directory: Option<PathBuf>,
|
||||||
|
pub no_color: bool,
|
||||||
|
pub show_help: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliOptions {
|
||||||
|
pub fn parse<I>(args: I) -> Result<Self, AppError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = String>,
|
||||||
|
{
|
||||||
|
let mut options = CliOptions::default();
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"-h" | "--help" => options.show_help = true,
|
||||||
|
"--config" => {
|
||||||
|
let value = args.next().ok_or_else(|| {
|
||||||
|
crate::config::ConfigError::Invalid("--config requires a path".into())
|
||||||
|
})?;
|
||||||
|
options.config_path = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--shell" => {
|
||||||
|
let value = args.next().ok_or_else(|| {
|
||||||
|
crate::config::ConfigError::Invalid("--shell requires a value".into())
|
||||||
|
})?;
|
||||||
|
options.shell_family = Some(value.parse().map_err(
|
||||||
|
|error: crate::shell::ShellFamilyError| {
|
||||||
|
crate::config::ConfigError::Invalid(error.to_string())
|
||||||
|
},
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
"--no-transcript" => options.transcript_enabled = Some(false),
|
||||||
|
"--transcript-dir" => {
|
||||||
|
let value = args.next().ok_or_else(|| {
|
||||||
|
crate::config::ConfigError::Invalid(
|
||||||
|
"--transcript-dir requires a path".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
options.transcript_directory = Some(PathBuf::from(value));
|
||||||
|
options.transcript_enabled = Some(true);
|
||||||
|
}
|
||||||
|
"--no-color" => options.no_color = true,
|
||||||
|
other => {
|
||||||
|
return Err(crate::config::ConfigError::Invalid(format!(
|
||||||
|
"unknown argument: {other}"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn help() -> &'static str {
|
||||||
|
"Usage: exoshell [--config <path>] [--shell powershell|posix] [--no-transcript] [--transcript-dir <path>] [--no-color]\n\nStarts the Exoshell Phase 1 interactive model chat. Exoshell suggests commands; it does not execute them."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_config_path() {
|
||||||
|
let options = CliOptions::parse(["--config".to_string(), "config.toml".to_string()])
|
||||||
|
.expect("options parse");
|
||||||
|
|
||||||
|
assert_eq!(options.config_path, Some(PathBuf::from("config.toml")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_flag_is_supported() {
|
||||||
|
let options = CliOptions::parse(["--help".to_string()]).expect("options parse");
|
||||||
|
|
||||||
|
assert!(options.show_help);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_phase1_cli_overrides() {
|
||||||
|
let options = CliOptions::parse([
|
||||||
|
"--shell".to_string(),
|
||||||
|
"posix".to_string(),
|
||||||
|
"--no-transcript".to_string(),
|
||||||
|
"--transcript-dir".to_string(),
|
||||||
|
"out".to_string(),
|
||||||
|
"--no-color".to_string(),
|
||||||
|
])
|
||||||
|
.expect("options parse");
|
||||||
|
|
||||||
|
assert_eq!(options.shell_family, Some(ShellFamily::Posix));
|
||||||
|
assert_eq!(options.transcript_enabled, Some(true));
|
||||||
|
assert_eq!(options.transcript_directory, Some(PathBuf::from("out")));
|
||||||
|
assert!(options.no_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
306
src/config.rs
Normal file
306
src/config.rs
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::app::CliOptions;
|
||||||
|
use crate::shell::ShellFamily;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Config {
|
||||||
|
pub provider: ProviderConfig,
|
||||||
|
pub shell: ShellConfig,
|
||||||
|
pub transcript: TranscriptConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ShellConfig {
|
||||||
|
pub family: ShellFamily,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TranscriptConfig {
|
||||||
|
pub directory: PathBuf,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct RawConfig {
|
||||||
|
provider: Option<RawProviderConfig>,
|
||||||
|
shell: Option<RawShellConfig>,
|
||||||
|
transcript: Option<RawTranscriptConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct RawProviderConfig {
|
||||||
|
base_url: Option<String>,
|
||||||
|
api_key_env: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct RawShellConfig {
|
||||||
|
family: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct RawTranscriptConfig {
|
||||||
|
directory: Option<PathBuf>,
|
||||||
|
enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
|
||||||
|
let raw = match path {
|
||||||
|
Some(path) => RawConfig::from_path(path)?,
|
||||||
|
None => RawConfig::from_default_path()?.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::from_raw(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_raw(raw: RawConfig) -> Result<Self, ConfigError> {
|
||||||
|
let provider = raw.provider.unwrap_or_default();
|
||||||
|
let shell = raw.shell.unwrap_or_default();
|
||||||
|
let transcript = raw.transcript.unwrap_or_default();
|
||||||
|
|
||||||
|
let api_key_env = provider
|
||||||
|
.api_key_env
|
||||||
|
.unwrap_or_else(|| "OPENAI_API_KEY".into());
|
||||||
|
let api_key = env::var(&api_key_env).map_err(|_| {
|
||||||
|
ConfigError::MissingApiKey(format!(
|
||||||
|
"set {api_key_env} or configure provider.api_key_env"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let family = shell.family.unwrap_or_else(default_shell_family);
|
||||||
|
let family = family
|
||||||
|
.parse::<ShellFamily>()
|
||||||
|
.map_err(|error| ConfigError::Invalid(error.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
provider: ProviderConfig {
|
||||||
|
base_url: provider
|
||||||
|
.base_url
|
||||||
|
.unwrap_or_else(|| "https://api.openai.com/v1".into()),
|
||||||
|
api_key,
|
||||||
|
api_key_env,
|
||||||
|
model: provider.model.unwrap_or_else(|| "gpt-4.1-mini".into()),
|
||||||
|
},
|
||||||
|
shell: ShellConfig { family },
|
||||||
|
transcript: TranscriptConfig {
|
||||||
|
directory: transcript.directory.unwrap_or_else(default_transcript_dir),
|
||||||
|
enabled: transcript.enabled.unwrap_or(true),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_cli_overrides(&mut self, options: &CliOptions) -> Result<(), ConfigError> {
|
||||||
|
if let Some(shell_family) = options.shell_family {
|
||||||
|
self.shell.family = shell_family;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(transcript_enabled) = options.transcript_enabled {
|
||||||
|
self.transcript.enabled = transcript_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(transcript_directory) = &options.transcript_directory {
|
||||||
|
self.transcript.directory = transcript_directory.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawConfig {
|
||||||
|
fn from_path(path: &Path) -> Result<Self, ConfigError> {
|
||||||
|
let contents = fs::read_to_string(path).map_err(|error| ConfigError::Read {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
toml::from_str(&contents).map_err(ConfigError::Parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_default_path() -> Result<Option<Self>, ConfigError> {
|
||||||
|
let path = default_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::from_path(&path).map(Some)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_shell_family() -> String {
|
||||||
|
ShellFamily::default_for_platform().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_config_path() -> PathBuf {
|
||||||
|
if cfg!(windows) {
|
||||||
|
env::var_os("APPDATA")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("exoshell")
|
||||||
|
.join("config.toml")
|
||||||
|
} else {
|
||||||
|
env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("exoshell")
|
||||||
|
.join("config.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_transcript_dir() -> PathBuf {
|
||||||
|
if cfg!(windows) {
|
||||||
|
env::var_os("APPDATA")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("exoshell")
|
||||||
|
.join("transcripts")
|
||||||
|
} else {
|
||||||
|
env::var_os("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("exoshell")
|
||||||
|
.join("transcripts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("failed to read config at {path}: {error}")]
|
||||||
|
Read {
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
error: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("failed to parse config: {0}")]
|
||||||
|
Parse(toml::de::Error),
|
||||||
|
#[error("missing provider API key: {0}")]
|
||||||
|
MissingApiKey(String),
|
||||||
|
#[error("invalid config: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_defaults_from_environment() {
|
||||||
|
unsafe {
|
||||||
|
env::set_var("EXOSHELL_TEST_KEY", "secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Config::from_raw(RawConfig {
|
||||||
|
provider: Some(RawProviderConfig {
|
||||||
|
api_key_env: Some("EXOSHELL_TEST_KEY".into()),
|
||||||
|
..RawProviderConfig::default()
|
||||||
|
}),
|
||||||
|
..RawConfig::default()
|
||||||
|
})
|
||||||
|
.expect("config loads");
|
||||||
|
|
||||||
|
assert_eq!(config.provider.api_key, "secret");
|
||||||
|
assert_eq!(config.provider.base_url, "https://api.openai.com/v1");
|
||||||
|
assert_eq!(config.provider.model, "gpt-4.1-mini");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_shell_family() {
|
||||||
|
unsafe {
|
||||||
|
env::set_var("EXOSHELL_TEST_KEY", "secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = Config::from_raw(RawConfig {
|
||||||
|
provider: Some(RawProviderConfig {
|
||||||
|
api_key_env: Some("EXOSHELL_TEST_KEY".into()),
|
||||||
|
..RawProviderConfig::default()
|
||||||
|
}),
|
||||||
|
shell: Some(RawShellConfig {
|
||||||
|
family: Some("cmd".into()),
|
||||||
|
}),
|
||||||
|
transcript: None,
|
||||||
|
})
|
||||||
|
.expect_err("shell family should be rejected");
|
||||||
|
|
||||||
|
assert!(matches!(error, ConfigError::Invalid(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_toml_config_file() {
|
||||||
|
unsafe {
|
||||||
|
env::set_var("EXOSHELL_TEST_KEY", "secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = tempfile::NamedTempFile::new().expect("temp config");
|
||||||
|
write!(
|
||||||
|
file,
|
||||||
|
r#"
|
||||||
|
[provider]
|
||||||
|
base_url = "http://localhost:11434/v1"
|
||||||
|
api_key_env = "EXOSHELL_TEST_KEY"
|
||||||
|
model = "local-model"
|
||||||
|
|
||||||
|
[shell]
|
||||||
|
family = "posix"
|
||||||
|
|
||||||
|
[transcript]
|
||||||
|
enabled = false
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.expect("write config");
|
||||||
|
|
||||||
|
let config = Config::load(Some(file.path())).expect("config loads");
|
||||||
|
|
||||||
|
assert_eq!(config.provider.base_url, "http://localhost:11434/v1");
|
||||||
|
assert_eq!(config.provider.model, "local-model");
|
||||||
|
assert_eq!(config.shell.family, ShellFamily::Posix);
|
||||||
|
assert!(!config.transcript.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_cli_overrides() {
|
||||||
|
unsafe {
|
||||||
|
env::set_var("EXOSHELL_TEST_KEY", "secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config = Config::from_raw(RawConfig {
|
||||||
|
provider: Some(RawProviderConfig {
|
||||||
|
api_key_env: Some("EXOSHELL_TEST_KEY".into()),
|
||||||
|
..RawProviderConfig::default()
|
||||||
|
}),
|
||||||
|
..RawConfig::default()
|
||||||
|
})
|
||||||
|
.expect("config loads");
|
||||||
|
|
||||||
|
let tempdir = PathBuf::from("manual-transcripts");
|
||||||
|
let options = CliOptions {
|
||||||
|
shell_family: Some(ShellFamily::Posix),
|
||||||
|
transcript_enabled: Some(false),
|
||||||
|
transcript_directory: Some(tempdir.clone()),
|
||||||
|
..CliOptions::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
config.apply_cli_overrides(&options).expect("overrides");
|
||||||
|
|
||||||
|
assert_eq!(config.shell.family, ShellFamily::Posix);
|
||||||
|
assert!(!config.transcript.enabled);
|
||||||
|
assert_eq!(config.transcript.directory, tempdir);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/formatting.rs
Normal file
66
src/formatting.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
pub fn render_assistant_output(response: &str) -> String {
|
||||||
|
let mut rendered = String::new();
|
||||||
|
let mut in_command_block = false;
|
||||||
|
|
||||||
|
for line in response.lines() {
|
||||||
|
if in_command_block && line.trim() == "```" {
|
||||||
|
in_command_block = false;
|
||||||
|
rendered.push_str("--- end suggested command ---\n");
|
||||||
|
rendered.push_str(line);
|
||||||
|
rendered.push('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(language) = command_language_from_fence(line) {
|
||||||
|
rendered.push_str(&format!(
|
||||||
|
"--- suggested command ({language}); review before running ---\n"
|
||||||
|
));
|
||||||
|
in_command_block = true;
|
||||||
|
rendered.push_str(line);
|
||||||
|
rendered.push('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered.push_str(line);
|
||||||
|
rendered.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.contains("[risk: high]") || response.contains("risk: high") {
|
||||||
|
rendered.push_str("--- review required: model marked this response as high risk ---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered.trim_end().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_language_from_fence(line: &str) -> Option<&str> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
let language = trimmed.strip_prefix("```")?;
|
||||||
|
|
||||||
|
match language {
|
||||||
|
"powershell" | "pwsh" | "sh" | "bash" | "zsh" | "posix" => Some(language),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_command_blocks() {
|
||||||
|
let output = render_assistant_output(
|
||||||
|
"Try this:\n```powershell\nGet-ChildItem\n```\nThen inspect the output.",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(output.contains("suggested command (powershell); review before running"));
|
||||||
|
assert!(output.contains("end suggested command"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_high_risk_language() {
|
||||||
|
let output = render_assistant_output("[risk: high]\n```sh\nrm -rf build\n```");
|
||||||
|
|
||||||
|
assert!(output.contains("review required"));
|
||||||
|
assert!(output.contains("rm -rf build"));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/main.rs
Normal file
37
src/main.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod formatting;
|
||||||
|
mod prompts;
|
||||||
|
mod providers;
|
||||||
|
mod repl;
|
||||||
|
mod shell;
|
||||||
|
mod transcripts;
|
||||||
|
|
||||||
|
use crate::app::{App, CliOptions};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::providers::openai_compatible::OpenAiCompatibleProvider;
|
||||||
|
use crate::repl::Repl;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
if let Err(error) = run().await {
|
||||||
|
eprintln!("error: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), app::AppError> {
|
||||||
|
let options = CliOptions::parse(std::env::args().skip(1))?;
|
||||||
|
|
||||||
|
if options.show_help {
|
||||||
|
println!("{}", CliOptions::help());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config = Config::load(options.config_path.as_deref())?;
|
||||||
|
config.apply_cli_overrides(&options)?;
|
||||||
|
let provider = OpenAiCompatibleProvider::from_config(&config)?;
|
||||||
|
let app = App::new(config, Box::new(provider));
|
||||||
|
|
||||||
|
Repl::new(app).run().await
|
||||||
|
}
|
||||||
50
src/prompts.rs
Normal file
50
src/prompts.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use crate::shell::ShellFamily;
|
||||||
|
|
||||||
|
pub fn phase1_system_prompt(shell_family: ShellFamily) -> String {
|
||||||
|
format!(
|
||||||
|
"You are Exoshell, a shell-adjacent assistant for technical operators.\n\
|
||||||
|
Sacred rule: enhance skill; do not replace it.\n\
|
||||||
|
The human keeps the controls. Suggest commands, but never say or imply that you executed them.\n\
|
||||||
|
Do not request or perform autonomous execution. Do not normalize blind destructive commands.\n\
|
||||||
|
Prefer deterministic shell tools when they are the right fit; do not force AI into workflows where awk, sed, jq, rg, git, cargo, make, or platform-native tools are better.\n\
|
||||||
|
Surface uncertainty with signal language such as 'signal: weak', 'signal: medium', or 'signal: high' when confidence matters.\n\
|
||||||
|
Target shell family: {shell_family}.\n\
|
||||||
|
Shell instructions: {}\n\
|
||||||
|
Command convention: put suggested commands in fenced code blocks using language tag `{}`. Add a short review note before risky operations. For destructive commands, require explicit user review and provide a safer inspection command first when possible.",
|
||||||
|
shell_family.prompt_instructions(),
|
||||||
|
command_language(shell_family)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_language(shell_family: ShellFamily) -> &'static str {
|
||||||
|
match shell_family {
|
||||||
|
ShellFamily::PowerShell => "powershell",
|
||||||
|
ShellFamily::Posix => "sh",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn powershell_prompt_contains_shell_specific_instructions() {
|
||||||
|
let prompt = phase1_system_prompt(ShellFamily::PowerShell);
|
||||||
|
|
||||||
|
assert!(prompt.contains("Target shell family: powershell"));
|
||||||
|
assert!(prompt.contains("Use PowerShell syntax"));
|
||||||
|
assert!(prompt.contains("never say or imply that you executed"));
|
||||||
|
assert!(prompt.contains("signal: weak"));
|
||||||
|
assert!(prompt.contains("fenced code blocks using language tag `powershell`"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn posix_prompt_contains_shell_specific_instructions() {
|
||||||
|
let prompt = phase1_system_prompt(ShellFamily::Posix);
|
||||||
|
|
||||||
|
assert!(prompt.contains("Target shell family: posix"));
|
||||||
|
assert!(prompt.contains("bash or zsh"));
|
||||||
|
assert!(prompt.contains("Prefer deterministic shell tools"));
|
||||||
|
assert!(prompt.contains("fenced code blocks using language tag `sh`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/providers/mod.rs
Normal file
81
src/providers/mod.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
pub mod openai_compatible;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: ChatRole,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatMessage {
|
||||||
|
pub fn new(role: ChatRole, content: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
role,
|
||||||
|
content: content.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ChatRole {
|
||||||
|
System,
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
pub stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ChatResponse {
|
||||||
|
Complete(String),
|
||||||
|
Stream(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait Provider: Send + Sync {
|
||||||
|
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ProviderError {
|
||||||
|
#[error("provider configuration error: {0}")]
|
||||||
|
Configuration(String),
|
||||||
|
#[error("provider authentication failed: {0}")]
|
||||||
|
Authentication(String),
|
||||||
|
#[error("provider network error: {0}")]
|
||||||
|
Network(String),
|
||||||
|
#[error("provider returned an invalid response: {0}")]
|
||||||
|
Response(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct FailingProvider;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Provider for FailingProvider {
|
||||||
|
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
|
||||||
|
Err(ProviderError::Authentication("bad key".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_trait_surfaces_typed_errors() {
|
||||||
|
let provider = FailingProvider;
|
||||||
|
let error = provider
|
||||||
|
.chat(ChatRequest {
|
||||||
|
messages: vec![],
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("provider should fail");
|
||||||
|
|
||||||
|
assert!(matches!(error, ProviderError::Authentication(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/providers/openai_compatible.rs
Normal file
245
src/providers/openai_compatible.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, Provider, ProviderError};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OpenAiCompatibleProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenAiCompatibleProvider {
|
||||||
|
pub fn from_config(config: &Config) -> Result<Self, ProviderError> {
|
||||||
|
Self::new(
|
||||||
|
config.provider.base_url.clone(),
|
||||||
|
config.provider.api_key.clone(),
|
||||||
|
config.provider.model.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
api_key: impl Into<String>,
|
||||||
|
model: impl Into<String>,
|
||||||
|
) -> Result<Self, ProviderError> {
|
||||||
|
let base_url = base_url.into().trim_end_matches('/').to_string();
|
||||||
|
if base_url.is_empty() {
|
||||||
|
return Err(ProviderError::Configuration("base URL is empty".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key = api_key.into();
|
||||||
|
if api_key.is_empty() {
|
||||||
|
return Err(ProviderError::Configuration("API key is empty".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = model.into();
|
||||||
|
if model.is_empty() {
|
||||||
|
return Err(ProviderError::Configuration("model is empty".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_url(&self) -> String {
|
||||||
|
format!("{}/chat/completions", self.base_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Provider for OpenAiCompatibleProvider {
|
||||||
|
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
|
||||||
|
let payload = ChatCompletionRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
messages: request.messages,
|
||||||
|
stream: request.stream,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(self.chat_url())
|
||||||
|
.bearer_auth(&self.api_key)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| ProviderError::Network(error.to_string()))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||||
|
return Err(ProviderError::Authentication(
|
||||||
|
"check provider API key and permissions".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(ProviderError::Response(format!(
|
||||||
|
"HTTP {status}: {}",
|
||||||
|
body.trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.stream {
|
||||||
|
return read_streaming_response(response).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ChatCompletionResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| ProviderError::Response(error.to_string()))?;
|
||||||
|
|
||||||
|
let content = body
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|choice| choice.message.content)
|
||||||
|
.ok_or_else(|| ProviderError::Response("missing assistant choice".into()))?;
|
||||||
|
|
||||||
|
Ok(ChatResponse::Complete(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_streaming_response(
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<ChatResponse, ProviderError> {
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
|
||||||
|
while let Some(item) = stream.next().await {
|
||||||
|
let bytes = item.map_err(|error| ProviderError::Network(error.to_string()))?;
|
||||||
|
buffer.push_str(&String::from_utf8_lossy(&bytes));
|
||||||
|
|
||||||
|
while let Some(line_end) = buffer.find('\n') {
|
||||||
|
let line = buffer[..line_end].trim().to_string();
|
||||||
|
buffer = buffer[line_end + 1..].to_string();
|
||||||
|
|
||||||
|
let Some(data) = line.strip_prefix("data:") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = data.trim();
|
||||||
|
if data == "[DONE]" {
|
||||||
|
return Ok(ChatResponse::Stream(chunks));
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: ChatCompletionChunk = serde_json::from_str(data)
|
||||||
|
.map_err(|error| ProviderError::Response(error.to_string()))?;
|
||||||
|
|
||||||
|
for choice in event.choices {
|
||||||
|
if let Some(content) = choice.delta.content {
|
||||||
|
chunks.push(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ChatResponse::Stream(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ChatCompletionRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatCompletionResponse {
|
||||||
|
choices: Vec<Choice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Choice {
|
||||||
|
message: ResponseMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResponseMessage {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatCompletionChunk {
|
||||||
|
choices: Vec<ChunkChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChunkChoice {
|
||||||
|
delta: ChunkDelta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChunkDelta {
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::providers::ChatRole;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_configuration() {
|
||||||
|
let error = OpenAiCompatibleProvider::new("", "key", "model")
|
||||||
|
.expect_err("empty base URL should fail");
|
||||||
|
|
||||||
|
assert!(matches!(error, ProviderError::Configuration(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sends_openai_compatible_request_and_reads_response() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock");
|
||||||
|
let address = listener.local_addr().expect("mock address");
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut socket, _) = listener.accept().await.expect("accept request");
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let bytes = socket.read(&mut request).await.expect("read request");
|
||||||
|
let request = String::from_utf8_lossy(&request[..bytes]);
|
||||||
|
|
||||||
|
assert!(request.contains("POST /chat/completions HTTP/1.1"));
|
||||||
|
assert!(request.contains("\"model\":\"test-model\""));
|
||||||
|
assert!(request.contains("\"role\":\"user\""));
|
||||||
|
assert!(request.contains("\"stream\":false"));
|
||||||
|
|
||||||
|
let body = r#"{"choices":[{"message":{"content":"hello from mock"}}]}"#;
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
socket
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.await
|
||||||
|
.expect("write response");
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider =
|
||||||
|
OpenAiCompatibleProvider::new(format!("http://{address}"), "test-key", "test-model")
|
||||||
|
.expect("provider");
|
||||||
|
|
||||||
|
let response = provider
|
||||||
|
.chat(ChatRequest {
|
||||||
|
messages: vec![ChatMessage::new(ChatRole::User, "hello")],
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("chat response");
|
||||||
|
|
||||||
|
server.await.expect("server task");
|
||||||
|
assert_eq!(response, ChatResponse::Complete("hello from mock".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/repl.rs
Normal file
100
src/repl.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use crate::app::{App, AppError};
|
||||||
|
use crate::formatting::render_assistant_output;
|
||||||
|
|
||||||
|
pub struct Repl {
|
||||||
|
app: App,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Repl {
|
||||||
|
pub fn new(app: App) -> Self {
|
||||||
|
Self { app }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(mut self) -> Result<(), AppError> {
|
||||||
|
println!("Exoshell Phase 1");
|
||||||
|
println!(
|
||||||
|
"Type /exit to quit. Type /multi to enter multi-line input, then finish with a single '.' line."
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print!("exo> ");
|
||||||
|
io::stdout().flush().map_err(ReplError::Io)?;
|
||||||
|
|
||||||
|
let Some(input) = read_input()? else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let input = input.trim().to_string();
|
||||||
|
if input.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == "/exit" || input == "/quit" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = if input == "/multi" {
|
||||||
|
read_multiline()?
|
||||||
|
} else {
|
||||||
|
input
|
||||||
|
};
|
||||||
|
|
||||||
|
if input.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.app.send(input).await {
|
||||||
|
Ok(response) => println!("\n{}\n", render_assistant_output(&response)),
|
||||||
|
Err(error) => eprintln!("request failed: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.app.save_transcript()? {
|
||||||
|
Some(path) => println!("transcript: {}", path.display()),
|
||||||
|
None => println!("transcript: disabled"),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_input() -> Result<Option<String>, ReplError> {
|
||||||
|
let mut input = String::new();
|
||||||
|
let bytes = io::stdin().read_line(&mut input).map_err(ReplError::Io)?;
|
||||||
|
if bytes == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_multiline() -> Result<String, ReplError> {
|
||||||
|
println!("multi-line input; finish with a single '.' line");
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
loop {
|
||||||
|
print!("... ");
|
||||||
|
io::stdout().flush().map_err(ReplError::Io)?;
|
||||||
|
|
||||||
|
let Some(line) = read_input()? else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = line.trim_end_matches(['\r', '\n']);
|
||||||
|
if line == "." {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ReplError {
|
||||||
|
#[error("terminal I/O failed: {0}")]
|
||||||
|
Io(std::io::Error),
|
||||||
|
}
|
||||||
80
src/shell.rs
Normal file
80
src/shell.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ShellFamily {
|
||||||
|
PowerShell,
|
||||||
|
Posix,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellFamily {
|
||||||
|
pub fn default_for_platform() -> Self {
|
||||||
|
if cfg!(windows) {
|
||||||
|
Self::PowerShell
|
||||||
|
} else {
|
||||||
|
Self::Posix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_instructions(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::PowerShell => {
|
||||||
|
"Use PowerShell syntax. Prefer PowerShell cmdlets for native Windows tasks, and use external tools such as rg, git, jq, or cargo when they are the right deterministic tool."
|
||||||
|
}
|
||||||
|
Self::Posix => {
|
||||||
|
"Use POSIX-like shell syntax suitable for bash or zsh. Prefer standard shell tools and common deterministic utilities such as rg, awk, sed, jq, git, make, or cargo when they fit."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ShellFamily {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::PowerShell => formatter.write_str("powershell"),
|
||||||
|
Self::Posix => formatter.write_str("posix"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ShellFamily {
|
||||||
|
type Err = ShellFamilyError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"powershell" => Ok(Self::PowerShell),
|
||||||
|
"posix" => Ok(Self::Posix),
|
||||||
|
other => Err(ShellFamilyError {
|
||||||
|
value: other.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("unsupported shell family '{value}', expected 'powershell' or 'posix'")]
|
||||||
|
pub struct ShellFamilyError {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_supported_shell_families() {
|
||||||
|
assert_eq!(
|
||||||
|
"powershell".parse::<ShellFamily>().expect("powershell"),
|
||||||
|
ShellFamily::PowerShell
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"posix".parse::<ShellFamily>().expect("posix"),
|
||||||
|
ShellFamily::Posix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsupported_shell_family() {
|
||||||
|
assert!("cmd".parse::<ShellFamily>().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/transcripts.rs
Normal file
154
src/transcripts.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::shell::ShellFamily;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Transcript {
|
||||||
|
started_at_epoch_ms: u128,
|
||||||
|
provider: String,
|
||||||
|
model: String,
|
||||||
|
shell_family: ShellFamily,
|
||||||
|
entries: Vec<TranscriptEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transcript {
|
||||||
|
pub fn new(provider: String, model: String, shell_family: ShellFamily) -> Self {
|
||||||
|
Self {
|
||||||
|
started_at_epoch_ms: unix_millis(),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
shell_family,
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_user(&mut self, content: &str) {
|
||||||
|
self.entries
|
||||||
|
.push(TranscriptEntry::User(content.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_assistant(&mut self, content: &str) {
|
||||||
|
self.entries
|
||||||
|
.push(TranscriptEntry::Assistant(content.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_error(&mut self, content: &str) {
|
||||||
|
self.entries
|
||||||
|
.push(TranscriptEntry::Error(content.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_to_dir(&self, directory: &Path) -> Result<PathBuf, TranscriptError> {
|
||||||
|
fs::create_dir_all(directory).map_err(|error| TranscriptError::CreateDir {
|
||||||
|
path: directory.to_path_buf(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let path = directory.join(format!(
|
||||||
|
"session-{}-{}.md",
|
||||||
|
self.started_at_epoch_ms,
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::write(&path, self.to_markdown()).map_err(|error| TranscriptError::Write {
|
||||||
|
path: path.clone(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_markdown(&self) -> String {
|
||||||
|
let mut markdown = format!(
|
||||||
|
"# Exoshell Session {}\n\n- started_at_epoch_ms: `{}`\n- provider: `{}`\n- model: `{}`\n- shell_family: `{}`\n\n",
|
||||||
|
self.started_at_epoch_ms,
|
||||||
|
self.started_at_epoch_ms,
|
||||||
|
self.provider,
|
||||||
|
self.model,
|
||||||
|
self.shell_family
|
||||||
|
);
|
||||||
|
|
||||||
|
for entry in &self.entries {
|
||||||
|
match entry {
|
||||||
|
TranscriptEntry::User(content) => {
|
||||||
|
markdown.push_str("## User\n\n");
|
||||||
|
markdown.push_str(content);
|
||||||
|
markdown.push_str("\n\n");
|
||||||
|
}
|
||||||
|
TranscriptEntry::Assistant(content) => {
|
||||||
|
markdown.push_str("## Assistant\n\n");
|
||||||
|
markdown.push_str(content);
|
||||||
|
markdown.push_str("\n\n");
|
||||||
|
}
|
||||||
|
TranscriptEntry::Error(content) => {
|
||||||
|
markdown.push_str("## Error\n\n");
|
||||||
|
markdown.push_str(content);
|
||||||
|
markdown.push_str("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum TranscriptEntry {
|
||||||
|
User(String),
|
||||||
|
Assistant(String),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_millis() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time before UNIX epoch")
|
||||||
|
.as_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TranscriptError {
|
||||||
|
#[error("failed to create transcript directory {path}: {error}")]
|
||||||
|
CreateDir {
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
error: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("failed to write transcript {path}: {error}")]
|
||||||
|
Write {
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
error: std::io::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_markdown_transcript() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let mut transcript = Transcript::new(
|
||||||
|
"test-provider".into(),
|
||||||
|
"test-model".into(),
|
||||||
|
ShellFamily::Posix,
|
||||||
|
);
|
||||||
|
transcript.record_user("hello");
|
||||||
|
transcript.record_assistant("hi");
|
||||||
|
transcript.record_error("provider failed");
|
||||||
|
|
||||||
|
let path = transcript
|
||||||
|
.write_to_dir(tempdir.path())
|
||||||
|
.expect("transcript writes");
|
||||||
|
let contents = fs::read_to_string(path).expect("read transcript");
|
||||||
|
|
||||||
|
assert!(contents.contains("test-model"));
|
||||||
|
assert!(contents.contains("test-provider"));
|
||||||
|
assert!(contents.contains("shell_family: `posix`"));
|
||||||
|
assert!(contents.contains("## User"));
|
||||||
|
assert!(contents.contains("hello"));
|
||||||
|
assert!(contents.contains("## Assistant"));
|
||||||
|
assert!(contents.contains("## Error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user