1140 lines
48 KiB
Rust
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(())
|
|
}
|