Skip to content

Commit babfe90

Browse files
committed
feat(scripted_tool): add discover command with tags, categories, search
Extend ToolDef with tags and category fields for progressive discovery. Add DiscoverBuiltin with --categories, --category, --tag, --search, and --json modes. Register alongside help builtin. 11 new tests. Update spec 014. Closes #521
1 parent bf5cf75 commit babfe90

File tree

3 files changed

+367
-4
lines changed

3 files changed

+367
-4
lines changed

crates/bashkit/src/scripted_tool/execute.rs

Lines changed: 326 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,14 @@ impl Builtin for ToolBuiltinAdapter {
149149
// HelpBuiltin — runtime schema introspection
150150
// ============================================================================
151151

152-
/// Snapshot of a tool definition for the `help` builtin.
152+
/// Snapshot of a tool definition for the `help` and `discover` builtins.
153153
#[derive(Clone)]
154154
struct ToolDefSnapshot {
155155
name: String,
156156
description: String,
157157
input_schema: serde_json::Value,
158+
tags: Vec<String>,
159+
category: Option<String>,
158160
}
159161

160162
/// Built-in `help` command for runtime tool schema introspection.
@@ -219,6 +221,134 @@ impl Builtin for HelpBuiltin {
219221
}
220222
}
221223

224+
// ============================================================================
225+
// DiscoverBuiltin — progressive tool discovery
226+
// ============================================================================
227+
228+
/// Built-in `discover` command for exploring large tool sets.
229+
struct DiscoverBuiltin {
230+
tools: Vec<ToolDefSnapshot>,
231+
}
232+
233+
impl DiscoverBuiltin {
234+
fn filter_tools(&self, args: &[String]) -> (Vec<&ToolDefSnapshot>, bool) {
235+
let json_mode = args.iter().any(|a| a == "--json");
236+
237+
if args.iter().any(|a| a == "--categories") {
238+
return (Vec::new(), json_mode);
239+
}
240+
241+
if let Some(pos) = args.iter().position(|a| a == "--category") {
242+
let cat = args.get(pos + 1).map(|s| s.as_str()).unwrap_or("");
243+
let filtered: Vec<&ToolDefSnapshot> = self
244+
.tools
245+
.iter()
246+
.filter(|t| t.category.as_deref() == Some(cat))
247+
.collect();
248+
return (filtered, json_mode);
249+
}
250+
251+
if let Some(pos) = args.iter().position(|a| a == "--tag") {
252+
let tag = args.get(pos + 1).map(|s| s.as_str()).unwrap_or("");
253+
let filtered: Vec<&ToolDefSnapshot> = self
254+
.tools
255+
.iter()
256+
.filter(|t| t.tags.iter().any(|tg| tg == tag))
257+
.collect();
258+
return (filtered, json_mode);
259+
}
260+
261+
if let Some(pos) = args.iter().position(|a| a == "--search") {
262+
let keyword = args
263+
.get(pos + 1)
264+
.map(|s| s.to_lowercase())
265+
.unwrap_or_default();
266+
let filtered: Vec<&ToolDefSnapshot> = self
267+
.tools
268+
.iter()
269+
.filter(|t| {
270+
t.name.to_lowercase().contains(&keyword)
271+
|| t.description.to_lowercase().contains(&keyword)
272+
})
273+
.collect();
274+
return (filtered, json_mode);
275+
}
276+
277+
(self.tools.iter().collect(), json_mode)
278+
}
279+
}
280+
281+
#[async_trait]
282+
impl Builtin for DiscoverBuiltin {
283+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
284+
let args = ctx.args;
285+
286+
if args.is_empty() {
287+
return Ok(ExecResult::err(
288+
"usage: discover --categories | --category <name> | --tag <tag> | --search <keyword> [--json]".to_string(),
289+
1,
290+
));
291+
}
292+
293+
let json_mode = args.iter().any(|a| a == "--json");
294+
295+
// --categories
296+
if args.iter().any(|a| a == "--categories") {
297+
let mut cats: std::collections::BTreeMap<String, usize> =
298+
std::collections::BTreeMap::new();
299+
for t in &self.tools {
300+
if let Some(ref cat) = t.category {
301+
*cats.entry(cat.clone()).or_insert(0) += 1;
302+
}
303+
}
304+
if json_mode {
305+
let arr: Vec<serde_json::Value> = cats
306+
.iter()
307+
.map(|(name, count)| serde_json::json!({"category": name, "count": count}))
308+
.collect();
309+
let json_str =
310+
serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string());
311+
return Ok(ExecResult::ok(format!("{json_str}\n")));
312+
}
313+
let mut out = String::new();
314+
for (name, count) in &cats {
315+
let plural = if *count == 1 { "tool" } else { "tools" };
316+
out.push_str(&format!("{name} ({count} {plural})\n"));
317+
}
318+
return Ok(ExecResult::ok(out));
319+
}
320+
321+
let (filtered, _) = self.filter_tools(args);
322+
323+
if json_mode {
324+
let arr: Vec<serde_json::Value> = filtered
325+
.iter()
326+
.map(|t| {
327+
let mut obj = serde_json::json!({
328+
"name": t.name,
329+
"description": t.description,
330+
});
331+
if !t.tags.is_empty() {
332+
obj["tags"] = serde_json::json!(t.tags);
333+
}
334+
if let Some(ref cat) = t.category {
335+
obj["category"] = serde_json::json!(cat);
336+
}
337+
obj
338+
})
339+
.collect();
340+
let json_str = serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string());
341+
return Ok(ExecResult::ok(format!("{json_str}\n")));
342+
}
343+
344+
let mut out = String::new();
345+
for t in &filtered {
346+
out.push_str(&format!("{:<20} {}\n", t.name, t.description));
347+
}
348+
Ok(ExecResult::ok(out))
349+
}
350+
}
351+
222352
// ============================================================================
223353
// ScriptedTool — internal helpers
224354
// ============================================================================
@@ -243,19 +373,27 @@ impl ScriptedTool {
243373
builder = builder.builtin(name, builtin);
244374
}
245375

