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