Skip to main content

oxi/
lib.rs

1#![warn(missing_docs)]
2#![warn(clippy::unwrap_used)]
3#![allow(unknown_lints)]
4
5//! oxi: CLI coding harness
6//!
7//! This crate provides the main application logic for the oxi CLI.
8
9// ─── Root-level entry modules ───────────────────────────────────────────────
10// cli must be pub for main.rs binary
11pub mod bootstrap;
12pub mod cli;
13pub mod main_dispatch;
14pub mod print_mode;
15pub mod services;
16pub mod setup_wizard;
17pub mod store;
18
19// ─── Directory groups ───────────────────────────────────────────────────────
20pub(crate) mod app;
21pub(crate) mod context;
22pub mod extensions; // public for main.rs
23pub(crate) mod infra;
24pub(crate) mod media;
25pub(crate) mod prompt;
26pub(crate) mod rpc_mode;
27pub(crate) mod skills;
28pub mod storage; // public for main.rs (packages)
29// Re-exports from storage for main.rs
30pub use storage::packages::PackageManager;
31pub use storage::packages::ResourceKind;
32pub mod tools;
33pub mod tui; // public for main.rs
34pub(crate) mod ui;
35pub(crate) mod util;
36
37///
38/// This is the **new entry point** for oxi-cli run modes. It uses
39/// `oxi-fs` adapters and `OxiBuilder::with_port_*` to construct an
40/// `Oxi` with persistence, auth, config, and skills wired. The legacy
41/// `App::new` path is still used by the interactive TUI during the
42/// migration period.
43///
44/// # Example
45///
46/// ```no_run
47/// use oxi::build_oxi_engine;
48/// # fn _example() -> anyhow::Result<()> {
49/// let oxi = build_oxi_engine()?;
50/// println!("providers: {}", oxi.providers().names().len());
51/// # Ok(()) }
52/// ```
53pub fn build_oxi_engine() -> anyhow::Result<oxi_sdk::Oxi> {
54    let paths = services::OxiPaths::default_paths()?;
55    services::build_oxi(&paths)
56}
57
58/// Self-check the wired port implementations. Prints a one-line summary
59/// per port and returns `Ok(())` if all are reachable.
60///
61/// Triggered by the `OXI_PORT_CHECK=1` environment variable from
62/// `oxi-cli/src/main.rs`. Useful for verifying the new composition root
63/// without disturbing the legacy `App::new` path.
64pub async fn run_port_check() -> anyhow::Result<()> {
65    let oxi = build_oxi_engine()?;
66    let ports = oxi.ports();
67
68    // State
69    let entries = ports.state.list("").await?;
70    println!("[state]    entries: {}", entries.len());
71
72    // Auth
73    let providers = ports.auth.list_providers().await?;
74    println!("[auth]     providers with credentials: {:?}", providers);
75
76    // Config
77    let keys = ports.config.list()?;
78    println!("[config]   keys: {}", keys.len());
79
80    // Skills
81    let skills = ports.skills.list().await?;
82    println!("[skills]   {} skill(s) discovered", skills.len());
83    for s in &skills {
84        println!("           - {}: {}", s.name, s.description);
85    }
86
87    // Event bus / memory / etc — all noop unless registered
88    let _ = ports
89        .event_bus
90        .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
91        .await;
92    println!("[event-bus] publish ok (noop bus if not registered)");
93
94    println!("\nport check: ok");
95    Ok(())
96}
97
98/// Context for compaction operations, passed to extension hooks
99#[derive(Debug, Clone)]
100pub struct CompactionContext {
101    /// Messages being compacted
102    pub messages_count: usize,
103    /// Estimated tokens before compaction
104    pub tokens_before: usize,
105    /// Target token count after compaction
106    pub target_tokens: usize,
107    /// Strategy being used
108    pub strategy: String,
109}
110
111impl CompactionContext {
112    /// Create a new compaction context
113    pub fn new(
114        messages_count: usize,
115        tokens_before: usize,
116        target_tokens: usize,
117        strategy: impl Into<String>,
118    ) -> Self {
119        Self {
120            messages_count,
121            tokens_before,
122            target_tokens,
123            strategy: strategy.into(),
124        }
125    }
126
127    /// Get expected compression ratio
128    pub fn compression_ratio(&self) -> f32 {
129        if self.tokens_before == 0 {
130            return 1.0;
131        }
132        self.target_tokens as f32 / self.tokens_before as f32
133    }
134}
135
136// ─── Module-level imports ────────────────────────────────────────────────────
137use crate::store::settings::Settings;
138use anyhow::{Error, Result};
139use oxi_agent::{Agent, AgentConfig, AgentEvent};
140use parking_lot::RwLock;
141use skills::SkillManager;
142use std::sync::Arc;
143
144// ─── Application state ───────────────────────────────────────────────────────
145
146/// Application state and entry point.
147///
148/// Holds an `Oxi` engine (composition root) and a single `Agent` built
149/// from it. The legacy `App::new(settings)` constructor is **gone**;
150/// use [`App::from_oxi`] with a wired `Oxi` from
151/// [`build_oxi_engine`].
152pub struct App {
153    oxi: oxi_sdk::Oxi,
154    agent: Arc<Agent>,
155    settings: Settings,
156    skills: RwLock<SkillManager>,
157    active_skills: RwLock<Vec<String>>,
158    wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
159    questionnaire_bridge:
160        Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
161    /// Shared local issue store (`.oxi/issues/`). Cloned cheaply (inner `Arc`).
162    /// Used by the agent `issue` tool, the TUI indicator, and the `oxi issue`
163    /// CLI subcommand.
164    issue_store: Option<crate::store::issues::FileIssueStore>,
165}
166
167/// Context for compaction operations, passed to extension hooks
168// ─── System prompt builder ───────────────────────────────────────────────────
169fn build_system_prompt(
170    thinking_level: crate::store::settings::ThinkingLevel,
171    skill_contents: &[String],
172) -> String {
173    let skills: Vec<prompt::system_prompt::Skill> = skill_contents
174        .iter()
175        .enumerate()
176        .map(|(i, content)| prompt::system_prompt::Skill {
177            name: format!("skill-{}", i),
178            content: content.clone(),
179        })
180        .collect();
181
182    let options = prompt::system_prompt::BuildSystemPromptOptions {
183        custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
184        skills,
185        cwd: std::env::current_dir()
186            .map(|p| p.to_string_lossy().to_string())
187            .unwrap_or_default(),
188        ..Default::default()
189    };
190
191    prompt::system_prompt::build_system_prompt(&options)
192}
193
194// ─── App implementation ─────────────────────────────────────────────────────
195
196impl App {
197    /// Build an `App` from a wired `Oxi` engine and a settings object.
198    ///
199    /// The `Oxi` should be created via [`build_oxi_engine`] (or
200    /// `services::build_oxi`) so that all 11 ports are wired. The
201    /// settings hold the user's runtime configuration (model, thinking
202    /// level, etc.).
203    pub async fn from_oxi(oxi: oxi_sdk::Oxi, settings: Settings) -> Result<Self> {
204        let model_id = settings.effective_model(None).unwrap_or_default();
205        let provider_name = settings
206            .effective_provider(None)
207            .unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
208
209        // Pull the API key from the wired port, not from oxi_store.
210        let api_key = oxi.ports().auth.get_api_key(&provider_name).await?;
211
212        let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
213            dirs::home_dir()
214                .unwrap_or_default()
215                .join(".oxi")
216                .join("skills")
217        });
218        let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
219            tracing::debug!("Skills not loaded: {}", e);
220            SkillManager::new()
221        });
222
223        let system_prompt = build_system_prompt(settings.thinking_level, &[]);
224        let compaction_strategy = if settings.auto_compaction {
225            oxi_sdk::CompactionStrategy::Threshold(0.8)
226        } else {
227            oxi_sdk::CompactionStrategy::Disabled
228        };
229
230        let config = AgentConfig {
231            name: "oxi".to_string(),
232            description: Some("oxi CLI agent".to_string()),
233            model_id: model_id.clone(),
234            system_prompt: Some(system_prompt),
235            timeout_seconds: settings.tool_timeout_seconds,
236            temperature: settings.effective_temperature(),
237            max_tokens: settings.effective_max_tokens(),
238            compaction_strategy,
239            compaction_instruction: None,
240            context_window: 128_000,
241            api_key,
242            workspace_dir: Some(
243                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
244            ),
245            output_mode: None,
246            provider_options: None,
247        };
248
249        // Build the agent via the SDK's AgentBuilder — no manual wiring.
250        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
251        let agent = oxi
252            .agent(config)
253            .workspace(cwd)
254            .build()
255            .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
256        let agent = Arc::new(agent);
257
258        let bridge =
259            std::sync::Arc::new(oxi_agent::tools::questionnaire::QuestionnaireBridge::new());
260        let questionnaire_tool =
261            oxi_agent::tools::questionnaire::QuestionnaireTool::new(bridge.clone());
262        agent
263            .tools()
264            .register_arc(std::sync::Arc::new(questionnaire_tool));
265
266        // Open the local issue store rooted at the project (`.oxi/issues/`).
267        // Best-effort: if the directory cannot be resolved, issues are simply
268        // unavailable — the app still works without them. The `/issue` slash
269        // command surfaces a clear error in that case.
270        let issue_store = std::env::current_dir()
271            .ok()
272            .map(|cwd| crate::store::issues::FileIssueStore::open_from_cwd(&cwd))
273            .and_then(|r| {
274                r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
275                    .ok()
276            });
277
278        // Register the `issue` agent tool when the store is available.
279        if let Some(store) = issue_store.clone() {
280            let tool = std::sync::Arc::new(crate::tools::IssueTool::new(store));
281            agent.tools().register_arc(tool);
282        }
283
284        Ok(Self {
285            oxi,
286            agent,
287            settings,
288            skills: RwLock::new(skills),
289            active_skills: RwLock::new(Vec::new()),
290            wasm_ext: None,
291            questionnaire_bridge: Some(bridge),
292            issue_store,
293        })
294    }
295
296    /// Get the current settings
297    pub fn settings(&self) -> &Settings {
298        &self.settings
299    }
300
301    /// Set the WASM extension manager
302    pub fn set_wasm_ext(
303        &mut self,
304        ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
305    ) {
306        self.wasm_ext = ext;
307    }
308
309    /// Get the WASM extension manager
310    pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
311        self.wasm_ext.as_ref()
312    }
313
314    /// Get a clone of the local issue store, if one was opened successfully.
315    pub fn issue_store(&self) -> Option<crate::store::issues::FileIssueStore> {
316        self.issue_store.clone()
317    }
318
319    /// Get a reference to the underlying agent.
320    pub fn agent(&self) -> Arc<Agent> {
321        Arc::clone(&self.agent)
322    }
323
324    /// Get the tool registry (for registering extension tools)
325    pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
326        self.agent.tools()
327    }
328
329    /// Get the questionnaire bridge, if initialized.
330    pub fn questionnaire_bridge(
331        &self,
332    ) -> Option<&std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>> {
333        self.questionnaire_bridge.as_ref()
334    }
335
336    /// Get a reference to the skill manager
337    pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
338        self.skills.read()
339    }
340
341    /// Activate a skill by name. Returns an error string if not found.
342    pub fn activate_skill(&self, name: &str) -> Result<(), String> {
343        {
344            let skills = self.skills.read();
345            if skills.get(name).is_none() {
346                return Err(format!("Skill '{}' not found", name));
347            }
348        }
349        let name_lower = name.to_lowercase();
350        {
351            let mut active = self.active_skills.write();
352            if !active.contains(&name_lower) {
353                active.push(name_lower);
354            }
355        }
356        self.rebuild_system_prompt();
357        Ok(())
358    }
359
360    /// Deactivate a skill by name.
361    pub fn deactivate_skill(&self, name: &str) {
362        let name_lower = name.to_lowercase();
363        {
364            let mut active = self.active_skills.write();
365            active.retain(|n| n != &name_lower);
366        }
367        self.rebuild_system_prompt();
368    }
369
370    /// List currently active skill names
371    pub fn active_skills(&self) -> Vec<String> {
372        self.active_skills.read().clone()
373    }
374
375    /// Rebuild the system prompt with current active skills
376    fn rebuild_system_prompt(&self) {
377        let active = self.active_skills.read();
378        let skills = self.skills.read();
379        let contents: Vec<String> = active
380            .iter()
381            .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
382            .collect();
383        let prompt = build_system_prompt(self.settings.thinking_level, &contents);
384        self.agent.set_system_prompt(prompt);
385    }
386
387    /// Get a clone of the current state
388    pub fn agent_state(&self) -> oxi_agent::AgentState {
389        self.agent.state()
390    }
391
392    /// Run a single prompt and return the response
393    pub async fn run_prompt(&self, prompt: String) -> Result<String> {
394        let (response, _events) = self.agent.run(prompt).await?;
395        Ok(response.content)
396    }
397
398    /// Run a prompt with event callback
399    pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
400    where
401        F: FnMut(AgentEvent) + Send + 'static,
402    {
403        self.agent.run_streaming(prompt, on_event).await?;
404        let state = self.agent_state();
405        for msg in state.messages.iter().rev() {
406            if let oxi_sdk::Message::Assistant(a) = msg {
407                return Ok(a.text_content());
408            }
409        }
410        Ok(String::new())
411    }
412
413    /// Reset the conversation
414    pub fn reset(&self) {
415        self.agent.reset();
416    }
417
418    /// Switch the model used for future LLM calls.
419    pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
420        let parts: Vec<&str> = model_id.split('/').collect();
421        let provider = parts
422            .first()
423            .map(|s| s.to_string())
424            .unwrap_or_else(|| "anthropic".to_string());
425        let api_key = self.oxi.ports().auth.get_api_key(&provider).await?;
426        let _ = self.agent.switch_model(model_id, api_key);
427        Ok(())
428    }
429
430    /// Get the current model ID
431    pub fn model_id(&self) -> String {
432        self.agent.model_id()
433    }
434}