246-
// Register the help builtin
376+
// Register the help and discover builtins
247377
let snapshots: Vec<ToolDefSnapshot> = self
248378
.tools
249379
.iter()
250380
.map(|t| ToolDefSnapshot {
251381
name: t.def.name.clone(),
252382
description: t.def.description.clone(),
253383
input_schema: t.def.input_schema.clone(),
384+
tags: t.def.tags.clone(),
385+
category: t.def.category.clone(),
254386
})
255387
.collect();
256388
builder = builder.builtin(
257389
"help".to_string(),
258-
Box::new(HelpBuiltin { tools: snapshots }),
390+
Box::new(HelpBuiltin {
391+
tools: snapshots.clone(),
392+
}),
393+
);
394+
builder = builder.builtin(
395+
"discover".to_string(),
396+
Box::new(DiscoverBuiltin { tools: snapshots }),
259397
);
260398

261399
builder.build()
@@ -739,4 +877,189 @@ mod tests {
739877
);
740878
}
741879
}
880+
881+
// -- DiscoverBuiltin tests --
882+
883+
fn build_discover_test_tool() -> ScriptedTool {
884+
ScriptedTool::builder("big_api")
885+
.short_description("Big API")
886+
.tool(
887+
ToolDef::new("create_charge", "Create a payment charge")
888+
.with_category("payments")
889+
.with_tags(&["billing", "write"]),
890+
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
891+
)
892+
.tool(
893+
ToolDef::new("refund", "Issue a refund")
894+
.with_category("payments")
895+
.with_tags(&["billing", "write"]),
896+
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
897+
)
898+
.tool(
899+
ToolDef::new("get_user", "Fetch user by ID")
900+
.with_category("users")
901+
.with_tags(&["read"]),
902+
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
903+
)
904+
.tool(
905+
ToolDef::new("delete_user", "Delete a user account")
906+
.with_category("users")
907+
.with_tags(&["admin", "write"]),
908+
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
909+
)
910+
.tool(
911+
ToolDef::new("get_inventory", "Check inventory levels").with_category("inventory"),
912+
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
913+
)
914+
.build()
915+
}
916+
917+
#[tokio::test]
918+
async fn test_discover_categories() {
919+
let mut tool = build_discover_test_tool();
920+
let resp = tool
921+
.execute(ToolRequest {
922+
commands: "discover --categories".to_string(),
923+
timeout_ms: None,
924+
})
925+
.await;
926+
assert_eq!(resp.exit_code, 0);
927+
assert!(resp.stdout.contains("payments (2 tools)"));
928+
assert!(resp.stdout.contains("users (2 tools)"));
929+
assert!(resp.stdout.contains("inventory (1 tool)"));
930+
}
931+
932+
#[tokio::test]
933+
async fn test_discover_category_filter() {
934+
let mut tool = build_discover_test_tool();
935+
let resp = tool
936+
.execute(ToolRequest {
937+
commands: "discover --category payments".to_string(),
938+
timeout_ms: None,
939+
})
940+
.await;
941+
assert_eq!(resp.exit_code, 0);
942+
assert!(resp.stdout.contains("create_charge"));
943+
assert!(resp.stdout.contains("refund"));
944+
assert!(!resp.stdout.contains("get_user"));
945+
}
946+
947+
#[tokio::test]
948+
async fn test_discover_tag_filter() {
949+
let mut tool = build_discover_test_tool();
950+
let resp = tool
951+
.execute(ToolRequest {
952+
commands: "discover --tag admin".to_string(),
953+
timeout_ms: None,
954+
})
955+
.await;
956+
assert_eq!(resp.exit_code, 0);
957+
assert!(resp.stdout.contains("delete_user"));
958+
assert!(!resp.stdout.contains("create_charge"));
959+
}
960+
961+
#[tokio::test]
962+
async fn test_discover_search() {
963+
let mut tool = build_discover_test_tool();
964+
let resp = tool
965+
.execute(ToolRequest {
966+
commands: "discover --search user".to_string(),
967+
timeout_ms: None,
968+
})
969+
.await;
970+
assert_eq!(resp.exit_code, 0);
971+
assert!(resp.stdout.contains("get_user"));
972+
assert!(resp.stdout.contains("delete_user"));
973+
assert!(!resp.stdout.contains("create_charge"));
974+
}
975+
976+
#[tokio::test]
977+
async fn test_discover_search_case_insensitive() {
978+
let mut tool = build_discover_test_tool();
979+
let resp = tool
980+
.execute(ToolRequest {
981+
commands: "discover --search REFUND".to_string(),
982+
timeout_ms: None,
983+
})
984+
.await;
985+
assert_eq!(resp.exit_code, 0);
986+
assert!(resp.stdout.contains("refund"));
987+
}
988+
989+
#[tokio::test]
990+
async fn test_discover_categories_json() {
991+
let mut tool = build_discover_test_tool();
992+
let resp = tool
993+
.execute(ToolRequest {
994+
commands: "discover --categories --json".to_string(),
995+
timeout_ms: None,
996+
})
997+
.await;
998+
assert_eq!(resp.exit_code, 0);
999+
let arr: Vec<serde_json::Value> =
1000+
serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
1001+
assert!(
1002+
arr.iter()
1003+
.any(|v| v["category"] == "payments" && v["count"] == 2)
1004+
);
1005+
}
1006+
1007+
#[tokio::test]
1008+
async fn test_discover_category_json() {
1009+
let mut tool = build_discover_test_tool();
1010+
let resp = tool
1011+
.execute(ToolRequest {
1012+
commands: "discover --category payments --json".to_string(),
1013+
timeout_ms: None,
1014+
})
1015+
.await;
1016+
assert_eq!(resp.exit_code, 0);
1017+
let arr: Vec<serde_json::Value> =
1018+
serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
1019+
assert_eq!(arr.len(), 2);
1020+
assert!(arr.iter().any(|v| v["name"] == "create_charge"));
1021+
}
1022+
1023+
#[tokio::test]
1024+
async fn test_discover_no_args_shows_usage() {
1025+
let mut tool = build_discover_test_tool();
1026+
let resp = tool
1027+
.execute(ToolRequest {
1028+
commands: "discover".to_string(),
1029+
timeout_ms: None,
1030+
})
1031+
.await;
1032+
assert_ne!(resp.exit_code, 0);
1033+
assert!(resp.stderr.contains("usage:"));
1034+
}
1035+
1036+
#[tokio::test]
1037+
async fn test_discover_tag_json() {
1038+
let mut tool = build_discover_test_tool();
1039+
let resp = tool
1040+
.execute(ToolRequest {
1041+
commands: "discover --tag billing --json".to_string(),
1042+
timeout_ms: None,
1043+
})
1044+
.await;
1045+
assert_eq!(resp.exit_code, 0);
1046+
let arr: Vec<serde_json::Value> =
1047+
serde_json::from_str(resp.stdout.trim()).expect("valid JSON");
1048+
assert_eq!(arr.len(), 2);
1049+
assert!(arr.iter().all(|v| {
1050+
v["tags"]
1051+
.as_array()
1052+
.expect("tags array")
1053+
.contains(&serde_json::json!("billing"))
1054+
}));
1055+
}
1056+
1057+
#[tokio::test]
1058+
async fn test_tooldef_with_tags_and_category() {
1059+
let def = ToolDef::new("test", "A test tool")
1060+
.with_tags(&["admin", "billing"])
1061+
.with_category("payments");
1062+
assert_eq!(def.tags, vec!["admin", "billing"]);
1063+
assert_eq!(def.category.as_deref(), Some("payments"));
1064+
}
7421065
}

0 commit comments

Comments
 (0)