Skip to main content

innate_core/
cli.rs

1//! CLI commands — thin wrapper over KnowledgeBase.
2
3use std::path::PathBuf;
4
5use clap::{Parser, Subcommand};
6use serde_json::json;
7
8use crate::kb::KnowledgeBase;
9
10fn default_db() -> PathBuf {
11    dirs_next::home_dir()
12        .unwrap_or_else(|| PathBuf::from("."))
13        .join(".innate")
14        .join("personal.db")
15}
16
17#[derive(Parser)]
18#[command(name = "innate", version, about = "Self-growing knowledge layer")]
19pub struct Cli {
20    #[arg(long, global = true, env = "INNATE_DB")]
21    pub db: Option<PathBuf>,
22
23    #[command(subcommand)]
24    pub command: Commands,
25}
26
27#[derive(Subcommand)]
28pub enum Commands {
29    /// Search the knowledge base
30    Recall {
31        query: String,
32        #[arg(long, default_value = "6000")]
33        budget: usize,
34        #[arg(long)]
35        top: Option<usize>,
36        #[arg(long, default_value = "text")]
37        format: String,
38        #[arg(long)]
39        include_sparks: bool,
40        /// Dependency expansion: false (default) | direct | closure
41        #[arg(long, default_value = "false")]
42        expand_deps: String,
43        /// Allow Refiner to trim blocks that don't fit the budget
44        #[arg(long)]
45        allow_trim: bool,
46        /// Refine mode written to usage_trace: off (default) | trim | adapt
47        #[arg(long, default_value = "off")]
48        refine_mode: String,
49        /// Event source written to usage_trace (sdk | cli | hook | daemon | augmented)
50        #[arg(long, default_value = "cli")]
51        source: String,
52    },
53    /// Close a trace with outcome
54    Record {
55        trace_id: String,
56        #[arg(long)]
57        query: Option<String>,
58        #[arg(long)]
59        outcome: Option<String>,
60        #[arg(long, value_delimiter = ',')]
61        used: Vec<String>,
62        #[arg(long)]
63        output_summary: Option<String>,
64        #[arg(long)]
65        nomination: Option<String>,
66        #[arg(long, default_value = "cli")]
67        source: String,
68        /// Explicit feedback: up or down (applied to --used chunks if provided)
69        #[arg(long)]
70        feedback: Option<String>,
71        #[arg(long, default_value = "0")]
72        priority: i64,
73    },
74    /// Add a knowledge chunk
75    Add {
76        content: String,
77        #[arg(long, default_value = "note")]
78        kind: String,
79        #[arg(long)]
80        trigger: Option<String>,
81        #[arg(long)]
82        anti_trigger: Option<String>,
83        #[arg(long, default_value = "chat")]
84        source: String,
85        #[arg(long)]
86        skill_name: Option<String>,
87    },
88    /// Capture a spark (idea)
89    Spark {
90        content: String,
91        #[arg(long)]
92        trigger: Option<String>,
93    },
94    /// Distil logs + curate
95    Evolve {
96        #[arg(long, default_value = "manual")]
97        trigger: String,
98        /// Rebuild embeddings for chunks with embed_version=0 or < meta.embed_version
99        #[arg(long)]
100        rebuild_embeddings: bool,
101    },
102    /// Health check — no arg = library summary; chunk_id or trace_id = detail view
103    Inspect { id: Option<String> },
104    /// Approve a pending chunk
105    Approve { chunk_id: String },
106    /// Archive a chunk
107    Archive {
108        chunk_id: String,
109        #[arg(long, default_value = "stale")]
110        reason: String,
111    },
112    /// Invalidate a chunk
113    Invalidate {
114        chunk_id: String,
115        #[arg(long, default_value = "")]
116        reason: String,
117    },
118    /// Restore an archived chunk
119    Restore { chunk_id: String },
120    /// Mature a spark
121    MatureSpark { spark_id: String, to: String },
122    /// Promote a spark to knowledge
123    PromoteSpark {
124        spark_id: String,
125        #[arg(long, default_value = "note")]
126        to: String,
127    },
128    /// Drop a spark
129    DropSpark {
130        spark_id: String,
131        #[arg(long, default_value = "")]
132        reason: String,
133    },
134    /// Interactive setup wizard — configure agents to use Innate MCP server
135    Install,
136    /// Remove Innate from all configured agents and PATH
137    Uninstall {
138        /// Skip confirmation prompts
139        #[arg(long, short = 'y')]
140        yes: bool,
141        /// Also delete knowledge data (~/.innate/). Cannot be undone.
142        #[arg(long)]
143        purge_data: bool,
144    },
145    /// Upgrade database schema to current version
146    Migrate,
147    /// Upgrade the innate binary to the latest (or specified) release
148    Upgrade {
149        /// Install this specific version, e.g. 0.3.0 or v0.3.0 (default: latest)
150        #[arg(long, value_name = "VERSION")]
151        version: Option<String>,
152        /// Only report whether an upgrade is available; do not install
153        #[arg(long)]
154        check: bool,
155    },
156    /// Daemon control (Linux only)
157    Daemon {
158        #[command(subcommand)]
159        action: DaemonCommands,
160    },
161    /// Start MCP stdio server
162    Mcp,
163}
164
165#[derive(Subcommand)]
166pub enum DaemonCommands {
167    /// Start the background log-watcher daemon
168    Start {
169        #[arg(long = "watch", value_name = "LOG_DIR")]
170        watch: Vec<std::path::PathBuf>,
171        #[arg(long, value_name = "PATH")]
172        pid_file: Option<std::path::PathBuf>,
173        #[arg(long, value_name = "PATH")]
174        state_db: Option<std::path::PathBuf>,
175        #[arg(long, value_name = "PATH")]
176        log_file: Option<std::path::PathBuf>,
177    },
178    /// Stop a running daemon
179    Stop {
180        #[arg(long, value_name = "PATH")]
181        pid_file: Option<std::path::PathBuf>,
182    },
183    /// Show daemon status
184    Status {
185        #[arg(long, value_name = "PATH")]
186        state_db: Option<std::path::PathBuf>,
187        #[arg(long, value_name = "PATH")]
188        pid_file: Option<std::path::PathBuf>,
189    },
190}
191
192pub fn run() -> anyhow::Result<()> {
193    let cli = Cli::parse();
194    let db_path = cli.db.unwrap_or_else(default_db);
195
196    if let Commands::Mcp = &cli.command {
197        return crate::mcp::run_server(db_path);
198    }
199
200    if let Commands::Install = &cli.command {
201        return crate::install::run_install();
202    }
203
204    if let Commands::Uninstall { yes, purge_data } = &cli.command {
205        return crate::install::run_uninstall(*yes, *purge_data);
206    }
207
208    if let Commands::Migrate = &cli.command {
209        let applied = crate::migrate::run_migrations(&db_path)?;
210        if applied.is_empty() {
211            println!("already at 4.5.1 — nothing to do");
212        } else {
213            for step in &applied {
214                println!("  applied: {step}");
215            }
216            println!("migration complete");
217        }
218        return Ok(());
219    }
220
221    if let Commands::Daemon { action } = &cli.command {
222        return run_daemon(action, &db_path);
223    }
224
225    if let Commands::Upgrade { version, check } = &cli.command {
226        return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
227    }
228
229    let kb = KnowledgeBase::open(&db_path)?;
230
231    match cli.command {
232        Commands::Recall {
233            query,
234            budget,
235            top,
236            format,
237            include_sparks,
238            expand_deps,
239            allow_trim,
240            refine_mode,
241            source,
242        } => {
243            let result = kb.recall(
244                &query,
245                budget,
246                true,
247                include_sparks,
248                top,
249                &source,
250                &expand_deps,
251                allow_trim,
252                &refine_mode,
253            )?;
254            match format.as_str() {
255                "json" => println!(
256                    "{}",
257                    serde_json::to_string_pretty(&json!({
258                        "trace_id": result.trace_id,
259                        "knowledge": result.knowledge,
260                        "sparks": result.sparks,
261                        "empty": result.empty,
262                    }))?
263                ),
264                "prompt" => {
265                    for chunk in &result.knowledge {
266                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
267                        println!("{content}\n---");
268                    }
269                    // metadata at end (§九 CLI contract)
270                    println!("<!-- innate_trace_id: {} -->", result.trace_id);
271                    println!(
272                        "<!-- innate_selected: {} -->",
273                        result
274                            .knowledge
275                            .iter()
276                            .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
277                            .collect::<Vec<_>>()
278                            .join(",")
279                    );
280                }
281                _ => {
282                    for chunk in &result.knowledge {
283                        let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
284                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
285                        let conf = chunk
286                            .get("confidence")
287                            .and_then(|v| v.as_f64())
288                            .unwrap_or(0.5);
289                        println!("[{id}] (conf={conf:.2})\n{content}\n");
290                    }
291                    if result.empty {
292                        println!("(no results)");
293                    }
294                }
295            }
296        }
297        Commands::Record {
298            trace_id,
299            query,
300            outcome,
301            used,
302            output_summary,
303            nomination,
304            source,
305            feedback,
306            priority,
307        } => {
308            let used_ref: Option<&[String]> = if used.is_empty() { None } else { Some(&used) };
309            // Per §二·五B: trace-level "up" applies only to explicitly used chunks.
310            let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
311                match feedback.as_deref() {
312                    Some("up") if !used.is_empty() => (Some(used.clone()), None),
313                    Some("down") if !used.is_empty() => (None, Some(used.clone())),
314                    Some("up") => (None, None), // no used chunks — ignore per design
315                    Some("down") => (None, None),
316                    _ => (None, None),
317                };
318            let fb_up_ref = fb_up.as_deref();
319            let fb_down_ref = fb_down.as_deref();
320            kb.record(
321                &trace_id,
322                query.as_deref(),
323                None,
324                output_summary.as_deref(),
325                outcome.as_deref(),
326                used_ref,
327                fb_up_ref,
328                fb_down_ref,
329                nomination.as_deref(),
330                priority,
331                &source,
332            )?;
333            println!("recorded");
334        }
335        Commands::Add {
336            content,
337            kind,
338            trigger,
339            anti_trigger,
340            source,
341            skill_name,
342        } => {
343            // If kind=skill and content is a readable file path, load its content.
344            let content = if kind == "skill" {
345                let p = std::path::Path::new(&content);
346                if p.exists() && p.is_file() {
347                    std::fs::read_to_string(p).map_err(|e| {
348                        anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
349                    })?
350                } else {
351                    content
352                }
353            } else {
354                content
355            };
356            let id = kb.add(
357                &content,
358                &kind,
359                trigger.as_deref(),
360                anti_trigger.as_deref(),
361                &source,
362                skill_name.as_deref(),
363            )?;
364            println!("{id}");
365        }
366        Commands::Spark { content, trigger } => {
367            let id = kb.spark(&content, trigger.as_deref(), None)?;
368            println!("{id}");
369        }
370        Commands::Evolve {
371            trigger,
372            rebuild_embeddings,
373        } => {
374            if rebuild_embeddings {
375                let rebuilt = kb.rebuild_embeddings()?;
376                println!("rebuilt {rebuilt} embeddings");
377            } else {
378                let report = kb.evolve(&trigger)?;
379                println!("{}", serde_json::to_string_pretty(&report)?);
380            }
381        }
382        Commands::Inspect { id } => match id.as_deref() {
383            None => {
384                let info = kb.inspect()?;
385                println!("{}", serde_json::to_string_pretty(&info)?);
386            }
387            Some(id) => {
388                let detail = kb.inspect_id(id)?;
389                println!("{}", serde_json::to_string_pretty(&detail)?);
390            }
391        },
392        Commands::Approve { chunk_id } => {
393            kb.approve(&chunk_id)?;
394            println!("approved");
395        }
396        Commands::Archive { chunk_id, reason } => {
397            kb.archive(&chunk_id, &reason)?;
398            println!("archived");
399        }
400        Commands::Invalidate { chunk_id, reason } => {
401            kb.invalidate(&chunk_id, &reason)?;
402            println!("invalidated");
403        }
404        Commands::Restore { chunk_id } => {
405            kb.restore(&chunk_id)?;
406            println!("restored");
407        }
408        Commands::MatureSpark { spark_id, to } => {
409            kb.mature_spark(&spark_id, &to)?;
410            println!("matured");
411        }
412        Commands::PromoteSpark { spark_id, to } => {
413            let id = kb.promote_spark(&spark_id, &to)?;
414            println!("{id}");
415        }
416        Commands::DropSpark { spark_id, reason } => {
417            kb.drop_spark(&spark_id, &reason)?;
418            println!("dropped");
419        }
420        Commands::Mcp
421        | Commands::Install
422        | Commands::Uninstall { .. }
423        | Commands::Migrate
424        | Commands::Upgrade { .. }
425        | Commands::Daemon { .. } => unreachable!(),
426    }
427    Ok(())
428}
429
430// ---------------------------------------------------------------------------
431// Daemon implementation
432// ---------------------------------------------------------------------------
433
434fn default_pid_file() -> std::path::PathBuf {
435    dirs_next::home_dir()
436        .unwrap_or_else(|| std::path::PathBuf::from("."))
437        .join(".innate")
438        .join("daemon.pid")
439}
440
441fn default_state_db() -> std::path::PathBuf {
442    dirs_next::home_dir()
443        .unwrap_or_else(|| std::path::PathBuf::from("."))
444        .join(".innate")
445        .join("daemon_state.sqlite")
446}
447
448fn default_log_file() -> std::path::PathBuf {
449    dirs_next::home_dir()
450        .unwrap_or_else(|| std::path::PathBuf::from("."))
451        .join(".innate")
452        .join("daemon.log")
453}
454
455fn run_daemon(action: &DaemonCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
456    match action {
457        DaemonCommands::Start {
458            watch,
459            pid_file,
460            state_db,
461            log_file,
462        } => crate::daemon::start(
463            watch,
464            db_path,
465            pid_file.as_deref().unwrap_or(&default_pid_file()),
466            state_db.as_deref().unwrap_or(&default_state_db()),
467            log_file.as_deref().unwrap_or(&default_log_file()),
468        ),
469        DaemonCommands::Stop { pid_file } => {
470            crate::daemon::stop(pid_file.as_deref().unwrap_or(&default_pid_file()))
471        }
472        DaemonCommands::Status { state_db, pid_file } => crate::daemon::status(
473            state_db.as_deref().unwrap_or(&default_state_db()),
474            pid_file.as_deref().unwrap_or(&default_pid_file()),
475        ),
476    }
477}