Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 13fdd3b

Browse files
z23ccclaude
andcommitted
feat(flowctl-cli): add skill register and skill-match commands
skill register: scans skills/*/SKILL.md, extracts YAML frontmatter (name + description), embeds via BGE-small, stores in skills table. skill match: semantic search against registered skills, returns ranked results with cosine similarity scores. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent efb1edf commit 13fdd3b

4 files changed

Lines changed: 226 additions & 0 deletions

File tree

flowctl/crates/flowctl-cli/src/commands/db_shim.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ impl Connection {
3030
fn inner(&self) -> libsql::Connection {
3131
self.conn.clone()
3232
}
33+
34+
/// Public accessor for modules that need the raw libsql connection
35+
/// (e.g. skill commands that call async repos directly).
36+
pub fn inner_conn(&self) -> libsql::Connection {
37+
self.conn.clone()
38+
}
3339
}
3440

3541
fn block_on<F: std::future::Future>(fut: F) -> F::Output {

flowctl/crates/flowctl-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ pub mod rp;
1818
pub mod stack;
1919
pub mod stats;
2020
pub mod task;
21+
pub mod skill;
2122
pub mod workflow;
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//! Skill commands: register, match.
2+
//!
3+
//! `skill register` scans `skills/*/SKILL.md` files, extracts YAML
4+
//! frontmatter (name + description), and upserts each into the DB with
5+
//! a BGE-small embedding for semantic matching.
6+
//!
7+
//! `skill match` performs semantic vector search against registered
8+
//! skills and returns ranked results.
9+
10+
use clap::Subcommand;
11+
use serde::Deserialize;
12+
use serde_json::json;
13+
14+
use crate::output::{error_exit, json_output, pretty_output};
15+
16+
use super::db_shim;
17+
18+
// ── CLI definition ─────────────────────────────────────────────────
19+
20+
#[derive(Subcommand, Debug)]
21+
pub enum SkillCmd {
22+
/// Scan skills/*/SKILL.md and register into DB with embeddings.
23+
Register {
24+
/// Directory to scan (default: DROID_PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT).
25+
#[arg(long)]
26+
dir: Option<String>,
27+
},
28+
/// Semantic search against registered skills.
29+
Match {
30+
/// Search query text.
31+
query: String,
32+
/// Maximum results to return.
33+
#[arg(long, default_value = "5")]
34+
limit: usize,
35+
/// Minimum cosine similarity threshold.
36+
#[arg(long, default_value = "0.75")]
37+
threshold: f64,
38+
},
39+
}
40+
41+
// ── Frontmatter struct ─────────────────────────────────────────────
42+
43+
#[derive(Deserialize)]
44+
struct SkillFrontmatter {
45+
name: String,
46+
description: String,
47+
}
48+
49+
// ── Dispatch ───────────────────────────────────────────────────────
50+
51+
pub fn dispatch(cmd: &SkillCmd, json: bool) {
52+
match cmd {
53+
SkillCmd::Register { dir } => cmd_skill_register(json, dir.as_deref()),
54+
SkillCmd::Match {
55+
query,
56+
limit,
57+
threshold,
58+
} => cmd_skill_match(json, query, *limit, *threshold),
59+
}
60+
}
61+
62+
// ── Register ───────────────────────────────────────────────────────
63+
64+
fn cmd_skill_register(json: bool, dir: Option<&str>) {
65+
// Resolve plugin root directory.
66+
let root = match dir {
67+
Some(d) => std::path::PathBuf::from(d),
68+
None => {
69+
if let Ok(d) = std::env::var("DROID_PLUGIN_ROOT") {
70+
std::path::PathBuf::from(d)
71+
} else if let Ok(d) = std::env::var("CLAUDE_PLUGIN_ROOT") {
72+
std::path::PathBuf::from(d)
73+
} else {
74+
error_exit("No --dir given and DROID_PLUGIN_ROOT / CLAUDE_PLUGIN_ROOT not set");
75+
}
76+
}
77+
};
78+
79+
let skills_dir = root.join("skills");
80+
if !skills_dir.is_dir() {
81+
error_exit(&format!("Skills directory not found: {}", skills_dir.display()));
82+
}
83+
84+
// Walk skills/*/SKILL.md
85+
let mut entries: Vec<(String, String, String)> = Vec::new(); // (name, description, path)
86+
let read_dir = std::fs::read_dir(&skills_dir).unwrap_or_else(|e| {
87+
error_exit(&format!("Cannot read {}: {e}", skills_dir.display()));
88+
});
89+
90+
for entry in read_dir.flatten() {
91+
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
92+
continue;
93+
}
94+
let skill_md = entry.path().join("SKILL.md");
95+
if !skill_md.is_file() {
96+
continue;
97+
}
98+
let content = match std::fs::read_to_string(&skill_md) {
99+
Ok(c) => c,
100+
Err(e) => {
101+
eprintln!("warn: cannot read {}: {e}", skill_md.display());
102+
continue;
103+
}
104+
};
105+
let fm: SkillFrontmatter =
106+
match flowctl_core::frontmatter::parse_frontmatter(&content) {
107+
Ok(f) => f,
108+
Err(e) => {
109+
eprintln!(
110+
"warn: cannot parse frontmatter in {}: {e}",
111+
skill_md.display()
112+
);
113+
continue;
114+
}
115+
};
116+
entries.push((
117+
fm.name,
118+
fm.description,
119+
skill_md.to_string_lossy().to_string(),
120+
));
121+
}
122+
123+
// Upsert each skill into DB.
124+
let conn = db_shim::require_db().unwrap_or_else(|e| {
125+
error_exit(&format!("Cannot open DB: {e}"));
126+
});
127+
128+
let rt = tokio::runtime::Builder::new_current_thread()
129+
.enable_all()
130+
.build()
131+
.expect("failed to create tokio runtime");
132+
133+
let repo = flowctl_db::skill::SkillRepo::new(conn.inner_conn());
134+
135+
for (name, desc, path) in &entries {
136+
rt.block_on(async {
137+
repo.upsert(name, desc, Some(path.as_str()))
138+
.await
139+
.unwrap_or_else(|e| {
140+
eprintln!("warn: failed to upsert skill '{}': {e}", name);
141+
});
142+
});
143+
}
144+
145+
let skills_json: Vec<serde_json::Value> = entries
146+
.iter()
147+
.map(|(n, d, _)| json!({"name": n, "description": d}))
148+
.collect();
149+
150+
if json {
151+
json_output(json!({
152+
"registered": entries.len(),
153+
"skills": skills_json,
154+
}));
155+
} else {
156+
pretty_output("skill_register", &format!("Registered {} skills", entries.len()));
157+
for (name, desc, _) in &entries {
158+
pretty_output("skill_register", &format!(" {} — {}", name, desc));
159+
}
160+
}
161+
}
162+
163+
// ── Match ──────────────────────────────────────────────────────────
164+
165+
fn cmd_skill_match(json: bool, query: &str, limit: usize, threshold: f64) {
166+
let conn = db_shim::require_db().unwrap_or_else(|e| {
167+
error_exit(&format!("Cannot open DB: {e}"));
168+
});
169+
170+
let rt = tokio::runtime::Builder::new_current_thread()
171+
.enable_all()
172+
.build()
173+
.expect("failed to create tokio runtime");
174+
175+
let repo = flowctl_db::skill::SkillRepo::new(conn.inner_conn());
176+
let matches = rt.block_on(async {
177+
repo.match_skills(query, limit, threshold)
178+
.await
179+
.unwrap_or_else(|e| {
180+
error_exit(&format!("match_skills failed: {e}"));
181+
})
182+
});
183+
184+
if json {
185+
let out: Vec<serde_json::Value> = matches
186+
.iter()
187+
.map(|m| {
188+
json!({
189+
"name": m.name,
190+
"description": m.description,
191+
"score": (m.score * 100.0).round() / 100.0,
192+
})
193+
})
194+
.collect();
195+
json_output(json!(out));
196+
} else {
197+
if matches.is_empty() {
198+
pretty_output("skill_match", "No matching skills found.");
199+
return;
200+
}
201+
pretty_output(
202+
"skill_match",
203+
&format!(" {:<6} {:<28} {}", "Score", "Name", "Description"),
204+
);
205+
for m in &matches {
206+
pretty_output(
207+
"skill_match",
208+
&format!(" {:<6.2} {:<28} {}", m.score, m.name, m.description),
209+
);
210+
}
211+
}
212+
}

flowctl/crates/flowctl-cli/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use commands::{
2323
query,
2424
ralph::RalphCmd,
2525
rp::RpCmd,
26+
skill::SkillCmd,
2627
stack::{InvariantsCmd, StackCmd},
2728
stats::StatsCmd,
2829
task::TaskCmd,
@@ -216,6 +217,11 @@ enum Commands {
216217
#[command(subcommand)]
217218
cmd: RalphCmd,
218219
},
220+
/// Skill registry commands (register, match).
221+
Skill {
222+
#[command(subcommand)]
223+
cmd: SkillCmd,
224+
},
219225
/// RepoPrompt helpers.
220226
Rp {
221227
#[command(subcommand)]
@@ -484,6 +490,7 @@ fn main() {
484490
Commands::Stack { cmd } => commands::stack::dispatch(&cmd, json),
485491
Commands::Invariants { cmd } => commands::stack::dispatch_invariants(&cmd, json),
486492
Commands::Ralph { cmd } => commands::ralph::dispatch(&cmd, json),
493+
Commands::Skill { cmd } => commands::skill::dispatch(&cmd, json),
487494
Commands::Rp { cmd } => commands::rp::dispatch(&cmd, json),
488495
Commands::Codex { cmd } => commands::codex::dispatch(&cmd, json),
489496
Commands::Hook { cmd } => commands::hook::dispatch(&cmd),

0 commit comments

Comments
 (0)