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