Initial scaffolding and docs

This commit is contained in:
K. Hodges 2026-06-02 04:29:41 -07:00
parent 8a07524724
commit d4268bd3cd
20 changed files with 3566 additions and 0 deletions

29
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View 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
View File

161
docs/PHASES.md Normal file
View 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
View 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
View 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

View 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
View File

@ -0,0 +1,5 @@
$ErrorActionPreference = "Stop"
cargo fmt --check
cargo test
cargo clippy --all-targets --all-features

187
src/app.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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(_)));
}
}

View 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
View 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
View 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
View 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"));
}
}