Skip to content

Commit c889a11

Browse files
authored
refactor(tool): migrate Glob/LS/Grep/Skill/Screenshot to typed schemas (#320)
Follow the established Schema()+Parameters() delegation pattern (single source of truth) with schema-parity tests locking the wire shape, and route Execute input parsing through DecodeInput[T]. Empty-input semantics preserved for LS/Skill (list modes). FuzzyFind excluded: its minimum/maximum constraints need a SchemaProperty extension first.
1 parent 225632d commit c889a11

6 files changed

Lines changed: 220 additions & 67 deletions

File tree

‎internal/tool/glob.go‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,36 @@ func (GlobTool) Name() string { return "Glob" }
1515
func (GlobTool) RiskLevel() string { return "low" }
1616
func (GlobTool) Aliases() []string { return []string{"glob"} }
1717
func (GlobTool) Description() string { return "Find files matching a glob pattern." }
18-
func (GlobTool) Parameters() map[string]interface{} {
19-
return map[string]interface{}{
20-
"type": "object",
21-
"properties": map[string]interface{}{
22-
"pattern": map[string]interface{}{"type": "string", "description": "Glob pattern (e.g. **/*.go)"},
23-
"path": map[string]interface{}{"type": "string", "description": "Root directory (default: current dir)"},
18+
19+
// GlobInput is the typed input for GlobTool.
20+
type GlobInput struct {
21+
Pattern string `json:"pattern"`
22+
Path string `json:"path"`
23+
}
24+
25+
// Schema returns the typed input schema. Parameters() delegates to it so the
26+
// two cannot diverge.
27+
func (GlobTool) Schema() ToolSchema {
28+
return ToolSchema{
29+
Type: "object",
30+
Properties: map[string]SchemaProperty{
31+
"pattern": {Type: "string", Description: "Glob pattern (e.g. **/*.go)"},
32+
"path": {Type: "string", Description: "Root directory (default: current dir)"},
2433
},
25-
"required": []string{"pattern"},
34+
Required: []string{"pattern"},
2635
}
2736
}
2837

38+
func (GlobTool) Parameters() map[string]interface{} {
39+
return globSchema.ToJSONSchema()
40+
}
41+
42+
// globSchema is the single source of truth for Glob's input schema.
43+
var globSchema = GlobTool{}.Schema()
44+
2945
func (GlobTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
30-
var p struct {
31-
Pattern string `json:"pattern"`
32-
Path string `json:"path"`
33-
}
34-
if err := json.Unmarshal(input, &p); err != nil {
46+
p, err := DecodeInput[GlobInput]("Glob", input)
47+
if err != nil {
3548
return "", err
3649
}
3750
root := p.Path
@@ -42,7 +55,7 @@ func (GlobTool) Execute(ctx context.Context, input json.RawMessage) (string, err
4255
return "", err
4356
}
4457
var matches []string
45-
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
58+
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
4659
if err != nil {
4760
return nil
4861
}

‎internal/tool/grep.go‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,38 @@ func (GrepTool) Name() string { return "Grep" }
1717
func (GrepTool) RiskLevel() string { return "low" }
1818
func (GrepTool) Aliases() []string { return []string{"grep"} }
1919
func (GrepTool) Description() string { return "Search for a regex pattern in files." }
20-
func (GrepTool) Parameters() map[string]interface{} {
21-
return map[string]interface{}{
22-
"type": "object",
23-
"properties": map[string]interface{}{
24-
"pattern": map[string]interface{}{"type": "string", "description": "Regex pattern to search for"},
25-
"path": map[string]interface{}{"type": "string", "description": "Directory to search (default: current dir)"},
26-
"include": map[string]interface{}{"type": "string", "description": "File glob filter (e.g. *.go)"},
20+
21+
// GrepInput is the typed input for GrepTool.
22+
type GrepInput struct {
23+
Pattern string `json:"pattern"`
24+
Path string `json:"path"`
25+
Include string `json:"include"`
26+
}
27+
28+
// Schema returns the typed input schema. Parameters() delegates to it so the
29+
// two cannot diverge.
30+
func (GrepTool) Schema() ToolSchema {
31+
return ToolSchema{
32+
Type: "object",
33+
Properties: map[string]SchemaProperty{
34+
"pattern": {Type: "string", Description: "Regex pattern to search for"},
35+
"path": {Type: "string", Description: "Directory to search (default: current dir)"},
36+
"include": {Type: "string", Description: "File glob filter (e.g. *.go)"},
2737
},
28-
"required": []string{"pattern"},
38+
Required: []string{"pattern"},
2939
}
3040
}
3141

42+
func (GrepTool) Parameters() map[string]interface{} {
43+
return grepSchema.ToJSONSchema()
44+
}
45+
46+
// grepSchema is the single source of truth for Grep's input schema.
47+
var grepSchema = GrepTool{}.Schema()
48+
3249
func (GrepTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
33-
var p struct {
34-
Pattern string `json:"pattern"`
35-
Path string `json:"path"`
36-
Include string `json:"include"`
37-
}
38-
if err := json.Unmarshal(input, &p); err != nil {
50+
p, err := DecodeInput[GrepInput]("Grep", input)
51+
if err != nil {
3952
return "", err
4053
}
4154
re, err := regexp.Compile(p.Pattern)

‎internal/tool/ls.go‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,38 @@ func (LSTool) Description() string {
1919
return "List files and directories in a directory."
2020
}
2121

22-
func (LSTool) Parameters() map[string]interface{} {
23-
return map[string]interface{}{
24-
"type": "object",
25-
"properties": map[string]interface{}{
26-
"path": map[string]interface{}{"type": "string", "description": "Directory path to list (default: current directory)"},
27-
"ignore": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Optional file or glob patterns to exclude"},
22+
// LSInput is the typed input for LSTool.
23+
type LSInput struct {
24+
Path string `json:"path"`
25+
Ignore []string `json:"ignore"`
26+
}
27+
28+
// Schema returns the typed input schema. Parameters() delegates to it so the
29+
// two cannot diverge.
30+
func (LSTool) Schema() ToolSchema {
31+
return ToolSchema{
32+
Type: "object",
33+
Properties: map[string]SchemaProperty{
34+
"path": {Type: "string", Description: "Directory path to list (default: current directory)"},
35+
"ignore": {Type: "array", Description: "Optional file or glob patterns to exclude", Items: &SchemaProperty{Type: "string"}},
2836
},
2937
}
3038
}
3139

40+
func (LSTool) Parameters() map[string]interface{} {
41+
return lsSchema.ToJSONSchema()
42+
}
43+
44+
// lsSchema is the single source of truth for LS's input schema.
45+
var lsSchema = LSTool{}.Schema()
46+
3247
func (LSTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
33-
var p struct {
34-
Path string `json:"path"`
35-
Ignore []string `json:"ignore"`
36-
}
48+
// Empty input lists the current directory; only decode when present.
49+
var p LSInput
3750
if len(input) > 0 {
38-
if err := json.Unmarshal(input, &p); err != nil {
51+
var err error
52+
p, err = DecodeInput[LSInput]("LS", input)
53+
if err != nil {
3954
return "", err
4055
}
4156
}

‎internal/tool/schema_batch_test.go‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package tool
2+
3+
import "testing"
4+
5+
func schemaProps(t *testing.T, params map[string]interface{}) map[string]interface{} {
6+
t.Helper()
7+
props, ok := params["properties"].(map[string]interface{})
8+
if !ok {
9+
t.Fatal("properties missing")
10+
}
11+
return props
12+
}
13+
14+
func TestGlobSchemaProvider(t *testing.T) {
15+
var _ SchemaProvider = GlobTool{}
16+
props := schemaProps(t, GlobTool{}.Parameters())
17+
if props["pattern"].(map[string]interface{})["type"] != "string" {
18+
t.Fatal("pattern type wrong")
19+
}
20+
if props["path"].(map[string]interface{})["type"] != "string" {
21+
t.Fatal("path type wrong")
22+
}
23+
req, _ := GlobTool{}.Parameters()["required"].([]string)
24+
if len(req) != 1 || req[0] != "pattern" {
25+
t.Fatalf("required = %v, want [pattern]", GlobTool{}.Parameters()["required"])
26+
}
27+
}
28+
29+
func TestLSSchemaProvider(t *testing.T) {
30+
var _ SchemaProvider = LSTool{}
31+
props := schemaProps(t, LSTool{}.Parameters())
32+
ignore, ok := props["ignore"].(map[string]interface{})
33+
if !ok || ignore["type"] != "array" {
34+
t.Fatalf("ignore prop = %v, want array type", props["ignore"])
35+
}
36+
items, ok := ignore["items"].(map[string]interface{})
37+
if !ok || items["type"] != "string" {
38+
t.Fatalf("ignore items = %v, want string type", ignore["items"])
39+
}
40+
_, hasRequired := LSTool{}.Parameters()["required"]
41+
if hasRequired {
42+
t.Fatalf("LS has no required fields, got %v", LSTool{}.Parameters()["required"])
43+
}
44+
}
45+
46+
func TestGrepSchemaProvider(t *testing.T) {
47+
var _ SchemaProvider = GrepTool{}
48+
props := schemaProps(t, GrepTool{}.Parameters())
49+
for _, f := range []string{"pattern", "path", "include"} {
50+
if props[f].(map[string]interface{})["type"] != "string" {
51+
t.Fatalf("%s type wrong", f)
52+
}
53+
}
54+
req, _ := GrepTool{}.Parameters()["required"].([]string)
55+
if len(req) != 1 || req[0] != "pattern" {
56+
t.Fatalf("required = %v, want [pattern]", GrepTool{}.Parameters()["required"])
57+
}
58+
}
59+
60+
func TestSkillSchemaProvider(t *testing.T) {
61+
var _ SchemaProvider = SkillTool{}
62+
props := schemaProps(t, SkillTool{}.Parameters())
63+
if props["skill"].(map[string]interface{})["type"] != "string" {
64+
t.Fatal("skill type wrong")
65+
}
66+
_, hasRequired := SkillTool{}.Parameters()["required"]
67+
if hasRequired {
68+
t.Fatalf("Skill has no required fields, got %v", SkillTool{}.Parameters()["required"])
69+
}
70+
}
71+
72+
func TestScreenshotSchemaProvider(t *testing.T) {
73+
var _ SchemaProvider = ScreenshotTool{}
74+
props := schemaProps(t, ScreenshotTool{}.Parameters())
75+
if props["url"].(map[string]interface{})["type"] != "string" {
76+
t.Fatal("url type wrong")
77+
}
78+
if props["width"].(map[string]interface{})["type"] != "number" {
79+
t.Fatal("width type wrong")
80+
}
81+
req, _ := ScreenshotTool{}.Parameters()["required"].([]string)
82+
if len(req) != 1 || req[0] != "url" {
83+
t.Fatalf("required = %v, want [url]", ScreenshotTool{}.Parameters()["required"])
84+
}
85+
}

‎internal/tool/screenshot.go‎

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,31 +21,43 @@ func (ScreenshotTool) Description() string {
2121
return "Capture a full-page screenshot of a URL with headless Chrome and save it as a PNG file."
2222
}
2323

24-
func (ScreenshotTool) Parameters() map[string]interface{} {
25-
return map[string]interface{}{
26-
"type": "object",
27-
"properties": map[string]interface{}{
28-
"url": map[string]interface{}{"type": "string", "description": "URL (http/https) to capture"},
29-
"path": map[string]interface{}{"type": "string", "description": "Destination PNG path; defaults to a temp file"},
30-
"width": map[string]interface{}{"type": "number", "description": "Viewport width in pixels (default 1280)"},
31-
"height": map[string]interface{}{"type": "number", "description": "Viewport height in pixels (default 800)"},
32-
"wait_ms": map[string]interface{}{"type": "number", "description": "Milliseconds to wait for the page to render (default 1500)"},
33-
"selector": map[string]interface{}{"type": "string", "description": "Optional CSS selector to wait for before capturing"},
24+
// ScreenshotInput is the typed input for ScreenshotTool.
25+
type ScreenshotInput struct {
26+
URL string `json:"url"`
27+
Path string `json:"path"`
28+
Width int `json:"width"`
29+
Height int `json:"height"`
30+
WaitMS int `json:"wait_ms"`
31+
Selector string `json:"selector"`
32+
}
33+
34+
// Schema returns the typed input schema. Parameters() delegates to it so the
35+
// two cannot diverge.
36+
func (ScreenshotTool) Schema() ToolSchema {
37+
return ToolSchema{
38+
Type: "object",
39+
Properties: map[string]SchemaProperty{
40+
"url": {Type: "string", Description: "URL (http/https) to capture"},
41+
"path": {Type: "string", Description: "Destination PNG path; defaults to a temp file"},
42+
"width": {Type: "number", Description: "Viewport width in pixels (default 1280)"},
43+
"height": {Type: "number", Description: "Viewport height in pixels (default 800)"},
44+
"wait_ms": {Type: "number", Description: "Milliseconds to wait for the page to render (default 1500)"},
45+
"selector": {Type: "string", Description: "Optional CSS selector to wait for before capturing"},
3446
},
35-
"required": []string{"url"},
47+
Required: []string{"url"},
3648
}
3749
}
3850

51+
func (ScreenshotTool) Parameters() map[string]interface{} {
52+
return screenshotSchema.ToJSONSchema()
53+
}
54+
55+
// screenshotSchema is the single source of truth for Screenshot's input schema.
56+
var screenshotSchema = ScreenshotTool{}.Schema()
57+
3958
func (ScreenshotTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
40-
var p struct {
41-
URL string `json:"url"`
42-
Path string `json:"path"`
43-
Width int `json:"width"`
44-
Height int `json:"height"`
45-
WaitMS int `json:"wait_ms"`
46-
Selector string `json:"selector"`
47-
}
48-
if err := json.Unmarshal(input, &p); err != nil {
59+
p, err := DecodeInput[ScreenshotInput]("Screenshot", input)
60+
if err != nil {
4961
return "", err
5062
}
5163

‎internal/tool/skill.go‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,36 @@ func (SkillTool) Description() string {
2020
return "Load instructions from a local Rho skill. Use without a skill name to list available skills."
2121
}
2222

23-
func (SkillTool) Parameters() map[string]interface{} {
24-
return map[string]interface{}{
25-
"type": "object",
26-
"properties": map[string]interface{}{
27-
"skill": map[string]interface{}{"type": "string", "description": "Skill name to load"},
23+
// SkillInput is the typed input for SkillTool.
24+
type SkillInput struct {
25+
Skill string `json:"skill"`
26+
}
27+
28+
// Schema returns the typed input schema. Parameters() delegates to it so the
29+
// two cannot diverge.
30+
func (SkillTool) Schema() ToolSchema {
31+
return ToolSchema{
32+
Type: "object",
33+
Properties: map[string]SchemaProperty{
34+
"skill": {Type: "string", Description: "Skill name to load"},
2835
},
2936
}
3037
}
3138

39+
func (SkillTool) Parameters() map[string]interface{} {
40+
return skillSchema.ToJSONSchema()
41+
}
42+
43+
// skillSchema is the single source of truth for Skill's input schema.
44+
var skillSchema = SkillTool{}.Schema()
45+
3246
func (SkillTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
33-
var p struct {
34-
Skill string `json:"skill"`
35-
}
47+
// Empty input lists available skills; only decode when present.
48+
var p SkillInput
3649
if len(input) > 0 {
37-
if err := json.Unmarshal(input, &p); err != nil {
50+
var err error
51+
p, err = DecodeInput[SkillInput]("Skill", input)
52+
if err != nil {
3853
return "", err
3954
}
4055
}

0 commit comments

Comments
 (0)