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
8pub use crate::backup::BackupCommands;
9pub use crate::daemon::DaemonCommands;
10pub use crate::hook::HookCommands;
11use crate::{RecallParams, RecordParams};
12
13fn default_db() -> PathBuf {
14    crate::paths::default_db_path()
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 (mcp | sdk | cli | hook | daemon | augmented)
50        #[arg(long, default_value = "cli")]
51        source: String,
52        /// Relevance gate: drop candidates whose fused score is below this value.
53        /// Keeps always-on hooks high-frequency without injecting noise.
54        #[arg(long)]
55        min_score: Option<f64>,
56    },
57    /// Close a trace with outcome
58    Record {
59        trace_id: String,
60        #[arg(long)]
61        query: Option<String>,
62        #[arg(long)]
63        outcome: Option<String>,
64        /// Comma-separated chunk ids. An explicit empty value means "known none".
65        #[arg(long)]
66        used: Option<String>,
67        #[arg(long, default_value = "explicit")]
68        used_attribution: String,
69        /// Treat --used as partial attribution; omitted selected chunks are not penalized.
70        #[arg(long)]
71        used_partial: bool,
72        #[arg(long)]
73        output: Option<String>,
74        #[arg(long)]
75        output_summary: Option<String>,
76        #[arg(long)]
77        nomination: Option<String>,
78        #[arg(long, default_value = "cli")]
79        source: String,
80        /// Explicit feedback: up or down (applied to --used chunks if provided)
81        #[arg(long)]
82        feedback: Option<String>,
83        #[arg(long, default_value = "user")]
84        feedback_kind: String,
85        #[arg(long)]
86        feedback_actor: Option<String>,
87        #[arg(long)]
88        feedback_reason: Option<String>,
89        #[arg(long)]
90        task_state: Option<String>,
91        #[arg(long, default_value = "0")]
92        priority: i64,
93    },
94    /// Add a knowledge chunk
95    Add {
96        content: String,
97        #[arg(long, default_value = "note")]
98        kind: String,
99        #[arg(long)]
100        trigger: Option<String>,
101        #[arg(long)]
102        anti_trigger: Option<String>,
103        #[arg(long, default_value = "chat")]
104        source: String,
105        #[arg(long)]
106        skill_name: Option<String>,
107        /// Declare a dependency on another chunk id (repeatable).
108        #[arg(long = "depends-on")]
109        depends_on: Vec<String>,
110        /// Dependency kind for --depends-on: hard (fail-closed) or soft (bonus).
111        #[arg(long, default_value = "hard")]
112        dep_kind: String,
113    },
114    /// Capture a spark (idea)
115    Spark {
116        content: String,
117        #[arg(long)]
118        trigger: Option<String>,
119    },
120    /// Distil logs + curate
121    Evolve {
122        #[arg(long, default_value = "manual")]
123        trigger: String,
124        /// Rebuild embeddings for chunks with embed_version=0 or < meta.embed_version
125        #[arg(long)]
126        rebuild_embeddings: bool,
127    },
128    /// Health check — no arg = library summary; chunk_id or trace_id = detail view
129    Inspect { id: Option<String> },
130    /// Approve a pending chunk
131    Approve { chunk_id: String },
132    /// Archive a chunk
133    Archive {
134        chunk_id: String,
135        #[arg(long, default_value = "stale")]
136        reason: String,
137    },
138    /// Invalidate a chunk
139    Invalidate {
140        chunk_id: String,
141        #[arg(long, default_value = "")]
142        reason: String,
143    },
144    /// Restore an archived chunk
145    Restore { chunk_id: String },
146    /// Mature a spark
147    MatureSpark { spark_id: String, to: String },
148    /// Promote a spark to knowledge
149    PromoteSpark {
150        spark_id: String,
151        #[arg(long, default_value = "note")]
152        to: String,
153    },
154    /// Drop a spark
155    DropSpark {
156        spark_id: String,
157        #[arg(long, default_value = "")]
158        reason: String,
159    },
160    /// Backup the database to Cloudflare R2
161    Backup {
162        #[command(subcommand)]
163        action: BackupCommands,
164    },
165    /// Interactive setup wizard — configure agents to use Innate MCP server
166    Install,
167    /// Remove Innate from all configured agents and PATH
168    Uninstall {
169        /// Skip confirmation prompts
170        #[arg(long, short = 'y')]
171        yes: bool,
172        /// Also delete knowledge data (~/.innate/). Cannot be undone.
173        #[arg(long)]
174        purge_data: bool,
175    },
176    /// Upgrade database schema to current version
177    Migrate,
178    /// Reclaim disk space: checkpoint the WAL and VACUUM the database
179    Vacuum,
180    /// Upgrade the innate binary to the latest (or specified) release
181    Upgrade {
182        /// Install this specific version, e.g. 0.3.0 or v0.3.0 (default: latest)
183        #[arg(long, value_name = "VERSION")]
184        version: Option<String>,
185        /// Only report whether an upgrade is available; do not install
186        #[arg(long)]
187        check: bool,
188    },
189    /// Daemon control (Linux only)
190    Daemon {
191        #[command(subcommand)]
192        action: DaemonCommands,
193    },
194    /// Start MCP stdio server
195    Mcp,
196    /// Start a local web UI to view and govern the knowledge base
197    Web {
198        /// Address to bind (localhost only by default; exposing beyond is unsafe)
199        #[arg(long, default_value = "127.0.0.1")]
200        bind: String,
201        /// Port to listen on
202        #[arg(long, default_value_t = 8788)]
203        port: u16,
204        /// Disable the governance auth token (NOT recommended; leaves writes unauthenticated)
205        #[arg(long)]
206        no_token: bool,
207        /// Required to bind a non-loopback address. Exposes the knowledge base to
208        /// the network; the auth token then gates reads as well as writes.
209        #[arg(long)]
210        allow_remote: bool,
211    },
212    /// Agent hook handlers (called by agent hooks; reads payload from stdin)
213    Hook {
214        #[command(subcommand)]
215        action: HookCommands,
216    },
217}
218
219pub fn run() -> anyhow::Result<()> {
220    let cli = Cli::parse();
221    // Create the ~/.innate subdirectory layout and migrate any legacy flat files
222    // before any path is resolved.
223    crate::paths::ensure_layout();
224    let db_path = cli.db.unwrap_or_else(default_db);
225
226    if let Commands::Mcp = &cli.command {
227        return crate::mcp::run_server(db_path);
228    }
229
230    if let Commands::Install = &cli.command {
231        return crate::install::run_install();
232    }
233
234    if let Commands::Uninstall { yes, purge_data } = &cli.command {
235        return crate::install::run_uninstall(*yes, *purge_data);
236    }
237
238    if let Commands::Migrate = &cli.command {
239        let applied = crate::migrate::run_migrations(&db_path)?;
240        if applied.is_empty() {
241            println!("already at 4.14 — nothing to do");
242        } else {
243            for step in &applied {
244                println!("  applied: {step}");
245            }
246            println!("migration complete");
247        }
248        return Ok(());
249    }
250
251    if let Commands::Daemon { action } = &cli.command {
252        return crate::daemon::run_command(action, &db_path);
253    }
254
255    if let Commands::Backup { action } = &cli.command {
256        return crate::backup::run_command(action, &db_path);
257    }
258
259    if let Commands::Upgrade { version, check } = &cli.command {
260        return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
261    }
262
263    if let Commands::Hook { action } = &cli.command {
264        return crate::hook::run_command(action, &db_path);
265    }
266
267    let kb = crate::open_kb(&db_path)?;
268
269    match cli.command {
270        Commands::Recall {
271            query,
272            budget,
273            top,
274            format,
275            include_sparks,
276            expand_deps,
277            allow_trim,
278            refine_mode,
279            source,
280            min_score,
281        } => {
282            let result = kb.recall(RecallParams {
283                query: &query,
284                budget,
285                trace: true,
286                include_sparks,
287                top,
288                source: &source,
289                expand_deps: &expand_deps,
290                allow_trim,
291                refine_mode: &refine_mode,
292                min_score,
293            })?;
294            match format.as_str() {
295                "json" => println!(
296                    "{}",
297                    serde_json::to_string_pretty(&json!({
298                        "trace_id": result.trace_id,
299                        "knowledge": result.knowledge,
300                        "sparks": result.sparks,
301                        "empty": result.empty,
302                    }))?
303                ),
304                "prompt" => {
305                    for chunk in &result.knowledge {
306                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
307                        println!("{content}\n---");
308                    }
309                    // metadata at end (§九 CLI contract)
310                    println!("<!-- innate_trace_id: {} -->", result.trace_id);
311                    println!(
312                        "<!-- innate_selected: {} -->",
313                        result
314                            .knowledge
315                            .iter()
316                            .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
317                            .collect::<Vec<_>>()
318                            .join(",")
319                    );
320                }
321                _ => {
322                    for chunk in &result.knowledge {
323                        let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
324                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
325                        let conf = chunk
326                            .get("confidence")
327                            .and_then(|v| v.as_f64())
328                            .unwrap_or(0.5);
329                        println!("[{id}] (conf={conf:.2})\n{content}\n");
330                    }
331                    if result.empty {
332                        println!("(no results)");
333                    }
334                }
335            }
336        }
337        Commands::Record {
338            trace_id,
339            query,
340            outcome,
341            used,
342            used_attribution,
343            used_partial,
344            output,
345            output_summary,
346            nomination,
347            source,
348            feedback,
349            feedback_kind,
350            feedback_actor,
351            feedback_reason,
352            task_state,
353            priority,
354        } => {
355            let used_ids = used.as_deref().map(|raw| {
356                raw.split(',')
357                    .map(str::trim)
358                    .filter(|id| !id.is_empty())
359                    .map(str::to_string)
360                    .collect::<Vec<_>>()
361            });
362            let used_ref = used_ids.as_deref();
363            // Per §二·五B: trace-level "up" applies only to explicitly used chunks.
364            let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
365                match feedback.as_deref() {
366                    Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
367                        (used_ids.clone(), None)
368                    }
369                    Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
370                        (None, used_ids.clone())
371                    }
372                    Some("up") => (None, None), // no used chunks — ignore per design
373                    Some("down") => (None, None),
374                    _ => (None, None),
375                };
376            let fb_up_ref = fb_up.as_deref();
377            let fb_down_ref = fb_down.as_deref();
378            kb.record(RecordParams {
379                trace_id: &trace_id,
380                query: query.as_deref(),
381                output: output.as_deref(),
382                output_summary: output_summary.as_deref(),
383                outcome: outcome.as_deref(),
384                used: used_ref,
385                used_attribution: &used_attribution,
386                used_complete: Some(!used_partial),
387                feedback_up: fb_up_ref,
388                feedback_down: fb_down_ref,
389                feedback_kind: &feedback_kind,
390                feedback_actor: feedback_actor.as_deref(),
391                feedback_reason: feedback_reason.as_deref(),
392                nomination: nomination.as_deref(),
393                priority,
394                task_state: task_state.as_deref(),
395                source: &source,
396            })?;
397            println!("recorded");
398        }
399        Commands::Add {
400            content,
401            kind,
402            trigger,
403            anti_trigger,
404            source,
405            skill_name,
406            depends_on,
407            dep_kind,
408        } => {
409            // If kind=skill and content is a readable file path, load its content.
410            let content = if kind == "skill" {
411                let p = std::path::Path::new(&content);
412                if p.exists() && p.is_file() {
413                    std::fs::read_to_string(p).map_err(|e| {
414                        anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
415                    })?
416                } else {
417                    content
418                }
419            } else {
420                content
421            };
422            let deps: Vec<(String, String)> = depends_on
423                .iter()
424                .map(|d| (d.clone(), dep_kind.clone()))
425                .collect();
426            let id = kb.add_with_deps(
427                &content,
428                &kind,
429                trigger.as_deref(),
430                anti_trigger.as_deref(),
431                &source,
432                skill_name.as_deref(),
433                &deps,
434            )?;
435            println!("{id}");
436        }
437        Commands::Spark { content, trigger } => {
438            let id = kb.spark(&content, trigger.as_deref(), None)?;
439            println!("{id}");
440        }
441        Commands::Evolve {
442            trigger,
443            rebuild_embeddings,
444        } => {
445            if rebuild_embeddings {
446                let rebuilt = kb.rebuild_embeddings()?;
447                let report = kb.evolve(&trigger)?;
448                println!(
449                    "{}",
450                    serde_json::to_string_pretty(&json!({
451                        "rebuilt_embeddings": rebuilt,
452                        "evolve": report
453                    }))?
454                );
455            } else {
456                let report = kb.evolve(&trigger)?;
457                println!("{}", serde_json::to_string_pretty(&report)?);
458            }
459        }
460        Commands::Inspect { id } => match id.as_deref() {
461            None => {
462                let info = kb.inspect()?;
463                println!("{}", serde_json::to_string_pretty(&info)?);
464            }
465            Some(id) => {
466                let detail = kb.inspect_id(id)?;
467                println!("{}", serde_json::to_string_pretty(&detail)?);
468            }
469        },
470        Commands::Approve { chunk_id } => {
471            kb.approve(&chunk_id)?;
472            println!("approved");
473        }
474        Commands::Archive { chunk_id, reason } => {
475            kb.archive(&chunk_id, &reason)?;
476            println!("archived");
477        }
478        Commands::Invalidate { chunk_id, reason } => {
479            kb.invalidate(&chunk_id, &reason)?;
480            println!("invalidated");
481        }
482        Commands::Restore { chunk_id } => {
483            kb.restore(&chunk_id)?;
484            println!("restored");
485        }
486        Commands::MatureSpark { spark_id, to } => {
487            kb.mature_spark(&spark_id, &to)?;
488            println!("matured");
489        }
490        Commands::PromoteSpark { spark_id, to } => {
491            let id = kb.promote_spark(&spark_id, &to)?;
492            println!("{id}");
493        }
494        Commands::DropSpark { spark_id, reason } => {
495            kb.drop_spark(&spark_id, &reason)?;
496            println!("dropped");
497        }
498        Commands::Vacuum => {
499            let (before, after) = kb.storage.vacuum()?;
500            let mb = |b: i64| b as f64 / 1_048_576.0;
501            println!(
502                "vacuumed: {:.2} MB → {:.2} MB (reclaimed {:.2} MB)",
503                mb(before),
504                mb(after),
505                mb(before - after)
506            );
507        }
508        Commands::Web {
509            bind,
510            port,
511            no_token,
512            allow_remote,
513        } => {
514            let loopback = crate::web::is_loopback(&bind);
515            if !loopback && !allow_remote {
516                anyhow::bail!(
517                    "refusing to bind non-loopback address {bind} without --allow-remote \
518                     (this exposes the knowledge base to the network)"
519                );
520            }
521            if !loopback && no_token {
522                anyhow::bail!(
523                    "--no-token cannot be combined with a non-loopback bind: a network-exposed \
524                     server must keep the auth token to gate reads and writes"
525                );
526            }
527            crate::web::serve(kb, &bind, port, !no_token)?;
528        }
529        Commands::Mcp
530        | Commands::Install
531        | Commands::Uninstall { .. }
532        | Commands::Migrate
533        | Commands::Upgrade { .. }
534        | Commands::Daemon { .. }
535        | Commands::Backup { .. }
536        | Commands::Hook { .. } => unreachable!(),
537    }
538    Ok(())
539}