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