1use 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#[derive(Args, Debug, Clone)]
47pub struct SkillArgs {
48 #[command(subcommand)]
49 pub action: SkillAction,
50}
51
52#[derive(Subcommand, Debug, Clone)]
55pub enum SkillAction {
56 Register(RegisterArgs),
58 List(ListArgs),
60 Get(GetArgs),
62 Resource(ResourceArgs),
65 Export(ExportArgs),
67 Promote(PromoteArgs),
69 Compose(ComposeArgs),
72}
73
74#[derive(Args, Debug, Clone)]
81pub struct RegisterArgs {
82 #[arg(long, value_name = "PATH")]
87 pub manifest: Option<PathBuf>,
88
89 #[arg(long, value_name = "TEXT", conflicts_with = "manifest")]
94 pub inline: Option<String>,
95
96 #[arg(long, default_value_t = false)]
98 pub json: bool,
99}
100
101#[derive(Args, Debug, Clone)]
103pub struct ListArgs {
104 #[arg(long, value_name = "NS")]
106 pub namespace: Option<String>,
107
108 #[arg(long, value_name = "TEXT")]
110 pub filter: Option<String>,
111
112 #[arg(long, default_value_t = false)]
114 pub json: bool,
115}
116
117#[derive(Args, Debug, Clone)]
119pub struct GetArgs {
120 #[arg(long, value_name = "ID")]
122 pub id: String,
123
124 #[arg(long, default_value_t = false)]
126 pub json: bool,
127}
128
129#[derive(Args, Debug, Clone)]
131pub struct ResourceArgs {
132 #[arg(long, value_name = "ID")]
134 pub id: String,
135
136 #[arg(long, value_name = "PATH")]
138 pub path: String,
139
140 #[arg(long, default_value_t = false)]
142 pub json: bool,
143}
144
145#[derive(Args, Debug, Clone)]
147pub struct ExportArgs {
148 #[arg(long, value_name = "ID")]
150 pub id: String,
151
152 #[arg(long, value_name = "PATH")]
156 pub output: PathBuf,
157
158 #[arg(long, default_value_t = false)]
160 pub json: bool,
161}
162
163#[derive(Args, Debug, Clone)]
165pub struct PromoteArgs {
166 #[arg(long, value_name = "ID")]
169 pub id: String,
170
171 #[arg(long, value_name = "NAME")]
174 pub name: String,
175
176 #[arg(long, value_name = "TEXT")]
178 pub description: String,
179
180 #[arg(long, value_name = "PATH")]
183 pub parameters_schema: Option<PathBuf>,
184
185 #[arg(long, default_value_t = false)]
187 pub json: bool,
188}
189
190#[derive(Args, Debug, Clone)]
192pub struct ComposeArgs {
193 #[arg(long, value_name = "ID")]
195 pub id: String,
196
197 #[arg(long, value_name = "N")]
201 pub budget_tokens: Option<u64>,
202
203 #[arg(long, default_value_t = true)]
205 pub json: bool,
206}
207
208pub 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
251fn 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 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, ¶ms, 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
307fn 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, ¶ms) {
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
341fn 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, ¶ms) {
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
363fn 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, ¶ms) {
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
391fn 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, ¶ms, 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
422fn 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, ¶ms, 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
461fn 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, ¶ms) {
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#[cfg(test)]
488#[allow(clippy::drop_non_drop)] mod 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 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 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 assert!(text.contains(&id) || text.contains("\"body\""));
666 }
667
668 #[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 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 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 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 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 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 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}