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