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