use crate::models::field_names;
use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::{Args, Subcommand};
use serde_json::{Value, json};
use crate::cli::CliOutput;
use crate::db;
#[derive(Args, Debug, Clone)]
pub struct SkillArgs {
#[command(subcommand)]
pub action: SkillAction,
}
#[derive(Subcommand, Debug, Clone)]
pub enum SkillAction {
Register(RegisterArgs),
List(ListArgs),
Get(GetArgs),
Resource(ResourceArgs),
Export(ExportArgs),
Promote(PromoteArgs),
Compose(ComposeArgs),
}
#[derive(Args, Debug, Clone)]
pub struct RegisterArgs {
#[arg(long, value_name = "PATH")]
pub manifest: Option<PathBuf>,
#[arg(long, value_name = "TEXT", conflicts_with = "manifest")]
pub inline: Option<String>,
#[arg(long, default_value_t = false)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ListArgs {
#[arg(long, value_name = "NS")]
pub namespace: Option<String>,
#[arg(long, value_name = "TEXT")]
pub filter: Option<String>,
#[arg(long, default_value_t = false)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub struct GetArgs {
#[arg(long, value_name = "ID")]
pub id: String,
#[arg(long, default_value_t = false)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ResourceArgs {
#[arg(long, value_name = "ID")]
pub id: String,
#[arg(long, value_name = "PATH")]
pub path: String,
#[arg(long, default_value_t = false)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ExportArgs {
#[arg(long, value_name = "ID")]
pub id: String,
#[arg(long, value_name = "PATH")]
pub output: PathBuf,
#[arg(long, default_value_t = false)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub struct PromoteArgs {
#[arg(long, value_name = "ID")]
pub id: String,
#[arg(long, value_name = "NAME")]
pub name: String,
#[arg(long, value_name = "TEXT")]
pub description: String,
#[arg(long, value_name = "PATH")]
pub parameters_schema: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ComposeArgs {
#[arg(long, value_name = "ID")]
pub id: String,
#[arg(long, value_name = "N")]
pub budget_tokens: Option<u64>,
#[arg(long, default_value_t = true)]
pub json: bool,
}
pub fn run(
db_path: &Path,
args: &SkillArgs,
active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let conn = db::open(db_path)?;
match &args.action {
SkillAction::Register(a) => run_register(&conn, a, active_keypair, out),
SkillAction::List(a) => run_list(&conn, a, out),
SkillAction::Get(a) => run_get(&conn, a, out),
SkillAction::Resource(a) => run_resource(&conn, a, out),
SkillAction::Export(a) => run_export(&conn, a, active_keypair, out),
SkillAction::Promote(a) => run_promote(&conn, a, active_keypair, out),
SkillAction::Compose(a) => run_compose(&conn, a, out),
}
}
fn handler_err_exit(out: &mut CliOutput<'_>, verb: &str, e: &str) -> Result<i32> {
writeln!(out.stderr, "ai-memory skill {verb}: {e}")?;
Ok(2)
}
fn emit_json(out: &mut CliOutput<'_>, v: &Value) -> Result<()> {
let s = serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
writeln!(out.stdout, "{s}")?;
Ok(())
}
fn run_register(
conn: &rusqlite::Connection,
args: &RegisterArgs,
active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let folder_path: Option<String> = args.manifest.as_ref().map(|p| {
if p.is_file() {
p.parent().map_or_else(
|| p.to_string_lossy().into_owned(),
|d| d.to_string_lossy().into_owned(),
)
} else {
p.to_string_lossy().into_owned()
}
});
let mut params = json!({});
if let Some(ref fp) = folder_path {
params["folder_path"] = json!(fp);
}
if let Some(ref inl) = args.inline {
params["inline_skill"] = json!(inl);
}
match crate::mcp::handle_skill_register(conn, ¶ms, active_keypair) {
Ok(v) => {
if args.json {
emit_json(out, &v)?;
} else {
let id = v["id"].as_str().unwrap_or("");
let ns = v["namespace"].as_str().unwrap_or("");
let name = v["name"].as_str().unwrap_or("");
let digest = v["digest"].as_str().unwrap_or("");
let signed = v["signed"].as_bool().unwrap_or(false);
writeln!(
out.stdout,
"registered skill {ns}/{name} id={id} digest={} signed={signed}",
&digest[..digest.len().min(16)],
)?;
if let Some(prev) = v.get(field_names::SUPERSEDED_ID).and_then(Value::as_str) {
writeln!(out.stdout, " superseded previous id={prev}")?;
}
}
Ok(0)
}
Err(e) => handler_err_exit(out, "register", &e),
}
}
fn run_list(conn: &rusqlite::Connection, args: &ListArgs, out: &mut CliOutput<'_>) -> Result<i32> {
let mut params = json!({});
if let Some(ref ns) = args.namespace {
params["namespace"] = json!(ns);
}
if let Some(ref f) = args.filter {
params["filter"] = json!(f);
}
match crate::mcp::handle_skill_list(conn, ¶ms) {
Ok(v) => {
if args.json {
emit_json(out, &v)?;
} else {
let empty: Vec<Value> = Vec::new();
let arr = v["skills"].as_array().unwrap_or(&empty);
writeln!(out.stdout, "{} skills", arr.len())?;
for s in arr {
let ns = s["namespace"].as_str().unwrap_or("");
let name = s["name"].as_str().unwrap_or("");
let id = s["id"].as_str().unwrap_or("");
let desc = s[field_names::DESCRIPTION].as_str().unwrap_or("");
writeln!(out.stdout, " {ns}/{name} ({id})\n {desc}")?;
}
}
Ok(0)
}
Err(e) => handler_err_exit(out, "list", &e),
}
}
fn run_get(conn: &rusqlite::Connection, args: &GetArgs, out: &mut CliOutput<'_>) -> Result<i32> {
let params = json!({ "skill_id": args.id });
match crate::mcp::handle_skill_get(conn, ¶ms) {
Ok(v) => {
if args.json {
emit_json(out, &v)?;
} else {
let ns = v["namespace"].as_str().unwrap_or("");
let name = v["name"].as_str().unwrap_or("");
let body = v["body"].as_str().unwrap_or("");
writeln!(out.stdout, "# {ns}/{name}\n\n{body}")?;
}
Ok(0)
}
Err(e) => handler_err_exit(out, "get", &e),
}
}
fn run_resource(
conn: &rusqlite::Connection,
args: &ResourceArgs,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let params = json!({
"skill_id": args.id,
(field_names::RESOURCE_PATH): args.path,
});
match crate::mcp::handle_skill_resource(conn, ¶ms) {
Ok(v) => {
if args.json {
emit_json(out, &v)?;
} else if let Some(content) = v["content"].as_str() {
writeln!(out.stdout, "{content}")?;
} else {
emit_json(out, &v)?;
}
Ok(0)
}
Err(e) => handler_err_exit(out, "resource", &e),
}
}
fn run_export(
conn: &rusqlite::Connection,
args: &ExportArgs,
active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let params = json!({
"skill_id": args.id,
(field_names::TARGET_FOLDER): args.output.to_string_lossy(),
});
match crate::mcp::handle_skill_export(conn, ¶ms, active_keypair) {
Ok(v) => {
if args.json {
emit_json(out, &v)?;
} else {
let fallback_folder = args.output.to_string_lossy();
let folder = v[field_names::TARGET_FOLDER]
.as_str()
.unwrap_or(&fallback_folder);
writeln!(out.stdout, "exported skill {} → {folder}", args.id)?;
}
Ok(0)
}
Err(e) => handler_err_exit(out, "export", &e),
}
}
fn run_promote(
conn: &rusqlite::Connection,
args: &PromoteArgs,
active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let mut params = json!({
(field_names::REFLECTION_ID): args.id,
(field_names::SKILL_NAME): args.name,
(field_names::SKILL_DESCRIPTION): args.description,
});
if let Some(ref p) = args.parameters_schema {
let raw = std::fs::read_to_string(p)
.map_err(|e| anyhow::anyhow!("read parameters_schema {}: {e}", p.display()))?;
let v: Value = serde_json::from_str(&raw)
.map_err(|e| anyhow::anyhow!("parse parameters_schema {}: {e}", p.display()))?;
params[field_names::PARAMETERS_SCHEMA] = v;
}
match crate::mcp::handle_skill_promote_from_reflection(conn, ¶ms, active_keypair) {
Ok(v) => {
if args.json {
emit_json(out, &v)?;
} else {
let id = v["skill_id"]
.as_str()
.or_else(|| v["id"].as_str())
.unwrap_or("");
writeln!(out.stdout, "promoted reflection {} → skill {id}", args.id)?;
}
Ok(0)
}
Err(e) => handler_err_exit(out, "promote", &e),
}
}
fn run_compose(
conn: &rusqlite::Connection,
args: &ComposeArgs,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let mut params = json!({ "skill_id": args.id });
if let Some(b) = args.budget_tokens {
params[field_names::BUDGET_TOKENS] = json!(b);
}
match crate::mcp::handle_skill_compositional_context(conn, ¶ms) {
Ok(v) => {
emit_json(out, &v)?;
Ok(0)
}
Err(e) => handler_err_exit(out, "compose", &e),
}
}
#[cfg(test)]
#[allow(clippy::drop_non_drop)] mod tests {
use super::*;
use crate::cli::CliOutput;
use tempfile::TempDir;
fn fresh_db() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("ai-memory.db");
let _conn = db::open(&path).unwrap();
(dir, path)
}
fn minimal_skill_md(name: &str) -> String {
format!("---\nnamespace: testns\nname: {name}\ndescription: A demo skill.\n---\n\nBody.\n")
}
#[test]
fn cli_skill_register_inline_smoke() {
let (_dir, db_path) = fresh_db();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Register(RegisterArgs {
manifest: None,
inline: Some(minimal_skill_md("cli-register")),
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(text.contains("\"registered\""));
assert!(text.contains("cli-register"));
}
#[test]
fn cli_skill_list_smoke() {
let (_dir, db_path) = fresh_db();
let conn = db::open(&db_path).unwrap();
let _ = crate::mcp::handle_skill_register(
&conn,
&json!({"inline_skill": minimal_skill_md("cli-list")}),
None,
)
.unwrap();
drop(conn);
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::List(ListArgs {
namespace: Some("testns".to_string()),
filter: None,
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(text.contains("cli-list"));
}
#[test]
fn cli_skill_get_smoke() {
let (_dir, db_path) = fresh_db();
let conn = db::open(&db_path).unwrap();
let reg = crate::mcp::handle_skill_register(
&conn,
&json!({"inline_skill": minimal_skill_md("cli-get")}),
None,
)
.unwrap();
let id = reg["id"].as_str().unwrap().to_string();
drop(conn);
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Get(GetArgs {
id: id.clone(),
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(text.contains(&id));
assert!(text.contains("cli-get"));
}
#[test]
fn cli_skill_export_smoke() {
let (dir, db_path) = fresh_db();
let conn = db::open(&db_path).unwrap();
let reg = crate::mcp::handle_skill_register(
&conn,
&json!({"inline_skill": minimal_skill_md("cli-export")}),
None,
)
.unwrap();
let id = reg["id"].as_str().unwrap().to_string();
drop(conn);
let target = dir.path().join("export-out");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Export(ExportArgs {
id: id.clone(),
output: target.clone(),
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
assert!(target.join("SKILL.md").exists());
}
#[test]
fn cli_skill_get_missing_id_exits_nonzero() {
let (_dir, db_path) = fresh_db();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Get(GetArgs {
id: "no-such-skill".to_string(),
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 2);
drop(out);
let err = String::from_utf8(stderr).unwrap();
assert!(err.contains(crate::errors::msg::SKILL_NOT_FOUND));
}
#[test]
fn cli_skill_compose_smoke() {
let (_dir, db_path) = fresh_db();
let conn = db::open(&db_path).unwrap();
let reg = crate::mcp::handle_skill_register(
&conn,
&json!({"inline_skill": minimal_skill_md("cli-compose")}),
None,
)
.unwrap();
let id = reg["id"].as_str().unwrap().to_string();
drop(conn);
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Compose(ComposeArgs {
id: id.clone(),
budget_tokens: Some(1000),
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(text.contains(&id) || text.contains("\"body\""));
}
#[test]
fn cli_skill_register_human_render_emits_summary_line() {
let (_dir, db_path) = fresh_db();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Register(RegisterArgs {
manifest: None,
inline: Some(minimal_skill_md("cli-register-human")),
json: false,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(
text.starts_with("registered skill "),
"expected human-render summary line, got: {text}"
);
assert!(text.contains("cli-register-human"));
assert!(text.contains("digest="));
assert!(text.contains("signed="));
}
#[test]
fn cli_skill_list_human_render_emits_table() {
let (_dir, db_path) = fresh_db();
let conn = db::open(&db_path).unwrap();
let _ = crate::mcp::handle_skill_register(
&conn,
&json!({"inline_skill": minimal_skill_md("cli-list-human")}),
None,
)
.unwrap();
drop(conn);
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::List(ListArgs {
namespace: Some("testns".to_string()),
filter: Some("cli-list-human".to_string()),
json: false,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(
text.contains(" skills"),
"expected count header, got: {text}"
);
assert!(text.contains("cli-list-human"));
}
#[test]
fn cli_skill_get_human_render_emits_markdown_header_and_body() {
let (_dir, db_path) = fresh_db();
let conn = db::open(&db_path).unwrap();
let reg = crate::mcp::handle_skill_register(
&conn,
&json!({"inline_skill": minimal_skill_md("cli-get-human")}),
None,
)
.unwrap();
let id = reg["id"].as_str().unwrap().to_string();
drop(conn);
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Get(GetArgs { id, json: false }),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(text.starts_with("# testns/cli-get-human"));
assert!(text.contains("Body."));
}
#[test]
fn cli_skill_export_human_render_emits_path_line() {
let (dir, db_path) = fresh_db();
let conn = db::open(&db_path).unwrap();
let reg = crate::mcp::handle_skill_register(
&conn,
&json!({"inline_skill": minimal_skill_md("cli-export-human")}),
None,
)
.unwrap();
let id = reg["id"].as_str().unwrap().to_string();
drop(conn);
let target = dir.path().join("export-human-out");
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Export(ExportArgs {
id: id.clone(),
output: target.clone(),
json: false,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
assert!(target.join("SKILL.md").exists());
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(text.starts_with("exported skill "));
assert!(text.contains(&id));
}
#[test]
fn cli_skill_register_handler_error_writes_to_stderr_and_returns_2() {
let (_dir, db_path) = fresh_db();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Register(RegisterArgs {
manifest: None,
inline: None,
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 2);
drop(out);
let err = String::from_utf8(stderr).unwrap();
assert!(
err.starts_with("ai-memory skill register:"),
"expected stderr prefix, got: {err}"
);
}
#[test]
fn cli_skill_resource_returns_2_on_missing_skill() {
let (_dir, db_path) = fresh_db();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Resource(ResourceArgs {
id: "no-such-skill-id".to_string(),
path: "doesnt-matter.txt".to_string(),
json: false,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 2);
drop(out);
let err = String::from_utf8(stderr).unwrap();
assert!(err.starts_with("ai-memory skill resource:"));
}
#[test]
fn cli_skill_promote_returns_2_on_missing_reflection() {
let (_dir, db_path) = fresh_db();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Promote(PromoteArgs {
id: "no-such-reflection".to_string(),
name: "demo-skill".to_string(),
description: "Promoted from missing reflection.".to_string(),
parameters_schema: None,
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 2);
drop(out);
let err = String::from_utf8(stderr).unwrap();
assert!(err.starts_with("ai-memory skill promote:"));
}
#[test]
fn cli_skill_compose_returns_2_on_missing_skill() {
let (_dir, db_path) = fresh_db();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Compose(ComposeArgs {
id: "no-such-skill".to_string(),
budget_tokens: None,
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 2);
drop(out);
let err = String::from_utf8(stderr).unwrap();
assert!(err.starts_with("ai-memory skill compose:"));
}
#[test]
fn cli_skill_register_manifest_file_path_normalised_to_parent_dir() {
let (dir, db_path) = fresh_db();
let folder = dir.path().join("skill-folder");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(
folder.join("SKILL.md"),
minimal_skill_md("cli-manifest-file"),
)
.unwrap();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = SkillArgs {
action: SkillAction::Register(RegisterArgs {
manifest: Some(folder.join("SKILL.md")),
inline: None,
json: true,
}),
};
let code = run(&db_path, &args, None, &mut out).unwrap();
assert_eq!(code, 0);
drop(out);
let text = String::from_utf8(stdout).unwrap();
assert!(text.contains("cli-manifest-file"));
}
}