Files
owlen/crates/app/cli/src/main.rs

1140 lines
48 KiB
Rust

mod commands;
mod messages;
mod engine;
mod state;
mod agent_manager;
mod tool_registry;
use clap::{Parser, ValueEnum};
use color_eyre::eyre::{Result, eyre};
use config_agent::load_settings;
use hooks::{HookEvent, HookManager, HookResult};
use llm_core::{AuthMethod, ChatOptions, LlmProvider, ProviderType};
use llm_anthropic::AnthropicClient;
use llm_ollama::OllamaClient;
use llm_openai::OpenAIClient;
use permissions::{PermissionDecision, Tool};
use plugins::PluginManager;
use serde::Serialize;
use std::io::Write;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
pub use commands::{BuiltinCommands, CommandResult};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Text,
Json,
StreamJson,
}
#[derive(Serialize)]
struct SessionOutput {
session_id: String,
messages: Vec<serde_json::Value>,
stats: Stats,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
tool: Option<String>,
}
#[derive(Serialize)]
struct Stats {
total_tokens: u64,
#[serde(skip_serializing_if = "Option::is_none")]
prompt_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
completion_tokens: Option<u64>,
duration_ms: u64,
}
#[derive(Serialize)]
struct StreamEvent {
#[serde(rename = "type")]
event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stats: Option<Stats>,
}
/// Application context shared across the session
pub struct AppContext {
pub plugin_manager: PluginManager,
pub config: config_agent::Settings,
}
impl AppContext {
pub fn new() -> Result<Self> {
let config = load_settings(None).unwrap_or_default();
let mut plugin_manager = PluginManager::new();
// Non-fatal: just log warnings, don't fail startup
if let Err(e) = plugin_manager.load_all() {
eprintln!("Warning: Failed to load some plugins: {}", e);
}
Ok(Self {
plugin_manager,
config,
})
}
/// Print loaded plugins and available commands
pub fn print_plugin_info(&self) {
let plugins = self.plugin_manager.plugins();
if !plugins.is_empty() {
println!("\nLoaded {} plugin(s):", plugins.len());
for plugin in plugins {
println!(" - {} v{}", plugin.manifest.name, plugin.manifest.version);
if let Some(desc) = &plugin.manifest.description {
println!(" {}", desc);
}
}
}
let commands = self.plugin_manager.all_commands();
if !commands.is_empty() {
println!("\nAvailable plugin commands:");
for (name, _path) in &commands {
println!(" /{}", name);
}
}
}
}
fn generate_session_id() -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
format!("session-{}", timestamp)
}
/// Create an LLM provider based on settings and CLI arguments
fn create_provider(
settings: &config_agent::Settings,
model_override: Option<&str>,
api_key_override: Option<&str>,
ollama_url_override: Option<&str>,
) -> Result<(Arc<dyn LlmProvider>, String)> {
// Determine which provider to use
let provider_type = settings.get_provider().unwrap_or(ProviderType::Ollama);
// Get or create auth manager
let auth_manager = auth_manager::AuthManager::new()
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
// Get authentication for this provider
let auth = auth_manager.get_auth(provider_type);
// Determine the model to use
let model = model_override
.map(|s| s.to_string())
.unwrap_or_else(|| settings.get_effective_model().to_string());
match provider_type {
ProviderType::Ollama => {
// Handle Ollama Cloud vs local
let api_key = api_key_override
.map(|s| s.to_string())
.or_else(|| settings.api_key.clone());
let use_cloud = model.ends_with("-cloud") && api_key.is_some();
let client = if use_cloud {
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
} else {
let base_url = ollama_url_override
.map(|s| s.to_string())
.unwrap_or_else(|| settings.ollama_url.clone());
let mut client = OllamaClient::new(base_url);
if let Some(key) = api_key {
client = client.with_api_key(key);
}
client
};
Ok((Arc::new(client) as Arc<dyn LlmProvider>, model))
}
ProviderType::Anthropic => {
// Try CLI override, then auth manager, then settings
let auth_method = api_key_override
.map(|k| AuthMethod::ApiKey(k.to_string()))
.or_else(|| auth.ok())
.or_else(|| settings.anthropic_api_key.clone().map(AuthMethod::ApiKey))
.ok_or_else(|| eyre!(
"Anthropic requires authentication. Run 'owlen login anthropic' or set ANTHROPIC_API_KEY"
))?;
let client = AnthropicClient::with_auth(auth_method).with_model(&model);
Ok((Arc::new(client) as Arc<dyn LlmProvider>, model))
}
ProviderType::OpenAI => {
// Try CLI override, then auth manager, then settings
let auth_method = api_key_override
.map(|k| AuthMethod::ApiKey(k.to_string()))
.or_else(|| auth.ok())
.or_else(|| settings.openai_api_key.clone().map(AuthMethod::ApiKey))
.ok_or_else(|| eyre!(
"OpenAI requires authentication. Run 'owlen login openai' or set OPENAI_API_KEY"
))?;
let client = OpenAIClient::with_auth(auth_method).with_model(&model);
Ok((Arc::new(client) as Arc<dyn LlmProvider>, model))
}
}
}
fn output_tool_result(
format: OutputFormat,
tool: &str,
result: serde_json::Value,
session_id: &str,
) -> Result<()> {
match format {
OutputFormat::Text => {
// For text, just print the result as-is
if let Some(s) = result.as_str() {
println!("{}", s);
} else {
println!("{}", serde_json::to_string_pretty(&result)?);
}
}
OutputFormat::Json => {
let output = SessionOutput {
session_id: session_id.to_string(),
messages: vec![],
stats: Stats {
total_tokens: 0,
prompt_tokens: None,
completion_tokens: None,
duration_ms: 0,
},
result: Some(result),
tool: Some(tool.to_string()),
};
println!("{}", serde_json::to_string(&output)?);
}
OutputFormat::StreamJson => {
// For stream-json, emit session_start, result, and session_end
let session_start = StreamEvent {
event_type: "session_start".to_string(),
session_id: Some(session_id.to_string()),
content: None,
stats: None,
};
println!("{}", serde_json::to_string(&session_start)?);
let result_event = StreamEvent {
event_type: "tool_result".to_string(),
session_id: None,
content: Some(serde_json::to_string(&result)?),
stats: None,
};
println!("{}", serde_json::to_string(&result_event)?);
let session_end = StreamEvent {
event_type: "session_end".to_string(),
session_id: None,
content: None,
stats: Some(Stats {
total_tokens: 0,
prompt_tokens: None,
completion_tokens: None,
duration_ms: 0,
}),
};
println!("{}", serde_json::to_string(&session_end)?);
}
}
Ok(())
}
#[derive(clap::Subcommand, Debug)]
enum Cmd {
Read { path: String },
Glob { pattern: String },
Grep { root: String, pattern: String },
Write { path: String, content: String },
Edit { path: String, old_string: String, new_string: String },
Bash { command: String, #[arg(long)] timeout: Option<u64> },
Slash { command_name: String, args: Vec<String> },
/// Authenticate with an LLM provider (anthropic, openai)
Login {
/// Provider to authenticate with (anthropic, openai)
provider: String,
},
/// Remove stored credentials for a provider
Logout {
/// Provider to log out from (anthropic, openai, ollama)
provider: String,
},
/// Show authentication status for all providers
Auth,
}
#[derive(Parser, Debug)]
#[command(name = "code", version)]
struct Args {
#[arg(long)]
ollama_url: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
print: bool,
/// Override the permission mode (plan, acceptEdits, code)
#[arg(long)]
mode: Option<String>,
/// Output format (text, json, stream-json)
#[arg(long, value_enum, default_value = "text")]
output_format: OutputFormat,
/// Disable TUI and use legacy text-based REPL
#[arg(long)]
no_tui: bool,
#[arg()]
prompt: Vec<String>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let args = Args::parse();
// Initialize application context with plugins
let app_context = AppContext::new()?;
let mut settings = app_context.config.clone();
// Override mode if specified via CLI
if let Some(mode) = args.mode {
settings.mode = mode;
}
// Create permission manager from settings
let perms = settings.create_permission_manager();
// Create hook manager
let mut hook_mgr = HookManager::new(".");
// Register plugin hooks
for plugin in app_context.plugin_manager.plugins() {
if let Ok(Some(hooks_config)) = plugin.load_hooks_config() {
for (event, command, pattern, timeout) in plugin.register_hooks_with_manager(&hooks_config) {
hook_mgr.register_hook(event, command, pattern, timeout);
}
}
}
// Generate session ID
let session_id = generate_session_id();
let output_format = args.output_format;
if let Some(cmd) = args.cmd {
match cmd {
Cmd::Read { path } => {
// Check permission
match perms.check(Tool::Read, None) {
PermissionDecision::Allow => {
// Check PreToolUse hook
let event = HookEvent::PreToolUse {
tool: "Read".to_string(),
args: serde_json::json!({"path": &path}),
};
match hook_mgr.execute(&event, Some(5000)).await? {
HookResult::Deny => {
return Err(eyre!("Hook denied Read operation"));
}
HookResult::Allow => {}
}
let s = tools_fs::read_file(&path)?;
output_tool_result(output_format, "Read", serde_json::json!(s), &session_id)?;
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Read operation requires approval. Use --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Read operation is blocked."));
}
}
}
Cmd::Glob { pattern } => {
// Check permission
match perms.check(Tool::Glob, None) {
PermissionDecision::Allow => {
// Check PreToolUse hook
let event = HookEvent::PreToolUse {
tool: "Glob".to_string(),
args: serde_json::json!({"pattern": &pattern}),
};
match hook_mgr.execute(&event, Some(5000)).await? {
HookResult::Deny => {
return Err(eyre!("Hook denied Glob operation"));
}
HookResult::Allow => {}
}
for p in tools_fs::glob_list(&pattern)? {
println!("{}", p);
}
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Glob operation requires approval. Use --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Glob operation is blocked."));
}
}
}
Cmd::Grep { root, pattern } => {
// Check permission
match perms.check(Tool::Grep, None) {
PermissionDecision::Allow => {
// Check PreToolUse hook
let event = HookEvent::PreToolUse {
tool: "Grep".to_string(),
args: serde_json::json!({"root": &root, "pattern": &pattern}),
};
match hook_mgr.execute(&event, Some(5000)).await? {
HookResult::Deny => {
return Err(eyre!("Hook denied Grep operation"));
}
HookResult::Allow => {}
}
for (path, line_number, text) in tools_fs::grep(&root, &pattern)? {
println!("{path}:{line_number}:{text}")
}
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Grep operation requires approval. Use --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Grep operation is blocked."));
}
}
}
Cmd::Write { path, content } => {
// Check permission
match perms.check(Tool::Write, None) {
PermissionDecision::Allow => {
// Check PreToolUse hook
let event = HookEvent::PreToolUse {
tool: "Write".to_string(),
args: serde_json::json!({"path": &path, "content": &content}),
};
match hook_mgr.execute(&event, Some(5000)).await? {
HookResult::Deny => {
return Err(eyre!("Hook denied Write operation"));
}
HookResult::Allow => {}
}
tools_fs::write_file(&path, &content)?;
println!("File written: {}", path);
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Write operation requires approval. Use --mode acceptEdits or --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Write operation is blocked."));
}
}
}
Cmd::Edit { path, old_string, new_string } => {
// Check permission
match perms.check(Tool::Edit, None) {
PermissionDecision::Allow => {
// Check PreToolUse hook
let event = HookEvent::PreToolUse {
tool: "Edit".to_string(),
args: serde_json::json!({"path": &path, "old_string": &old_string, "new_string": &new_string}),
};
match hook_mgr.execute(&event, Some(5000)).await? {
HookResult::Deny => {
return Err(eyre!("Hook denied Edit operation"));
}
HookResult::Allow => {}
}
tools_fs::edit_file(&path, &old_string, &new_string)?;
println!("File edited: {}", path);
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Edit operation requires approval. Use --mode acceptEdits or --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Edit operation is blocked."));
}
}
}
Cmd::Bash { command, timeout } => {
// Check permission with command context for pattern matching
match perms.check(Tool::Bash, Some(&command)) {
PermissionDecision::Allow => {
// Check PreToolUse hook
let event = HookEvent::PreToolUse {
tool: "Bash".to_string(),
args: serde_json::json!({"command": &command, "timeout": timeout}),
};
match hook_mgr.execute(&event, Some(5000)).await? {
HookResult::Deny => {
return Err(eyre!("Hook denied Bash operation"));
}
HookResult::Allow => {}
}
let mut session = tools_bash::BashSession::new().await?;
let output = session.execute(&command, timeout).await?;
// Print stdout
if !output.stdout.is_empty() {
print!("{}", output.stdout);
}
// Print stderr to stderr
if !output.stderr.is_empty() {
eprint!("{}", output.stderr);
}
session.close().await?;
// Exit with same code as command
if !output.success {
std::process::exit(output.exit_code);
}
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Bash operation requires approval. Use --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Bash operation is blocked."));
}
}
}
Cmd::Slash { command_name, args } => {
// Check permission
match perms.check(Tool::SlashCommand, None) {
PermissionDecision::Allow => {
// Check PreToolUse hook
let event = HookEvent::PreToolUse {
tool: "SlashCommand".to_string(),
args: serde_json::json!({"command_name": &command_name, "args": &args}),
};
match hook_mgr.execute(&event, Some(5000)).await? {
HookResult::Deny => {
return Err(eyre!("Hook denied SlashCommand operation"));
}
HookResult::Allow => {}
}
// Look for command file in .owlen/commands/ first
let local_command_path = format!(".owlen/commands/{}.md", command_name);
// Try local commands first, then plugin commands
let content = if let Ok(c) = tools_fs::read_file(&local_command_path) {
c
} else if let Some(plugin_path) = app_context.plugin_manager.all_commands().get(&command_name) {
// Found in plugins
tools_fs::read_file(&plugin_path.to_string_lossy())?
} else {
return Err(eyre!(
"Slash command '{}' not found in .owlen/commands/ or plugins",
command_name
));
};
// Parse with arguments
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let slash_cmd = tools_slash::parse_slash_command(&content, &args_refs)?;
// Resolve file references
let resolved_body = slash_cmd.resolve_file_refs()?;
// Print the resolved command body
println!("{}", resolved_body);
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Slash command requires approval. Use --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Slash command is blocked."));
}
}
}
Cmd::Login { provider } => {
let provider_type = provider.parse::<llm_core::ProviderType>().ok()
.ok_or_else(|| eyre!(
"Unknown provider: {}. Supported: anthropic, openai, ollama",
provider
))?;
let auth_manager = auth_manager::AuthManager::new()
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
// Check if OAuth is available for this provider
let oauth_available = match provider_type {
llm_core::ProviderType::Anthropic => llm_anthropic::AnthropicAuth::is_oauth_available(),
llm_core::ProviderType::OpenAI => llm_openai::OpenAIAuth::is_oauth_available(),
llm_core::ProviderType::Ollama => false,
};
if oauth_available {
// Use OAuth device flow
println!("Starting OAuth login for {}...", provider);
auth_manager.login(provider_type).await
.map_err(|e| eyre!("Login failed: {}", e))?;
println!("\n✅ Successfully logged in to {}!", provider);
} else {
// OAuth not available, prompt for API key
println!("\n🔐 {} Login\n", provider.to_uppercase());
// Show provider-specific instructions
let (console_url, local_note) = match provider_type {
llm_core::ProviderType::Anthropic => (
"https://console.anthropic.com/settings/keys",
None,
),
llm_core::ProviderType::OpenAI => (
"https://platform.openai.com/api-keys",
None,
),
llm_core::ProviderType::Ollama => (
"https://ollama.com/settings/keys",
Some("Note: Local Ollama doesn't require authentication.\nThis is for Ollama Cloud access.\n"),
),
};
if let Some(note) = local_note {
println!("{}", note);
}
println!("Get your API key from: {}\n", console_url);
// Offer to open browser
print!("Open browser to get API key? [Y/n]: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut open_browser = String::new();
std::io::stdin().read_line(&mut open_browser)?;
let open_browser = open_browser.trim().to_lowercase();
if open_browser.is_empty() || open_browser == "y" || open_browser == "yes" {
if let Err(e) = open::that(console_url) {
println!("Could not open browser: {}", e);
} else {
println!("Opening browser...\n");
}
}
println!("Enter your API key below (input is hidden):\n");
// Prompt for API key
print!("API Key: ");
std::io::Write::flush(&mut std::io::stdout())?;
// Read API key (hide input)
let api_key = rpassword::read_password()
.map_err(|e| eyre!("Failed to read API key: {}", e))?;
let api_key = api_key.trim();
if api_key.is_empty() {
return Err(eyre!("API key cannot be empty. Login cancelled."));
}
// Validate API key format (basic check)
match provider_type {
llm_core::ProviderType::Anthropic => {
if !api_key.starts_with("sk-ant-") {
println!("\n⚠️ Warning: Anthropic API keys typically start with 'sk-ant-'");
}
}
llm_core::ProviderType::OpenAI => {
if !api_key.starts_with("sk-") {
println!("\n⚠️ Warning: OpenAI API keys typically start with 'sk-'");
}
}
llm_core::ProviderType::Ollama => {
// Ollama Cloud API keys - no specific format validation
}
}
// Store the API key
auth_manager.store_api_key(provider_type, api_key)
.map_err(|e| eyre!("Failed to store API key: {}", e))?;
println!("\n✅ API key stored successfully!");
}
println!("Credentials stored in: {}", auth_manager.storage_name());
return Ok(());
}
Cmd::Logout { provider } => {
let provider_type = provider.parse::<llm_core::ProviderType>().ok()
.ok_or_else(|| eyre!(
"Unknown provider: {}. Supported: anthropic, openai, ollama",
provider
))?;
let auth_manager = auth_manager::AuthManager::new()
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
auth_manager.logout(provider_type)
.map_err(|e| eyre!("Logout failed: {}", e))?;
println!("Successfully logged out from {}.", provider);
return Ok(());
}
Cmd::Auth => {
let auth_manager = auth_manager::AuthManager::new()
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
println!("\n🔐 Authentication Status\n");
println!("Storage: {}\n", auth_manager.storage_name());
println!("{:<12} {:<15} {:<30}", "Provider", "Status", "Details");
println!("{}", "-".repeat(57));
for status in auth_manager.status() {
let status_icon = if status.authenticated { "" } else { "" };
let status_text = if status.authenticated { "Authenticated" } else { "Not authenticated" };
let details = status.message.unwrap_or_else(|| "-".to_string());
println!(
"{:<12} {} {:<12} {}",
status.provider,
status_icon,
status_text,
details
);
}
println!("\nTo authenticate: owlen login <provider>");
println!("To logout: owlen logout <provider>");
return Ok(());
}
}
}
// Create provider based on settings and CLI args
let (client, model) = create_provider(
&settings,
args.model.as_deref(),
args.api_key.as_deref(),
args.ollama_url.as_deref(),
)?;
let opts = ChatOptions::new(&model);
// Initialize async engine infrastructure
let (tx, rx) = tokio::sync::mpsc::channel::<messages::Message>(100);
// Keep tx for future use (will be passed to UI/REPL)
let _tx = tx.clone();
// Create shared state
let state = Arc::new(tokio::sync::Mutex::new(crate::state::AppState::new()));
// Spawn the Engine Loop
let client_clone = client.clone();
let tx_clone = tx.clone();
let state_clone = state.clone();
tokio::spawn(async move {
engine::run_engine_loop(rx, tx_clone, client_clone, state_clone).await;
});
// Check if interactive mode (no prompt provided)
if args.prompt.is_empty() {
// Use TUI mode unless --no-tui flag is set or not a TTY
if !args.no_tui && atty::is(atty::Stream::Stdout) {
// Start background token refresh for long-running TUI sessions
let auth_manager = Arc::new(
auth_manager::AuthManager::new()
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?
);
let _token_refresher = auth_manager.clone().start_background_refresh();
// Launch TUI with multi-provider support
// Note: For now, TUI doesn't use plugin manager directly
// In the future, we'll integrate plugin commands into TUI
return ui::run_with_providers(auth_manager, perms, settings).await;
}
// Legacy text-based REPL
println!("🤖 Owlen Interactive Mode");
println!("Model: {}", opts.model);
println!("Mode: {:?}", settings.mode);
// Show loaded plugins
let plugins = app_context.plugin_manager.plugins();
if !plugins.is_empty() {
println!("Plugins: {} loaded", plugins.len());
}
println!("Type your message or /help for commands. Press Ctrl+C to exit.\n");
use std::io::{stdin, BufRead};
let stdin = stdin();
let mut lines = stdin.lock().lines();
let mut stats = agent_core::SessionStats::new();
let mut history = agent_core::SessionHistory::new();
let mut checkpoint_mgr = agent_core::CheckpointManager::new(
std::path::PathBuf::from(".owlen/checkpoints")
);
loop {
print!("> ");
std::io::stdout().flush().ok();
if let Some(Ok(line)) = lines.next() {
let input = line.trim();
if input.is_empty() {
continue;
}
// Handle slash commands
if input.starts_with('/') {
match input {
"/help" => {
println!("\n📖 Available Commands:");
println!(" /help - Show this help message");
println!(" /status - Show session status");
println!(" /permissions - Show permission settings");
println!(" /cost - Show token usage and timing");
println!(" /history - Show conversation history");
println!(" /checkpoint - Save current session state");
println!(" /checkpoints - List all saved checkpoints");
println!(" /rewind <id> - Restore session from checkpoint");
println!(" /clear - Clear conversation history");
println!(" /plugins - Show loaded plugins and commands");
println!(" /exit - Exit interactive mode");
// Show plugin commands if any are loaded
let plugin_commands = app_context.plugin_manager.all_commands();
if !plugin_commands.is_empty() {
println!("\n📦 Plugin Commands:");
for (name, _path) in &plugin_commands {
println!(" /{}", name);
}
}
}
"/status" => {
println!("\n📊 Session Status:");
println!(" Model: {}", opts.model);
println!(" Mode: {:?}", settings.mode);
println!(" Messages: {}", stats.total_messages);
println!(" Tools: {} calls", stats.total_tool_calls);
let elapsed = stats.start_time.elapsed().unwrap_or_default();
println!(" Uptime: {}", agent_core::SessionStats::format_duration(elapsed));
}
"/permissions" => {
println!("\n🔒 Permission Settings:");
println!(" Mode: {:?}", perms.mode());
println!("\n Read-only tools: Read, Grep, Glob, NotebookRead");
match perms.mode() {
permissions::Mode::Plan => {
println!(" ✅ Allowed (plan mode)");
println!("\n Write tools: Write, Edit, NotebookEdit");
println!(" ❓ Ask permission");
println!("\n System tools: Bash");
println!(" ❓ Ask permission");
}
permissions::Mode::AcceptEdits => {
println!(" ✅ Allowed");
println!("\n Write tools: Write, Edit, NotebookEdit");
println!(" ✅ Allowed (acceptEdits mode)");
println!("\n System tools: Bash");
println!(" ❓ Ask permission");
}
permissions::Mode::Code => {
println!(" ✅ Allowed");
println!("\n Write tools: Write, Edit, NotebookEdit");
println!(" ✅ Allowed (code mode)");
println!("\n System tools: Bash");
println!(" ✅ Allowed (code mode)");
}
}
}
"/cost" => {
println!("\n💰 Token Usage & Timing:");
println!(" Est. Tokens: ~{}", stats.estimated_tokens);
println!(" Total Time: {}", agent_core::SessionStats::format_duration(stats.total_duration));
if stats.total_messages > 0 {
let avg_time = stats.total_duration / stats.total_messages as u32;
println!(" Avg/Message: {}", agent_core::SessionStats::format_duration(avg_time));
}
println!("\n Note: Ollama is free - no cost incurred!");
}
"/history" => {
println!("\n📜 Conversation History:");
if history.user_prompts.is_empty() {
println!(" (No messages yet)");
} else {
for (i, (user, assistant)) in history.user_prompts.iter()
.zip(history.assistant_responses.iter()).enumerate() {
println!("\n [{}] User: {}", i + 1, user);
println!(" Assistant: {}...",
assistant.chars().take(100).collect::<String>());
}
}
if !history.tool_calls.is_empty() {
println!("\n Tool Calls: {}", history.tool_calls.len());
}
}
"/checkpoint" => {
let checkpoint_id = format!("checkpoint-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
);
match checkpoint_mgr.save_checkpoint(
checkpoint_id.clone(),
stats.clone(),
&history,
) {
Ok(checkpoint) => {
println!("\n💾 Checkpoint saved: {}", checkpoint_id);
if !checkpoint.file_diffs.is_empty() {
println!(" Files tracked: {}", checkpoint.file_diffs.len());
}
}
Err(e) => {
eprintln!("\n❌ Failed to save checkpoint: {}", e);
}
}
}
"/checkpoints" => {
match checkpoint_mgr.list_checkpoints() {
Ok(checkpoints) => {
if checkpoints.is_empty() {
println!("\n📋 No checkpoints saved yet");
} else {
println!("\n📋 Saved Checkpoints:");
for (i, cp_id) in checkpoints.iter().enumerate() {
println!(" [{}] {}", i + 1, cp_id);
}
println!("\n Use /rewind <id> to restore");
}
}
Err(e) => {
eprintln!("\n❌ Failed to list checkpoints: {}", e);
}
}
}
"/clear" => {
history.clear();
stats = agent_core::SessionStats::new();
println!("\n🗑️ Session history cleared!");
}
"/plugins" => {
let plugins = app_context.plugin_manager.plugins();
if plugins.is_empty() {
println!("\n📦 No plugins loaded");
println!(" Place plugins in:");
println!(" - ~/.config/owlen/plugins (user plugins)");
println!(" - .owlen/plugins (project plugins)");
} else {
println!("\n📦 Loaded Plugins:");
for plugin in plugins {
println!("\n {} v{}", plugin.manifest.name, plugin.manifest.version);
if let Some(desc) = &plugin.manifest.description {
println!(" {}", desc);
}
if let Some(author) = &plugin.manifest.author {
println!(" Author: {}", author);
}
let commands = plugin.all_command_names();
if !commands.is_empty() {
println!(" Commands: {}", commands.join(", "));
}
let agents = plugin.all_agent_names();
if !agents.is_empty() {
println!(" Agents: {}", agents.join(", "));
}
let skills = plugin.all_skill_names();
if !skills.is_empty() {
println!(" Skills: {}", skills.join(", "));
}
}
}
}
"/exit" => {
println!("\n👋 Goodbye!");
break;
}
cmd if cmd.starts_with("/rewind ") => {
let checkpoint_id = cmd.strip_prefix("/rewind ").unwrap().trim();
match checkpoint_mgr.rewind_to(checkpoint_id) {
Ok(restored_files) => {
println!("\n⏪ Rewound to checkpoint: {}", checkpoint_id);
if !restored_files.is_empty() {
println!(" Restored files:");
for file in restored_files {
println!(" - {}", file.display());
}
}
// Load the checkpoint to restore history and stats
if let Ok(checkpoint) = checkpoint_mgr.load_checkpoint(checkpoint_id) {
stats = checkpoint.stats;
history.user_prompts = checkpoint.user_prompts;
history.assistant_responses = checkpoint.assistant_responses;
history.tool_calls = checkpoint.tool_calls;
println!(" Session state restored");
}
}
Err(e) => {
eprintln!("\n❌ Failed to rewind: {}", e);
}
}
}
_ => {
println!("\n❌ Unknown command: {}", input);
println!(" Type /help for available commands");
}
}
continue;
}
// Regular message - run through agent loop
history.add_user_message(input.to_string());
let start = SystemTime::now();
let ctx = agent_core::ToolContext::new();
match agent_core::run_agent_loop(&client, input, &opts, &perms, &ctx).await {
Ok(response) => {
println!("\n{}", response);
history.add_assistant_message(response.clone());
// Update stats
let duration = start.elapsed().unwrap_or_default();
let tokens = (input.len() + response.len()) / 4; // Rough estimate
stats.record_message(tokens, duration);
}
Err(e) => {
eprintln!("\n❌ Error: {}", e);
}
}
} else {
break;
}
}
return Ok(());
}
// Non-interactive mode - process single prompt
let prompt = args.prompt.join(" ");
let start_time = SystemTime::now();
// Handle different output formats
let ctx = agent_core::ToolContext::new();
match output_format {
OutputFormat::Text => {
// Text format: Use agent orchestrator with tool calling
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
println!("{}", response);
}
OutputFormat::Json => {
// JSON format: Use agent loop and output as JSON
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
let output = SessionOutput {
session_id,
messages: vec![
serde_json::json!({"role": "user", "content": prompt}),
serde_json::json!({"role": "assistant", "content": response}),
],
stats: Stats {
total_tokens: estimated_tokens,
prompt_tokens: Some((prompt.len() / 4) as u64),
completion_tokens: Some((response.len() / 4) as u64),
duration_ms,
},
result: None,
tool: None,
};
println!("{}", serde_json::to_string(&output)?);
}
OutputFormat::StreamJson => {
// Stream-JSON format: emit session_start, response, and session_end
let session_start = StreamEvent {
event_type: "session_start".to_string(),
session_id: Some(session_id.clone()),
content: None,
stats: None,
};
println!("{}", serde_json::to_string(&session_start)?);
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
let chunk_event = StreamEvent {
event_type: "chunk".to_string(),
session_id: None,
content: Some(response.clone()),
stats: None,
};
println!("{}", serde_json::to_string(&chunk_event)?);
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
let session_end = StreamEvent {
event_type: "session_end".to_string(),
session_id: None,
content: None,
stats: Some(Stats {
total_tokens: estimated_tokens,
prompt_tokens: Some((prompt.len() / 4) as u64),
completion_tokens: Some((response.len() / 4) as u64),
duration_ms,
}),
};
println!("{}", serde_json::to_string(&session_end)?);
}
}
Ok(())
}