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