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    /// Upgrade database schema to current version
137    Migrate,
138    /// Upgrade the innate binary to the latest (or specified) release
139    Upgrade {
140        /// Install this specific version, e.g. 0.3.0 or v0.3.0 (default: latest)
141        #[arg(long, value_name = "VERSION")]
142        version: Option<String>,
143        /// Only report whether an upgrade is available; do not install
144        #[arg(long)]
145        check: bool,
146    },
147    /// Daemon control (Linux only)
148    Daemon {
149        #[command(subcommand)]
150        action: DaemonCommands,
151    },
152    /// Start MCP stdio server
153    Mcp,
154}
155
156#[derive(Subcommand)]
157pub enum DaemonCommands {
158    /// Start the background log-watcher daemon
159    Start {
160        #[arg(long = "watch", value_name = "LOG_DIR")]
161        watch: Vec<std::path::PathBuf>,
162        #[arg(long, value_name = "PATH")]
163        pid_file: Option<std::path::PathBuf>,
164        #[arg(long, value_name = "PATH")]
165        state_db: Option<std::path::PathBuf>,
166        #[arg(long, value_name = "PATH")]
167        log_file: Option<std::path::PathBuf>,
168    },
169    /// Stop a running daemon
170    Stop {
171        #[arg(long, value_name = "PATH")]
172        pid_file: Option<std::path::PathBuf>,
173    },
174    /// Show daemon status
175    Status {
176        #[arg(long, value_name = "PATH")]
177        state_db: Option<std::path::PathBuf>,
178        #[arg(long, value_name = "PATH")]
179        pid_file: Option<std::path::PathBuf>,
180    },
181}
182
183pub fn run() -> anyhow::Result<()> {
184    let cli = Cli::parse();
185    let db_path = cli.db.unwrap_or_else(default_db);
186
187    if let Commands::Mcp = &cli.command {
188        return crate::mcp::run_server(db_path);
189    }
190
191    if let Commands::Install = &cli.command {
192        return crate::install::run_install();
193    }
194
195    if let Commands::Migrate = &cli.command {
196        let applied = crate::migrate::run_migrations(&db_path)?;
197        if applied.is_empty() {
198            println!("already at 4.5.1 — nothing to do");
199        } else {
200            for step in &applied {
201                println!("  applied: {step}");
202            }
203            println!("migration complete");
204        }
205        return Ok(());
206    }
207
208    if let Commands::Daemon { action } = &cli.command {
209        return run_daemon(action, &db_path);
210    }
211
212    if let Commands::Upgrade { version, check } = &cli.command {
213        return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
214    }
215
216    let kb = KnowledgeBase::open(&db_path)?;
217
218    match cli.command {
219        Commands::Recall {
220            query,
221            budget,
222            top,
223            format,
224            include_sparks,
225            expand_deps,
226            allow_trim,
227            refine_mode,
228            source,
229        } => {
230            let result = kb.recall(
231                &query,
232                budget,
233                true,
234                include_sparks,
235                top,
236                &source,
237                &expand_deps,
238                allow_trim,
239                &refine_mode,
240            )?;
241            match format.as_str() {
242                "json" => println!(
243                    "{}",
244                    serde_json::to_string_pretty(&json!({
245                        "trace_id": result.trace_id,
246                        "knowledge": result.knowledge,
247                        "sparks": result.sparks,
248                        "empty": result.empty,
249                    }))?
250                ),
251                "prompt" => {
252                    for chunk in &result.knowledge {
253                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
254                        println!("{content}\n---");
255                    }
256                    // metadata at end (§九 CLI contract)
257                    println!("<!-- innate_trace_id: {} -->", result.trace_id);
258                    println!(
259                        "<!-- innate_selected: {} -->",
260                        result
261                            .knowledge
262                            .iter()
263                            .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
264                            .collect::<Vec<_>>()
265                            .join(",")
266                    );
267                }
268                _ => {
269                    for chunk in &result.knowledge {
270                        let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
271                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
272                        let conf = chunk
273                            .get("confidence")
274                            .and_then(|v| v.as_f64())
275                            .unwrap_or(0.5);
276                        println!("[{id}] (conf={conf:.2})\n{content}\n");
277                    }
278                    if result.empty {
279                        println!("(no results)");
280                    }
281                }
282            }
283        }
284        Commands::Record {
285            trace_id,
286            query,
287            outcome,
288            used,
289            output_summary,
290            nomination,
291            source,
292            feedback,
293            priority,
294        } => {
295            let used_ref: Option<&[String]> = if used.is_empty() { None } else { Some(&used) };
296            // Per §二·五B: trace-level "up" applies only to explicitly used chunks.
297            let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
298                match feedback.as_deref() {
299                    Some("up") if !used.is_empty() => (Some(used.clone()), None),
300                    Some("down") if !used.is_empty() => (None, Some(used.clone())),
301                    Some("up") => (None, None), // no used chunks — ignore per design
302                    Some("down") => (None, None),
303                    _ => (None, None),
304                };
305            let fb_up_ref = fb_up.as_deref();
306            let fb_down_ref = fb_down.as_deref();
307            kb.record(
308                &trace_id,
309                query.as_deref(),
310                None,
311                output_summary.as_deref(),
312                outcome.as_deref(),
313                used_ref,
314                fb_up_ref,
315                fb_down_ref,
316                nomination.as_deref(),
317                priority,
318                &source,
319            )?;
320            println!("recorded");
321        }
322        Commands::Add {
323            content,
324            kind,
325            trigger,
326            anti_trigger,
327            source,
328            skill_name,
329        } => {
330            // If kind=skill and content is a readable file path, load its content.
331            let content = if kind == "skill" {
332                let p = std::path::Path::new(&content);
333                if p.exists() && p.is_file() {
334                    std::fs::read_to_string(p).map_err(|e| {
335                        anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
336                    })?
337                } else {
338                    content
339                }
340            } else {
341                content
342            };
343            let id = kb.add(
344                &content,
345                &kind,
346                trigger.as_deref(),
347                anti_trigger.as_deref(),
348                &source,
349                skill_name.as_deref(),
350            )?;
351            println!("{id}");
352        }
353        Commands::Spark { content, trigger } => {
354            let id = kb.spark(&content, trigger.as_deref(), None)?;
355            println!("{id}");
356        }
357        Commands::Evolve {
358            trigger,
359            rebuild_embeddings,
360        } => {
361            if rebuild_embeddings {
362                let rebuilt = kb.rebuild_embeddings()?;
363                println!("rebuilt {rebuilt} embeddings");
364            } else {
365                let report = kb.evolve(&trigger)?;
366                println!("{}", serde_json::to_string_pretty(&report)?);
367            }
368        }
369        Commands::Inspect { id } => match id.as_deref() {
370            None => {
371                let info = kb.inspect()?;
372                println!("{}", serde_json::to_string_pretty(&info)?);
373            }
374            Some(id) => {
375                let detail = kb.inspect_id(id)?;
376                println!("{}", serde_json::to_string_pretty(&detail)?);
377            }
378        },
379        Commands::Approve { chunk_id } => {
380            kb.approve(&chunk_id)?;
381            println!("approved");
382        }
383        Commands::Archive { chunk_id, reason } => {
384            kb.archive(&chunk_id, &reason)?;
385            println!("archived");
386        }
387        Commands::Invalidate { chunk_id, reason } => {
388            kb.invalidate(&chunk_id, &reason)?;
389            println!("invalidated");
390        }
391        Commands::Restore { chunk_id } => {
392            kb.restore(&chunk_id)?;
393            println!("restored");
394        }
395        Commands::MatureSpark { spark_id, to } => {
396            kb.mature_spark(&spark_id, &to)?;
397            println!("matured");
398        }
399        Commands::PromoteSpark { spark_id, to } => {
400            let id = kb.promote_spark(&spark_id, &to)?;
401            println!("{id}");
402        }
403        Commands::DropSpark { spark_id, reason } => {
404            kb.drop_spark(&spark_id, &reason)?;
405            println!("dropped");
406        }
407        Commands::Mcp
408        | Commands::Install
409        | Commands::Migrate
410        | Commands::Upgrade { .. }
411        | Commands::Daemon { .. } => unreachable!(),
412    }
413    Ok(())
414}
415
416// ---------------------------------------------------------------------------
417// Daemon implementation
418// ---------------------------------------------------------------------------
419
420fn default_pid_file() -> std::path::PathBuf {
421    dirs_next::home_dir()
422        .unwrap_or_else(|| std::path::PathBuf::from("."))
423        .join(".innate")
424        .join("daemon.pid")
425}
426
427fn default_state_db() -> std::path::PathBuf {
428    dirs_next::home_dir()
429        .unwrap_or_else(|| std::path::PathBuf::from("."))
430        .join(".innate")
431        .join("daemon_state.sqlite")
432}
433
434fn default_log_file() -> std::path::PathBuf {
435    dirs_next::home_dir()
436        .unwrap_or_else(|| std::path::PathBuf::from("."))
437        .join(".innate")
438        .join("daemon.log")
439}
440
441fn run_daemon(action: &DaemonCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
442    match action {
443        DaemonCommands::Start {
444            watch,
445            pid_file,
446            state_db,
447            log_file,
448        } => crate::daemon::start(
449            watch,
450            db_path,
451            pid_file.as_deref().unwrap_or(&default_pid_file()),
452            state_db.as_deref().unwrap_or(&default_state_db()),
453            log_file.as_deref().unwrap_or(&default_log_file()),
454        ),
455        DaemonCommands::Stop { pid_file } => {
456            crate::daemon::stop(pid_file.as_deref().unwrap_or(&default_pid_file()))
457        }
458        DaemonCommands::Status { state_db, pid_file } => crate::daemon::status(
459            state_db.as_deref().unwrap_or(&default_state_db()),
460            pid_file.as_deref().unwrap_or(&default_pid_file()),
461        ),
462    }
463}