Skip to main content

oxi/
lib.rs

1#![warn(missing_docs)]
2// Relax two test-idiom lints under `cfg(test)` so `cargo clippy --all-targets`
3// stays clean without weakening the shipped library:
4//   - `clippy::unwrap_used` — `unwrap()`/`unwrap_err()` are idiomatic in tests;
5//     shipped (non-test) code still `warn`s on it (see the line below).
6//   - `clippy::field_reassign_with_default` — the `let mut x = X::default();
7//     x.f = ..;` test-setup pattern.
8#![warn(clippy::unwrap_used)]
9#![cfg_attr(test, allow(clippy::unwrap_used, clippy::field_reassign_with_default))]
10#![allow(unknown_lints)]
11
12//! oxi: CLI coding harness
13//!
14//! This crate provides the main application logic for the oxi CLI.
15
16// ─── Root-level entry modules ───────────────────────────────────────────────
17// cli must be pub for main.rs binary
18pub mod bootstrap;
19pub mod cli;
20pub mod internal_urls;
21pub mod main_dispatch;
22pub mod mcp_credentials;
23pub mod print_mode;
24pub mod services;
25pub mod setup_wizard;
26pub mod store;
27
28// ─── Directory groups ───────────────────────────────────────────────────────
29pub(crate) mod app;
30pub(crate) mod context;
31pub mod discovery;
32pub mod extensions; // public for main.rs
33pub(crate) mod infra;
34pub(crate) mod media;
35pub(crate) mod prompt;
36pub(crate) mod rpc_mode;
37pub(crate) mod skills;
38pub mod storage; // public for main.rs (packages)
39// Re-exports from storage for main.rs
40pub use storage::packages::PackageManager;
41pub use storage::packages::ResourceKind;
42pub mod tools;
43pub mod tui; // public for main.rs
44pub(crate) mod ui;
45pub(crate) mod util;
46
47///
48/// This is the **new entry point** for oxi-cli run modes. It uses
49/// `oxi-fs` adapters and `OxiBuilder::with_port_*` to construct an
50/// `Oxi` with persistence, auth, config, and skills wired. The legacy
51/// `App::new` path is still used by the interactive TUI during the
52/// migration period.
53///
54/// # Example
55///
56/// ```no_run
57/// use oxi::build_oxi_engine;
58/// # async fn _example() -> anyhow::Result<()> {
59/// let oxi = build_oxi_engine().await?;
60/// println!("providers: {}", oxi.providers().names().len());
61/// # Ok(()) }
62/// ```
63pub async fn build_oxi_engine() -> anyhow::Result<oxi_sdk::Oxi> {
64    let paths = services::OxiPaths::default_paths()?;
65    services::build_oxi(&paths).await
66}
67
68/// Self-check the wired port implementations. Prints a one-line summary
69/// per port and returns `Ok(())` if all are reachable.
70///
71/// Triggered by the `OXI_PORT_CHECK=1` environment variable from
72/// `oxi-cli/src/main.rs`. Useful for verifying the new composition root
73/// without disturbing the legacy `App::new` path.
74pub async fn run_port_check() -> anyhow::Result<()> {
75    let oxi = build_oxi_engine().await?;
76    let ports = oxi.ports();
77
78    // State
79    let entries = ports.state.list("").await?;
80    println!("[state]    entries: {}", entries.len());
81
82    // Auth
83    let providers = ports.auth.list_providers().await?;
84    println!("[auth]     providers with credentials: {:?}", providers);
85
86    // Config
87    let keys = ports.config.list()?;
88    println!("[config]   keys: {}", keys.len());
89
90    // Skills
91    let skills = ports.skills.list().await?;
92    println!("[skills]   {} skill(s) discovered", skills.len());
93    for s in &skills {
94        println!("           - {}: {}", s.name, s.description);
95    }
96
97    // Event bus / memory / etc — all noop unless registered
98    let _ = ports
99        .event_bus
100        .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
101        .await;
102    println!("[event-bus] publish ok (noop bus if not registered)");
103
104    println!("\nport check: ok");
105    Ok(())
106}
107
108/// Context for compaction operations, passed to extension hooks
109#[derive(Debug, Clone)]
110pub struct CompactionContext {
111    /// Messages being compacted
112    pub messages_count: usize,
113    /// Estimated tokens before compaction
114    pub tokens_before: usize,
115    /// Target token count after compaction
116    pub target_tokens: usize,
117    /// Strategy being used
118    pub strategy: String,
119}
120
121impl CompactionContext {
122    /// Create a new compaction context
123    pub fn new(
124        messages_count: usize,
125        tokens_before: usize,
126        target_tokens: usize,
127        strategy: impl Into<String>,
128    ) -> Self {
129        Self {
130            messages_count,
131            tokens_before,
132            target_tokens,
133            strategy: strategy.into(),
134        }
135    }
136
137    /// Get expected compression ratio
138    pub fn compression_ratio(&self) -> f32 {
139        if self.tokens_before == 0 {
140            return 1.0;
141        }
142        self.target_tokens as f32 / self.tokens_before as f32
143    }
144}
145
146// ─── Module-level imports ────────────────────────────────────────────────────
147use crate::store::settings::Settings;
148use anyhow::{Error, Result};
149use oxi_agent::{Agent, AgentConfig, AgentEvent};
150use parking_lot::RwLock;
151use skills::SkillManager;
152use std::sync::Arc;
153
154// ─── Application state ───────────────────────────────────────────────────────
155
156/// Application state and entry point.
157///
158/// Holds an `Oxi` engine (composition root) and a single `Agent` built
159/// from it. The legacy `App::new(settings)` constructor is **gone**;
160/// use [`App::from_oxi`] with a wired `Oxi` from
161/// [`build_oxi_engine`].
162pub struct App {
163    oxi: oxi_sdk::Oxi,
164    agent: Arc<Agent>,
165    settings: Settings,
166    skills: RwLock<SkillManager>,
167    active_skills: RwLock<Vec<String>>,
168    wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
169    ask_bridge: Option<std::sync::Arc<oxi_agent::tools::ask::AskBridge>>,
170    /// Shared local issue store (`.oxi/issues/`). Cloned cheaply (inner `Arc`).
171    /// Used by the agent `issue` tool, the TUI indicator, and the `oxi issue`
172    /// CLI subcommand.
173    issue_store: Option<crate::store::issues::FileIssueStore>,
174    /// Process-wide liveness identity used by every issue-ownership surface
175    /// in this process (agent tool's `ToolContext.session_id`, TUI panel,
176    /// slash-command `/issue` handlers). See
177    /// [`crate::store::issues::liveness::TUI_OWNERSHIP_ID`] for the TUI value.
178    ownership_session_id: String,
179    /// Alive-lock held for the lifetime of `App`. Dropped with `App`, releasing
180    /// the OS-held flock so any other process sees this session as dead once
181    /// we exit (including `kill -9` / crash / normal exit). Only held when
182    /// `issue_store` is available.
183    #[allow(dead_code)]
184    liveness_guard: Option<crate::store::issues::liveness::AliveGuard>,
185}
186
187/// Context for compaction operations, passed to extension hooks
188// ─── System prompt builder ───────────────────────────────────────────────────
189fn build_system_prompt(
190    thinking_level: crate::store::settings::ThinkingLevel,
191    skill_contents: &[String],
192) -> String {
193    let skills: Vec<prompt::system_prompt::Skill> = skill_contents
194        .iter()
195        .enumerate()
196        .map(|(i, content)| prompt::system_prompt::Skill {
197            name: format!("skill-{}", i),
198            content: content.clone(),
199        })
200        .collect();
201
202    let options = prompt::system_prompt::BuildSystemPromptOptions {
203        custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
204        skills,
205        cwd: std::env::current_dir()
206            .map(|p| p.to_string_lossy().to_string())
207            .unwrap_or_default(),
208        ..Default::default()
209    };
210
211    prompt::system_prompt::build_system_prompt(&options)
212}
213
214// ─── App implementation ─────────────────────────────────────────────────────
215
216impl App {
217    /// Build an `App` from a wired `Oxi` engine and a settings object.
218    ///
219    /// The `Oxi` should be created via [`build_oxi_engine`] (or
220    /// `services::build_oxi`) so that all 11 ports are wired. The
221    /// settings hold the user's runtime configuration (model, thinking
222    /// level, etc.).
223    ///
224    /// `ownership_session_id` is the per-process liveness identity used by
225    /// the agent's `issue` tool (`ToolContext.session_id`), the TUI panel,
226    /// and the `/issue` slash command. In TUI mode this MUST equal
227    /// [`crate::store::issues::liveness::TUI_OWNERSHIP_ID`] so the panel and
228    /// agent see the same flock holder. In print / RPC mode, a stable
229    /// process-scoped id (e.g. `proc-<pid>-<uuid>`) is appropriate.
230    /// When `issue_store` is available, an `flock` is acquired under this
231    /// id for the lifetime of the returned `App`.
232    pub async fn from_oxi(
233        oxi: oxi_sdk::Oxi,
234        settings: Settings,
235        ownership_session_id: String,
236    ) -> Result<Self> {
237        let model_id = settings.effective_model(None).unwrap_or_default();
238        let provider_name = settings
239            .effective_provider(None)
240            .unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
241
242        // Pull the API key from the wired port, not from oxi_store.
243        let api_key = oxi.ports().auth.get_api_key(&provider_name).await?;
244
245        let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
246            dirs::home_dir()
247                .unwrap_or_default()
248                .join(".oxi")
249                .join("skills")
250        });
251        let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
252            tracing::debug!("Skills not loaded: {}", e);
253            SkillManager::new()
254        });
255
256        let system_prompt = build_system_prompt(settings.thinking_level, &[]);
257        let compaction_strategy = if settings.auto_compaction {
258            oxi_sdk::CompactionStrategy::Threshold(0.8)
259        } else {
260            oxi_sdk::CompactionStrategy::Disabled
261        };
262
263        let config = AgentConfig {
264            name: "oxi".to_string(),
265            description: Some("oxi CLI agent".to_string()),
266            model_id: model_id.clone(),
267            system_prompt: Some(system_prompt),
268            timeout_seconds: settings.tool_timeout_seconds,
269            temperature: settings.effective_temperature(),
270            max_tokens: settings.effective_max_tokens(),
271            compaction_strategy,
272            compaction_instruction: None,
273            context_window: 128_000,
274            api_key,
275            workspace_dir: Some(
276                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
277            ),
278            output_mode: None,
279            provider_options: None,
280            session_id: Some(ownership_session_id.clone()),
281            ttsr_engine: None,
282            memory: None,
283            todo: None,
284            agent_pool: None,
285            ..Default::default()
286        };
287
288        // Build the agent via the SDK's AgentBuilder — no manual wiring.
289        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
290        let agent = oxi
291            .agent(config)
292            .workspace(cwd)
293            .build()
294            .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
295        let agent = Arc::new(agent);
296
297        let ask_timeout = if settings.ask_timeout_secs > 0 {
298            Some(std::time::Duration::from_secs(settings.ask_timeout_secs))
299        } else {
300            None
301        };
302        let bridge =
303            std::sync::Arc::new(oxi_agent::tools::ask::AskBridge::with_timeout(ask_timeout));
304        let ask_tool = oxi_agent::tools::ask::AskTool::new(bridge.clone());
305        agent.tools().register_arc(std::sync::Arc::new(ask_tool));
306        // Open the local issue store rooted at the project (`.oxi/issues/`).
307        // Best-effort: if the directory cannot be resolved, issues are simply
308        // unavailable — the app still works without them. The `/issue` slash
309        // command surfaces a clear error in that case.
310        let issue_store = std::env::current_dir()
311            .ok()
312            .map(|cwd| crate::store::issues::FileIssueStore::open_from_cwd(&cwd))
313            .and_then(|r| {
314                r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
315                    .ok()
316            });
317
318        // Register the `issue` agent tool when the store is available.
319        if let Some(store) = issue_store.clone() {
320            let tool = std::sync::Arc::new(crate::tools::IssueTool::new(store));
321            agent.tools().register_arc(tool);
322        }
323
324        Ok(Self {
325            oxi,
326            agent,
327            settings,
328            skills: RwLock::new(skills),
329            active_skills: RwLock::new(Vec::new()),
330            wasm_ext: None,
331            ask_bridge: Some(bridge),
332            issue_store,
333            ownership_session_id,
334            liveness_guard: None, // set below once issue_store is known
335        })
336        .map(|mut app| {
337            // Acquire the process-wide liveness flock now that issue_store exists.
338            // Best-effort: another live process already holds the lock is non-fatal;
339            // we still expose ownership_session_id so callers can detect the conflict.
340            app.liveness_guard =
341                acquire_ownership_guard(app.issue_store.as_ref(), &app.ownership_session_id);
342            app
343        })
344    }
345
346    /// Per-process liveness identity. Used by the agent's `issue` tool and any
347    /// other surface that gates on `is_session_alive`.
348    pub fn ownership_session_id(&self) -> &str {
349        &self.ownership_session_id
350    }
351
352    /// True iff `App` holds a live liveness flock under `ownership_session_id`.
353    /// False when there is no `issue_store` (e.g. headless test) or when another
354    /// live process already holds the lock (the assignment feature will surface
355    /// `Assigned` errors in that case — by design).
356    pub fn has_liveness_lock(&self) -> bool {
357        self.liveness_guard.is_some()
358    }
359
360    /// Get the current settings
361    pub fn settings(&self) -> &Settings {
362        &self.settings
363    }
364
365    /// Set the WASM extension manager
366    pub fn set_wasm_ext(
367        &mut self,
368        ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
369    ) {
370        self.wasm_ext = ext;
371    }
372
373    /// Get the WASM extension manager
374    pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
375        self.wasm_ext.as_ref()
376    }
377
378    /// Get a clone of the local issue store, if one was opened successfully.
379    pub fn issue_store(&self) -> Option<crate::store::issues::FileIssueStore> {
380        self.issue_store.clone()
381    }
382
383    /// Get a reference to the underlying `Oxi` engine. The catalog port and
384    /// other ports are accessible through it.
385    pub fn oxi(&self) -> &oxi_sdk::Oxi {
386        &self.oxi
387    }
388
389    /// Get a reference to the underlying agent.
390    pub fn agent(&self) -> Arc<Agent> {
391        Arc::clone(&self.agent)
392    }
393
394    /// Get the tool registry (for registering extension tools)
395    pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
396        self.agent.tools()
397    }
398
399    /// Get the ask bridge, if initialized.
400    pub fn ask_bridge(&self) -> Option<&std::sync::Arc<oxi_agent::tools::ask::AskBridge>> {
401        self.ask_bridge.as_ref()
402    }
403
404    /// Get a reference to the skill manager
405    pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
406        self.skills.read()
407    }
408
409    /// Activate a skill by name. Returns an error string if not found.
410    pub fn activate_skill(&self, name: &str) -> Result<(), String> {
411        {
412            let skills = self.skills.read();
413            if skills.get(name).is_none() {
414                return Err(format!("Skill '{}' not found", name));
415            }
416        }
417        let name_lower = name.to_lowercase();
418        {
419            let mut active = self.active_skills.write();
420            if !active.contains(&name_lower) {
421                active.push(name_lower);
422            }
423        }
424        self.rebuild_system_prompt();
425        Ok(())
426    }
427
428    /// Deactivate a skill by name.
429    pub fn deactivate_skill(&self, name: &str) {
430        let name_lower = name.to_lowercase();
431        {
432            let mut active = self.active_skills.write();
433            active.retain(|n| n != &name_lower);
434        }
435        self.rebuild_system_prompt();
436    }
437
438    /// List currently active skill names
439    pub fn active_skills(&self) -> Vec<String> {
440        self.active_skills.read().clone()
441    }
442
443    /// Rebuild the system prompt with current active skills
444    fn rebuild_system_prompt(&self) {
445        let active = self.active_skills.read();
446        let skills = self.skills.read();
447        let contents: Vec<String> = active
448            .iter()
449            .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
450            .collect();
451        let prompt = build_system_prompt(self.settings.thinking_level, &contents);
452        self.agent.set_system_prompt(prompt);
453    }
454
455    /// Get a clone of the current state
456    pub fn agent_state(&self) -> oxi_agent::AgentState {
457        self.agent.state()
458    }
459
460    /// Run a single prompt and return the response
461    pub async fn run_prompt(&self, prompt: String) -> Result<String> {
462        let (response, _events) = self.agent.run(prompt).await?;
463        Ok(response.content)
464    }
465
466    /// Run a prompt with event callback
467    pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
468    where
469        F: FnMut(AgentEvent) + Send + 'static,
470    {
471        self.agent.run_streaming(prompt, on_event).await?;
472        let state = self.agent_state();
473        for msg in state.messages.iter().rev() {
474            if let oxi_sdk::Message::Assistant(a) = msg {
475                return Ok(a.text_content());
476            }
477        }
478        Ok(String::new())
479    }
480
481    /// Reset the conversation
482    pub fn reset(&self) {
483        self.agent.reset();
484    }
485
486    /// Switch the model used for future LLM calls.
487    pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
488        let parts: Vec<&str> = model_id.split('/').collect();
489        let provider = parts
490            .first()
491            .map(|s| s.to_string())
492            .unwrap_or_else(|| "anthropic".to_string());
493        let api_key = self.oxi.ports().auth.get_api_key(&provider).await?;
494        let _ = self.agent.switch_model(model_id, api_key);
495        Ok(())
496    }
497
498    /// Get the current model ID
499    pub fn model_id(&self) -> String {
500        self.agent.model_id()
501    }
502}
503
504/// Acquire the process-wide liveness flock for `ownership_id` under the issue
505/// store's `.alive/` directory.
506///
507/// Returns `None` (no lock) when there is no issue store or when another live
508/// process already holds the lock — both non-fatal; the caller can still read
509/// `ownership_session_id` and the assignment feature will surface `Assigned`
510/// errors if contention actually occurs.
511///
512/// Extracted from `App::from_oxi` so the single-lock invariant (defect #13 fix)
513/// can be unit-tested without standing up a full `Oxi` engine.
514pub(crate) fn acquire_ownership_guard(
515    issue_store: Option<&crate::store::issues::FileIssueStore>,
516    ownership_id: &str,
517) -> Option<crate::store::issues::liveness::AliveGuard> {
518    let store = issue_store?;
519    if ownership_id.is_empty() {
520        // Defensive: never hold a lock under the empty string — that was the
521        // #13 bug shape (empty owner is never alive, so ownership was bypassed).
522        return None;
523    }
524    crate::store::issues::liveness::acquire(&store.issues_dir(), ownership_id).ok()
525}
526
527#[cfg(test)]
528mod tests {
529    //! P0 regression: `App` must hold exactly one liveness flock under its
530    //! ownership identity. We test the extracted `acquire_ownership_guard`
531    //! helper (the single chokepoint `from_oxi` delegates to) rather than
532    //! standing up a full `Oxi` engine.
533    use super::*;
534    use crate::store::issues::FileIssueStore;
535    use crate::store::issues::liveness;
536
537    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
538        let tmp = tempfile::tempdir().unwrap();
539        let dir = tmp.path().join(".oxi").join("issues");
540        std::fs::create_dir_all(&dir).unwrap();
541        (tmp, FileIssueStore::open(dir).unwrap())
542    }
543
544    #[test]
545    fn app_holds_single_liveness_lock() {
546        // The #13 invariant: acquiring the ownership guard makes the session
547        // live under that identity, and a second acquire under the SAME id
548        // fails (one flock per identity — single lock).
549        let (_tmp, store) = tmp_store();
550        let dir = store.issues_dir();
551        let id = "proc-test-app";
552
553        let guard = acquire_ownership_guard(Some(&store), id);
554        assert!(
555            guard.is_some(),
556            "App must acquire the liveness lock for its ownership id"
557        );
558        assert!(
559            liveness::is_session_alive(&dir, id),
560            "after acquire, the session must be live"
561        );
562
563        // While held, the same identity cannot be acquired again — single lock.
564        let second = liveness::acquire(&dir, id);
565        assert!(second.is_err(), "second acquire under same id must fail");
566
567        drop(guard);
568        assert!(
569            !liveness::is_session_alive(&dir, id),
570            "dropping App's guard releases the lock"
571        );
572    }
573
574    #[test]
575    fn acquire_returns_none_without_store() {
576        // No issue store (headless/test) → no lock. Not an error.
577        let dir = tempfile::tempdir().unwrap();
578        let id = "proc-x";
579        assert!(acquire_ownership_guard(None, id).is_none());
580        let _ = dir; // no store created
581    }
582
583    #[test]
584    fn acquire_rejects_empty_ownership_id() {
585        // Defensive guard against the #13 bug shape: never hold a lock under
586        // the empty string (it's never alive, so ownership would be bypassed).
587        let (_tmp, store) = tmp_store();
588        assert!(
589            acquire_ownership_guard(Some(&store), "").is_none(),
590            "empty ownership id must never acquire a lock (#13 guard)"
591        );
592    }
593}