Skip to main content

ai_memory/cli/commands/
skill.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 Cluster E API-2 (issue #767) — `ai-memory skill <subcommand>`
5//! CLI surface.
6//!
7//! Closes the CLI/HTTP parity gap surfaced by the v0.7.0 6-reviewer audit:
8//! the L1-5 Agent Skills substrate landed with seven MCP tools
9//! (`memory_skill_*`) but zero CLI subcommands and zero HTTP routes, so
10//! HTTP-daemon operators and shell-driven workflows could not interact
11//! with skills at all. This module adds the CLI surface; the matching
12//! HTTP routes live in `src/handlers/http.rs`.
13//!
14//! Each subcommand delegates to the **same** substrate handler the MCP
15//! dispatch already uses (re-exported as `crate::mcp::handle_skill_*`).
16//! No business logic is re-implemented here — the CLI is a clap-shaped
17//! thin client over the existing handlers, so MCP / CLI / HTTP share a
18//! single source of truth for skill semantics.
19//!
20//! Verb mapping (CLI → MCP tool name):
21//!
22//!   * `ai-memory skill register`    → `memory_skill_register`
23//!   * `ai-memory skill list`        → `memory_skill_list`
24//!   * `ai-memory skill get`         → `memory_skill_get`
25//!   * `ai-memory skill resource`    → `memory_skill_resource`
26//!   * `ai-memory skill export`      → `memory_skill_export`
27//!   * `ai-memory skill promote`     → `memory_skill_promote_from_reflection`
28//!   * `ai-memory skill compose`     → `memory_skill_compositional_context`
29//!
30//! All seven mirror the MCP tool surface 1:1. No new MCP tools land
31//! here — the MCP surface stays at whatever
32//! `Profile::full().expected_tool_count()` reports (canonical SSOT in
33//! `src/profile.rs`; pinned by `profile_full_matches_registry_all`).
34
35use crate::models::field_names;
36use std::path::{Path, PathBuf};
37
38use anyhow::Result;
39use clap::{Args, Subcommand};
40use serde_json::{Value, json};
41
42use crate::cli::CliOutput;
43use crate::db;
44
45/// Top-level `ai-memory skill <subcommand>` argument struct.
46#[derive(Args, Debug, Clone)]
47pub struct SkillArgs {
48    #[command(subcommand)]
49    pub action: SkillAction,
50}
51
52/// `ai-memory skill ...` sub-subcommands. One per MCP `memory_skill_*`
53/// tool so the CLI surface is parity-checkable by name.
54#[derive(Subcommand, Debug, Clone)]
55pub enum SkillAction {
56    /// Register a SKILL.md skill from a folder or inline manifest text.
57    Register(RegisterArgs),
58    /// List current (non-superseded) skills — discovery payload only.
59    List(ListArgs),
60    /// Fetch the full activation payload for a skill (body included).
61    Get(GetArgs),
62    /// Fetch the decompressed content of a single skill resource and
63    /// verify its SHA-256 digest.
64    Resource(ResourceArgs),
65    /// Export a skill back to a folder as a round-trip-stable SKILL.md.
66    Export(ExportArgs),
67    /// Promote a Reflection-kind memory into a reusable Agent Skill.
68    Promote(PromoteArgs),
69    /// Load a skill body together with the reflections declared in its
70    /// `composes_with_reflections` frontmatter list.
71    Compose(ComposeArgs),
72}
73
74// ---------------------------------------------------------------------------
75// Per-verb argument structs
76// ---------------------------------------------------------------------------
77
78/// `ai-memory skill register` — accepts EITHER `--manifest <folder-or-file>`
79/// OR `--inline <text>`.
80#[derive(Args, Debug, Clone)]
81pub struct RegisterArgs {
82    /// Path to a directory containing `SKILL.md` (and an optional
83    /// `resources/` sub-directory), OR a path to a SKILL.md file
84    /// directly (the parent directory is then treated as the folder).
85    /// Mirrors the MCP `folder_path` parameter.
86    #[arg(long, value_name = "PATH")]
87    pub manifest: Option<PathBuf>,
88
89    /// Raw SKILL.md text including YAML frontmatter and markdown body.
90    /// Mirrors the MCP `inline_skill` parameter. Mutually exclusive
91    /// with `--manifest` at the substrate level — passing both surfaces
92    /// the substrate's "either / or" error.
93    #[arg(long, value_name = "TEXT", conflicts_with = "manifest")]
94    pub inline: Option<String>,
95
96    /// Emit a structured JSON envelope instead of a human summary line.
97    #[arg(long, default_value_t = false)]
98    pub json: bool,
99}
100
101/// `ai-memory skill list`
102#[derive(Args, Debug, Clone)]
103pub struct ListArgs {
104    /// Filter to this namespace. Omit (or pass `%`) for all namespaces.
105    #[arg(long, value_name = "NS")]
106    pub namespace: Option<String>,
107
108    /// Optional substring filter applied to name and description.
109    #[arg(long, value_name = "TEXT")]
110    pub filter: Option<String>,
111
112    /// Emit a structured JSON envelope instead of a human table.
113    #[arg(long, default_value_t = false)]
114    pub json: bool,
115}
116
117/// `ai-memory skill get`
118#[derive(Args, Debug, Clone)]
119pub struct GetArgs {
120    /// The UUID of the skill to retrieve. Mirrors MCP `skill_id`.
121    #[arg(long, value_name = "ID")]
122    pub id: String,
123
124    /// Emit a structured JSON envelope; default is a brief summary.
125    #[arg(long, default_value_t = false)]
126    pub json: bool,
127}
128
129/// `ai-memory skill resource`
130#[derive(Args, Debug, Clone)]
131pub struct ResourceArgs {
132    /// The UUID of the parent skill.
133    #[arg(long, value_name = "ID")]
134    pub id: String,
135
136    /// Relative path of the resource (e.g. `scripts/run.sh`).
137    #[arg(long, value_name = "PATH")]
138    pub path: String,
139
140    /// Emit a structured JSON envelope.
141    #[arg(long, default_value_t = false)]
142    pub json: bool,
143}
144
145/// `ai-memory skill export`
146#[derive(Args, Debug, Clone)]
147pub struct ExportArgs {
148    /// The UUID of the skill to export.
149    #[arg(long, value_name = "ID")]
150    pub id: String,
151
152    /// Destination directory. Created if absent. Re-registering the
153    /// exported folder via `ai-memory skill register --manifest <dir>`
154    /// produces the IDENTICAL SHA-256 digest (round-trip guarantee).
155    #[arg(long, value_name = "PATH")]
156    pub output: PathBuf,
157
158    /// Emit a structured JSON envelope.
159    #[arg(long, default_value_t = false)]
160    pub json: bool,
161}
162
163/// `ai-memory skill promote`
164#[derive(Args, Debug, Clone)]
165pub struct PromoteArgs {
166    /// The UUID of a Reflection-kind memory (created via `memory_reflect`).
167    /// Mirrors MCP `reflection_id`.
168    #[arg(long, value_name = "ID")]
169    pub id: String,
170
171    /// agentskills.io §3.1-compliant skill name (`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`,
172    /// 1–64 chars).
173    #[arg(long, value_name = "NAME")]
174    pub name: String,
175
176    /// 1–1024 char description for the promoted skill.
177    #[arg(long, value_name = "TEXT")]
178    pub description: String,
179
180    /// Optional path to a JSON file containing the skill's parameters
181    /// JSON-schema. Spliced into the SKILL.md body verbatim.
182    #[arg(long, value_name = "PATH")]
183    pub parameters_schema: Option<PathBuf>,
184
185    /// Emit a structured JSON envelope.
186    #[arg(long, default_value_t = false)]
187    pub json: bool,
188}
189
190/// `ai-memory skill compose`
191#[derive(Args, Debug, Clone)]
192pub struct ComposeArgs {
193    /// The UUID of the skill to load with composed reflections.
194    #[arg(long, value_name = "ID")]
195    pub id: String,
196
197    /// Optional token cap on the cumulative reflection content (skill
198    /// body is NOT counted). Default 4000 inside the substrate; hard-
199    /// clamped to 32000.
200    #[arg(long, value_name = "N")]
201    pub budget_tokens: Option<u64>,
202
203    /// Emit a structured JSON envelope (default).
204    #[arg(long, default_value_t = true)]
205    pub json: bool,
206}
207
208// ---------------------------------------------------------------------------
209// Dispatch
210// ---------------------------------------------------------------------------
211
212/// Dispatch entry-point called from `daemon_runtime::run`.
213///
214/// Loads the active keypair (when configured) so register / export /
215/// promote can sign their output, matching the MCP-side wiring.
216///
217/// # Errors
218///
219/// Surfaces DB-open errors verbatim. Substrate-handler failures bubble
220/// up as exit codes (non-zero) with the substrate's error string
221/// printed to stderr.
222pub fn run(
223    db_path: &Path,
224    args: &SkillArgs,
225    active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
226    out: &mut CliOutput<'_>,
227) -> Result<i32> {
228    let conn = db::open(db_path)?;
229    match &args.action {
230        SkillAction::Register(a) => run_register(&conn, a, active_keypair, out),
231        SkillAction::List(a) => run_list(&conn, a, out),
232        SkillAction::Get(a) => run_get(&conn, a, out),
233        SkillAction::Resource(a) => run_resource(&conn, a, out),
234        SkillAction::Export(a) => run_export(&conn, a, active_keypair, out),
235        SkillAction::Promote(a) => run_promote(&conn, a, active_keypair, out),
236        SkillAction::Compose(a) => run_compose(&conn, a, out),
237    }
238}
239
240fn handler_err_exit(out: &mut CliOutput<'_>, verb: &str, e: &str) -> Result<i32> {
241    writeln!(out.stderr, "ai-memory skill {verb}: {e}")?;
242    Ok(2)
243}
244
245fn emit_json(out: &mut CliOutput<'_>, v: &Value) -> Result<()> {
246    let s = serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
247    writeln!(out.stdout, "{s}")?;
248    Ok(())
249}
250
251// ---------------------------------------------------------------------------
252// register
253// ---------------------------------------------------------------------------
254
255fn run_register(
256    conn: &rusqlite::Connection,
257    args: &RegisterArgs,
258    active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
259    out: &mut CliOutput<'_>,
260) -> Result<i32> {
261    // Normalise --manifest: accept either a directory OR a SKILL.md
262    // file path (in which case we hand the substrate the parent dir).
263    let folder_path: Option<String> = args.manifest.as_ref().map(|p| {
264        if p.is_file() {
265            p.parent().map_or_else(
266                || p.to_string_lossy().into_owned(),
267                |d| d.to_string_lossy().into_owned(),
268            )
269        } else {
270            p.to_string_lossy().into_owned()
271        }
272    });
273
274    let mut params = json!({});
275    if let Some(ref fp) = folder_path {
276        params["folder_path"] = json!(fp);
277    }
278    if let Some(ref inl) = args.inline {
279        params["inline_skill"] = json!(inl);
280    }
281
282    match crate::mcp::handle_skill_register(conn, &params, active_keypair) {
283        Ok(v) => {
284            if args.json {
285                emit_json(out, &v)?;
286            } else {
287                let id = v["id"].as_str().unwrap_or("");
288                let ns = v["namespace"].as_str().unwrap_or("");
289                let name = v["name"].as_str().unwrap_or("");
290                let digest = v["digest"].as_str().unwrap_or("");
291                let signed = v["signed"].as_bool().unwrap_or(false);
292                writeln!(
293                    out.stdout,
294                    "registered skill {ns}/{name} id={id} digest={} signed={signed}",
295                    &digest[..digest.len().min(16)],
296                )?;
297                if let Some(prev) = v.get(field_names::SUPERSEDED_ID).and_then(Value::as_str) {
298                    writeln!(out.stdout, "  superseded previous id={prev}")?;
299                }
300            }
301            Ok(0)
302        }
303        Err(e) => handler_err_exit(out, "register", &e),
304    }
305}
306
307// ---------------------------------------------------------------------------
308// list
309// ---------------------------------------------------------------------------
310
311fn run_list(conn: &rusqlite::Connection, args: &ListArgs, out: &mut CliOutput<'_>) -> Result<i32> {
312    let mut params = json!({});
313    if let Some(ref ns) = args.namespace {
314        params["namespace"] = json!(ns);
315    }
316    if let Some(ref f) = args.filter {
317        params["filter"] = json!(f);
318    }
319    match crate::mcp::handle_skill_list(conn, &params) {
320        Ok(v) => {
321            if args.json {
322                emit_json(out, &v)?;
323            } else {
324                let empty: Vec<Value> = Vec::new();
325                let arr = v["skills"].as_array().unwrap_or(&empty);
326                writeln!(out.stdout, "{} skills", arr.len())?;
327                for s in arr {
328                    let ns = s["namespace"].as_str().unwrap_or("");
329                    let name = s["name"].as_str().unwrap_or("");
330                    let id = s["id"].as_str().unwrap_or("");
331                    let desc = s[field_names::DESCRIPTION].as_str().unwrap_or("");
332                    writeln!(out.stdout, "  {ns}/{name} ({id})\n    {desc}")?;
333                }
334            }
335            Ok(0)
336        }
337        Err(e) => handler_err_exit(out, "list", &e),
338    }
339}
340
341// ---------------------------------------------------------------------------
342// get
343// ---------------------------------------------------------------------------
344
345fn run_get(conn: &rusqlite::Connection, args: &GetArgs, out: &mut CliOutput<'_>) -> Result<i32> {
346    let params = json!({ "skill_id": args.id });
347    match crate::mcp::handle_skill_get(conn, &params) {
348        Ok(v) => {
349            if args.json {
350                emit_json(out, &v)?;
351            } else {
352                let ns = v["namespace"].as_str().unwrap_or("");
353                let name = v["name"].as_str().unwrap_or("");
354                let body = v["body"].as_str().unwrap_or("");
355                writeln!(out.stdout, "# {ns}/{name}\n\n{body}")?;
356            }
357            Ok(0)
358        }
359        Err(e) => handler_err_exit(out, "get", &e),
360    }
361}
362
363// ---------------------------------------------------------------------------
364// resource
365// ---------------------------------------------------------------------------
366
367fn run_resource(
368    conn: &rusqlite::Connection,
369    args: &ResourceArgs,
370    out: &mut CliOutput<'_>,
371) -> Result<i32> {
372    let params = json!({
373        "skill_id": args.id,
374        (field_names::RESOURCE_PATH): args.path,
375    });
376    match crate::mcp::handle_skill_resource(conn, &params) {
377        Ok(v) => {
378            if args.json {
379                emit_json(out, &v)?;
380            } else if let Some(content) = v["content"].as_str() {
381                writeln!(out.stdout, "{content}")?;
382            } else {
383                emit_json(out, &v)?;
384            }
385            Ok(0)
386        }
387        Err(e) => handler_err_exit(out, "resource", &e),
388    }
389}
390
391// ---------------------------------------------------------------------------
392// export
393// ---------------------------------------------------------------------------
394
395fn run_export(
396    conn: &rusqlite::Connection,
397    args: &ExportArgs,
398    active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
399    out: &mut CliOutput<'_>,
400) -> Result<i32> {
401    let params = json!({
402        "skill_id": args.id,
403        (field_names::TARGET_FOLDER): args.output.to_string_lossy(),
404    });
405    match crate::mcp::handle_skill_export(conn, &params, active_keypair) {
406        Ok(v) => {
407            if args.json {
408                emit_json(out, &v)?;
409            } else {
410                let fallback_folder = args.output.to_string_lossy();
411                let folder = v[field_names::TARGET_FOLDER]
412                    .as_str()
413                    .unwrap_or(&fallback_folder);
414                writeln!(out.stdout, "exported skill {} → {folder}", args.id)?;
415            }
416            Ok(0)
417        }
418        Err(e) => handler_err_exit(out, "export", &e),
419    }
420}
421
422// ---------------------------------------------------------------------------
423// promote
424// ---------------------------------------------------------------------------
425
426fn run_promote(
427    conn: &rusqlite::Connection,
428    args: &PromoteArgs,
429    active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
430    out: &mut CliOutput<'_>,
431) -> Result<i32> {
432    let mut params = json!({
433        (field_names::REFLECTION_ID): args.id,
434        (field_names::SKILL_NAME): args.name,
435        (field_names::SKILL_DESCRIPTION): args.description,
436    });
437    if let Some(ref p) = args.parameters_schema {
438        let raw = std::fs::read_to_string(p)
439            .map_err(|e| anyhow::anyhow!("read parameters_schema {}: {e}", p.display()))?;
440        let v: Value = serde_json::from_str(&raw)
441            .map_err(|e| anyhow::anyhow!("parse parameters_schema {}: {e}", p.display()))?;
442        params[field_names::PARAMETERS_SCHEMA] = v;
443    }
444    match crate::mcp::handle_skill_promote_from_reflection(conn, &params, active_keypair) {
445        Ok(v) => {
446            if args.json {
447                emit_json(out, &v)?;
448            } else {
449                let id = v["skill_id"]
450                    .as_str()
451                    .or_else(|| v["id"].as_str())
452                    .unwrap_or("");
453                writeln!(out.stdout, "promoted reflection {} → skill {id}", args.id)?;
454            }
455            Ok(0)
456        }
457        Err(e) => handler_err_exit(out, "promote", &e),
458    }
459}
460
461// ---------------------------------------------------------------------------
462// compose
463// ---------------------------------------------------------------------------
464
465fn run_compose(
466    conn: &rusqlite::Connection,
467    args: &ComposeArgs,
468    out: &mut CliOutput<'_>,
469) -> Result<i32> {
470    let mut params = json!({ "skill_id": args.id });
471    if let Some(b) = args.budget_tokens {
472        params[field_names::BUDGET_TOKENS] = json!(b);
473    }
474    match crate::mcp::handle_skill_compositional_context(conn, &params) {
475        Ok(v) => {
476            emit_json(out, &v)?;
477            Ok(0)
478        }
479        Err(e) => handler_err_exit(out, "compose", &e),
480    }
481}
482
483// ---------------------------------------------------------------------------
484// Unit tests
485// ---------------------------------------------------------------------------
486
487#[cfg(test)]
488#[allow(clippy::drop_non_drop)] // explicit borrow-release of CliOutput; see file-level note above.
489mod tests {
490    use super::*;
491    use crate::cli::CliOutput;
492    use tempfile::TempDir;
493
494    fn fresh_db() -> (TempDir, PathBuf) {
495        let dir = TempDir::new().unwrap();
496        let path = dir.path().join("ai-memory.db");
497        let _conn = db::open(&path).unwrap();
498        (dir, path)
499    }
500
501    fn minimal_skill_md(name: &str) -> String {
502        format!("---\nnamespace: testns\nname: {name}\ndescription: A demo skill.\n---\n\nBody.\n")
503    }
504
505    #[test]
506    fn cli_skill_register_inline_smoke() {
507        let (_dir, db_path) = fresh_db();
508        let mut stdout: Vec<u8> = Vec::new();
509        let mut stderr: Vec<u8> = Vec::new();
510        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
511        let args = SkillArgs {
512            action: SkillAction::Register(RegisterArgs {
513                manifest: None,
514                inline: Some(minimal_skill_md("cli-register")),
515                json: true,
516            }),
517        };
518        let code = run(&db_path, &args, None, &mut out).unwrap();
519        assert_eq!(code, 0);
520        drop(out);
521        let text = String::from_utf8(stdout).unwrap();
522        assert!(text.contains("\"registered\""));
523        assert!(text.contains("cli-register"));
524    }
525
526    #[test]
527    fn cli_skill_list_smoke() {
528        let (_dir, db_path) = fresh_db();
529        // Seed a skill via the register handler so list has something to find.
530        let conn = db::open(&db_path).unwrap();
531        let _ = crate::mcp::handle_skill_register(
532            &conn,
533            &json!({"inline_skill": minimal_skill_md("cli-list")}),
534            None,
535        )
536        .unwrap();
537        drop(conn);
538
539        let mut stdout: Vec<u8> = Vec::new();
540        let mut stderr: Vec<u8> = Vec::new();
541        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
542        let args = SkillArgs {
543            action: SkillAction::List(ListArgs {
544                namespace: Some("testns".to_string()),
545                filter: None,
546                json: true,
547            }),
548        };
549        let code = run(&db_path, &args, None, &mut out).unwrap();
550        assert_eq!(code, 0);
551        drop(out);
552        let text = String::from_utf8(stdout).unwrap();
553        assert!(text.contains("cli-list"));
554    }
555
556    #[test]
557    fn cli_skill_get_smoke() {
558        let (_dir, db_path) = fresh_db();
559        let conn = db::open(&db_path).unwrap();
560        let reg = crate::mcp::handle_skill_register(
561            &conn,
562            &json!({"inline_skill": minimal_skill_md("cli-get")}),
563            None,
564        )
565        .unwrap();
566        let id = reg["id"].as_str().unwrap().to_string();
567        drop(conn);
568
569        let mut stdout: Vec<u8> = Vec::new();
570        let mut stderr: Vec<u8> = Vec::new();
571        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
572        let args = SkillArgs {
573            action: SkillAction::Get(GetArgs {
574                id: id.clone(),
575                json: true,
576            }),
577        };
578        let code = run(&db_path, &args, None, &mut out).unwrap();
579        assert_eq!(code, 0);
580        drop(out);
581        let text = String::from_utf8(stdout).unwrap();
582        assert!(text.contains(&id));
583        assert!(text.contains("cli-get"));
584    }
585
586    #[test]
587    fn cli_skill_export_smoke() {
588        let (dir, db_path) = fresh_db();
589        let conn = db::open(&db_path).unwrap();
590        let reg = crate::mcp::handle_skill_register(
591            &conn,
592            &json!({"inline_skill": minimal_skill_md("cli-export")}),
593            None,
594        )
595        .unwrap();
596        let id = reg["id"].as_str().unwrap().to_string();
597        drop(conn);
598
599        let target = dir.path().join("export-out");
600
601        let mut stdout: Vec<u8> = Vec::new();
602        let mut stderr: Vec<u8> = Vec::new();
603        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
604        let args = SkillArgs {
605            action: SkillAction::Export(ExportArgs {
606                id: id.clone(),
607                output: target.clone(),
608                json: true,
609            }),
610        };
611        let code = run(&db_path, &args, None, &mut out).unwrap();
612        assert_eq!(code, 0);
613        // SKILL.md must exist in the output folder.
614        assert!(target.join("SKILL.md").exists());
615    }
616
617    #[test]
618    fn cli_skill_get_missing_id_exits_nonzero() {
619        let (_dir, db_path) = fresh_db();
620        let mut stdout: Vec<u8> = Vec::new();
621        let mut stderr: Vec<u8> = Vec::new();
622        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
623        let args = SkillArgs {
624            action: SkillAction::Get(GetArgs {
625                id: "no-such-skill".to_string(),
626                json: true,
627            }),
628        };
629        let code = run(&db_path, &args, None, &mut out).unwrap();
630        assert_eq!(code, 2);
631        drop(out);
632        let err = String::from_utf8(stderr).unwrap();
633        assert!(err.contains(crate::errors::msg::SKILL_NOT_FOUND));
634    }
635
636    #[test]
637    fn cli_skill_compose_smoke() {
638        let (_dir, db_path) = fresh_db();
639        let conn = db::open(&db_path).unwrap();
640        let reg = crate::mcp::handle_skill_register(
641            &conn,
642            &json!({"inline_skill": minimal_skill_md("cli-compose")}),
643            None,
644        )
645        .unwrap();
646        let id = reg["id"].as_str().unwrap().to_string();
647        drop(conn);
648
649        let mut stdout: Vec<u8> = Vec::new();
650        let mut stderr: Vec<u8> = Vec::new();
651        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
652        let args = SkillArgs {
653            action: SkillAction::Compose(ComposeArgs {
654                id: id.clone(),
655                budget_tokens: Some(1000),
656                json: true,
657            }),
658        };
659        let code = run(&db_path, &args, None, &mut out).unwrap();
660        assert_eq!(code, 0);
661        drop(out);
662        let text = String::from_utf8(stdout).unwrap();
663        // A skill with no `composes_with_reflections` declaration still
664        // returns body and an empty reflections list — pin that.
665        assert!(text.contains(&id) || text.contains("\"body\""));
666    }
667
668    // ------------------------------------------------------------------
669    // Coverage-uplift block (2026-05-19): exercise the non-JSON (human-
670    // render) paths for every skill verb so the `if args.json { ... }
671    // else { ... writeln!(...) }` else-arms are not dead from a test-
672    // coverage standpoint.
673    // ------------------------------------------------------------------
674
675    #[test]
676    fn cli_skill_register_human_render_emits_summary_line() {
677        let (_dir, db_path) = fresh_db();
678        let mut stdout: Vec<u8> = Vec::new();
679        let mut stderr: Vec<u8> = Vec::new();
680        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
681        let args = SkillArgs {
682            action: SkillAction::Register(RegisterArgs {
683                manifest: None,
684                inline: Some(minimal_skill_md("cli-register-human")),
685                json: false,
686            }),
687        };
688        let code = run(&db_path, &args, None, &mut out).unwrap();
689        assert_eq!(code, 0);
690        drop(out);
691        let text = String::from_utf8(stdout).unwrap();
692        // The human-render path emits "registered skill {ns}/{name} ..."
693        assert!(
694            text.starts_with("registered skill "),
695            "expected human-render summary line, got: {text}"
696        );
697        assert!(text.contains("cli-register-human"));
698        assert!(text.contains("digest="));
699        assert!(text.contains("signed="));
700    }
701
702    #[test]
703    fn cli_skill_list_human_render_emits_table() {
704        let (_dir, db_path) = fresh_db();
705        let conn = db::open(&db_path).unwrap();
706        let _ = crate::mcp::handle_skill_register(
707            &conn,
708            &json!({"inline_skill": minimal_skill_md("cli-list-human")}),
709            None,
710        )
711        .unwrap();
712        drop(conn);
713
714        let mut stdout: Vec<u8> = Vec::new();
715        let mut stderr: Vec<u8> = Vec::new();
716        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
717        let args = SkillArgs {
718            action: SkillAction::List(ListArgs {
719                namespace: Some("testns".to_string()),
720                filter: Some("cli-list-human".to_string()),
721                json: false,
722            }),
723        };
724        let code = run(&db_path, &args, None, &mut out).unwrap();
725        assert_eq!(code, 0);
726        drop(out);
727        let text = String::from_utf8(stdout).unwrap();
728        // Human render prints "{N} skills" + per-skill indented lines.
729        assert!(
730            text.contains(" skills"),
731            "expected count header, got: {text}"
732        );
733        assert!(text.contains("cli-list-human"));
734    }
735
736    #[test]
737    fn cli_skill_get_human_render_emits_markdown_header_and_body() {
738        let (_dir, db_path) = fresh_db();
739        let conn = db::open(&db_path).unwrap();
740        let reg = crate::mcp::handle_skill_register(
741            &conn,
742            &json!({"inline_skill": minimal_skill_md("cli-get-human")}),
743            None,
744        )
745        .unwrap();
746        let id = reg["id"].as_str().unwrap().to_string();
747        drop(conn);
748
749        let mut stdout: Vec<u8> = Vec::new();
750        let mut stderr: Vec<u8> = Vec::new();
751        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
752        let args = SkillArgs {
753            action: SkillAction::Get(GetArgs { id, json: false }),
754        };
755        let code = run(&db_path, &args, None, &mut out).unwrap();
756        assert_eq!(code, 0);
757        drop(out);
758        let text = String::from_utf8(stdout).unwrap();
759        // Human render prints "# {ns}/{name}\n\n{body}"
760        assert!(text.starts_with("# testns/cli-get-human"));
761        assert!(text.contains("Body."));
762    }
763
764    #[test]
765    fn cli_skill_export_human_render_emits_path_line() {
766        let (dir, db_path) = fresh_db();
767        let conn = db::open(&db_path).unwrap();
768        let reg = crate::mcp::handle_skill_register(
769            &conn,
770            &json!({"inline_skill": minimal_skill_md("cli-export-human")}),
771            None,
772        )
773        .unwrap();
774        let id = reg["id"].as_str().unwrap().to_string();
775        drop(conn);
776
777        let target = dir.path().join("export-human-out");
778        let mut stdout: Vec<u8> = Vec::new();
779        let mut stderr: Vec<u8> = Vec::new();
780        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
781        let args = SkillArgs {
782            action: SkillAction::Export(ExportArgs {
783                id: id.clone(),
784                output: target.clone(),
785                json: false,
786            }),
787        };
788        let code = run(&db_path, &args, None, &mut out).unwrap();
789        assert_eq!(code, 0);
790        assert!(target.join("SKILL.md").exists());
791        drop(out);
792        let text = String::from_utf8(stdout).unwrap();
793        // Human render prints "exported skill {id} → {folder}"
794        assert!(text.starts_with("exported skill "));
795        assert!(text.contains(&id));
796    }
797
798    #[test]
799    fn cli_skill_register_handler_error_writes_to_stderr_and_returns_2() {
800        let (_dir, db_path) = fresh_db();
801        let mut stdout: Vec<u8> = Vec::new();
802        let mut stderr: Vec<u8> = Vec::new();
803        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
804        // Pass neither --manifest nor --inline — substrate returns the
805        // "either/or" error string. Exits 2 with stderr text.
806        let args = SkillArgs {
807            action: SkillAction::Register(RegisterArgs {
808                manifest: None,
809                inline: None,
810                json: true,
811            }),
812        };
813        let code = run(&db_path, &args, None, &mut out).unwrap();
814        assert_eq!(code, 2);
815        drop(out);
816        let err = String::from_utf8(stderr).unwrap();
817        assert!(
818            err.starts_with("ai-memory skill register:"),
819            "expected stderr prefix, got: {err}"
820        );
821    }
822
823    #[test]
824    fn cli_skill_resource_returns_2_on_missing_skill() {
825        let (_dir, db_path) = fresh_db();
826        let mut stdout: Vec<u8> = Vec::new();
827        let mut stderr: Vec<u8> = Vec::new();
828        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
829        let args = SkillArgs {
830            action: SkillAction::Resource(ResourceArgs {
831                id: "no-such-skill-id".to_string(),
832                path: "doesnt-matter.txt".to_string(),
833                json: false,
834            }),
835        };
836        let code = run(&db_path, &args, None, &mut out).unwrap();
837        assert_eq!(code, 2);
838        drop(out);
839        let err = String::from_utf8(stderr).unwrap();
840        assert!(err.starts_with("ai-memory skill resource:"));
841    }
842
843    #[test]
844    fn cli_skill_promote_returns_2_on_missing_reflection() {
845        let (_dir, db_path) = fresh_db();
846        let mut stdout: Vec<u8> = Vec::new();
847        let mut stderr: Vec<u8> = Vec::new();
848        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
849        let args = SkillArgs {
850            action: SkillAction::Promote(PromoteArgs {
851                id: "no-such-reflection".to_string(),
852                name: "demo-skill".to_string(),
853                description: "Promoted from missing reflection.".to_string(),
854                parameters_schema: None,
855                json: true,
856            }),
857        };
858        let code = run(&db_path, &args, None, &mut out).unwrap();
859        assert_eq!(code, 2);
860        drop(out);
861        let err = String::from_utf8(stderr).unwrap();
862        assert!(err.starts_with("ai-memory skill promote:"));
863    }
864
865    #[test]
866    fn cli_skill_compose_returns_2_on_missing_skill() {
867        let (_dir, db_path) = fresh_db();
868        let mut stdout: Vec<u8> = Vec::new();
869        let mut stderr: Vec<u8> = Vec::new();
870        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
871        let args = SkillArgs {
872            action: SkillAction::Compose(ComposeArgs {
873                id: "no-such-skill".to_string(),
874                budget_tokens: None,
875                json: true,
876            }),
877        };
878        let code = run(&db_path, &args, None, &mut out).unwrap();
879        assert_eq!(code, 2);
880        drop(out);
881        let err = String::from_utf8(stderr).unwrap();
882        assert!(err.starts_with("ai-memory skill compose:"));
883    }
884
885    #[test]
886    fn cli_skill_register_manifest_file_path_normalised_to_parent_dir() {
887        // The run_register branch at lines 260-269: if --manifest points
888        // to a FILE (not a directory), the parent dir is handed to the
889        // substrate. Build a real folder with SKILL.md and pass the file
890        // path to exercise the is_file() → parent-dir branch.
891        let (dir, db_path) = fresh_db();
892        let folder = dir.path().join("skill-folder");
893        std::fs::create_dir_all(&folder).unwrap();
894        std::fs::write(
895            folder.join("SKILL.md"),
896            minimal_skill_md("cli-manifest-file"),
897        )
898        .unwrap();
899
900        let mut stdout: Vec<u8> = Vec::new();
901        let mut stderr: Vec<u8> = Vec::new();
902        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
903        let args = SkillArgs {
904            action: SkillAction::Register(RegisterArgs {
905                manifest: Some(folder.join("SKILL.md")),
906                inline: None,
907                json: true,
908            }),
909        };
910        let code = run(&db_path, &args, None, &mut out).unwrap();
911        assert_eq!(code, 0);
912        drop(out);
913        let text = String::from_utf8(stdout).unwrap();
914        assert!(text.contains("cli-manifest-file"));
915    }
916}