on main help sign in
feat(prompts): extend templating system to more prompts
commit0c1014e5f7465e09483716b4c3732d1e8af76c84 authorKieran Klukas <kieran@dunkirk.sh> dateTue 12 May 2026, 11:44 (5 months ago) parent5ad8bee
22 files changed+168−31
internal/agent/tools/crush_logs.go+21−3 internal/agent/tools/crush_logs.md → internal/agent/tools/crush_logs.md.tpl+2−2 internal/agent/tools/download.go+19−3 internal/agent/tools/download.md−1 internal/agent/tools/download.md.tpl+1 internal/agent/tools/fetch.go+13−1 internal/agent/tools/fetch.md.tpl+1−1 internal/agent/tools/glob.go+19−3 internal/agent/tools/glob.md−1 internal/agent/tools/glob.md.tpl+1 internal/agent/tools/grep.go+19−3 internal/agent/tools/grep.md−1 internal/agent/tools/grep.md.tpl+1 internal/agent/tools/ls.go+19−3 internal/agent/tools/ls.md−1 internal/agent/tools/ls.md.tpl+1 internal/agent/tools/sourcegraph.go+19−3 internal/agent/tools/sourcegraph.md → internal/agent/tools/sourcegraph.md.tpl+1−1 internal/agent/tools/tools.go+9 internal/agent/tools/view.go+21−3 internal/agent/tools/view.md−1 internal/agent/tools/view.md.tpl+1
internal/agent/tools/crush_logs.go+21−3
@@ -5,6 +5,7 @@ import (
55 _ "embed"
66 "encoding/json"
77 "fmt"
8+ "html/template"
89 "io"
910 "os"
1011 "path/filepath"
@@ -18,8 +19,25 @@ import (
1819
1920const CrushLogsToolName = "crush_logs"
2021
21−//go:embed crush_logs.md
22−var crushLogsDescription string
22+//go:embed crush_logs.md.tpl
23+var crushLogsDescriptionTmpl []byte
24+
25+var crushLogsDescriptionTpl = template.Must(
26+ template.New("crushLogsDescription").
27+ Parse(string(crushLogsDescriptionTmpl)),
28+)
29+
30+type crushLogsDescriptionData struct {
31+ DefaultLines int
32+ MaxLines int
33+}
34+
35+func crushLogsDescription() string {
36+ return renderTemplate(crushLogsDescriptionTpl, crushLogsDescriptionData{
37+ DefaultLines: defaultLogLines,
38+ MaxLines: maxLogLines,
39+ })
40+}
2341
2442// Max line size to prevent memory issues with very long log lines (1 MB).
2543const maxLogLineSize = 1024 * 1024
@@ -58,7 +76,7 @@ type CrushLogsParams struct {
5876func NewCrushLogsTool(logFile string) fantasy.AgentTool {
5977 return fantasy.NewAgentTool(
6078 CrushLogsToolName,
61− crushLogsDescription,
79+ crushLogsDescription(),
6280 func(ctx context.Context, params CrushLogsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
6381 result := runCrushLogs(logFile, params)
6482 return fantasy.NewTextResponse(result), nil
internal/agent/tools/crush_logs.md → internal/agent/tools/crush_logs.md.tplrenamed+2−2
@@ -1,4 +1,4 @@
1−Read Crush's internal application logs (default 50 entries, max 100); useful for diagnosing provider errors, tool failures, LSP/MCP issues.
1+Read Crush's internal application logs (default {{ .DefaultLines }} entries, max {{ .MaxLines }}); useful for diagnosing provider errors, tool failures, LSP/MCP issues.
22
33<usage>
44- Returns recent log entries from Crush's internal log file
@@ -8,6 +8,6 @@ Read Crush's internal application logs (default 50 entries, max 100); useful for
88</usage>
99
1010<tips>
11−- Default returns last 50 entries; use lines parameter for more (max 100)
11+- Default returns last {{ .DefaultLines }} entries; use lines parameter for more (max {{ .MaxLines }})
1212- Look for ERROR and WARN entries first when diagnosing problems
1313</tips>
internal/agent/tools/download.go+19−3
@@ -5,6 +5,7 @@ import (
55 "context"
66 _ "embed"
77 "fmt"
8+ "html/template"
89 "io"
910 "net/http"
1011 "os"
@@ -31,8 +32,23 @@ type DownloadPermissionsParams struct {
3132
3233const DownloadToolName = "download"
3334
34−//go:embed download.md
35−var downloadDescription string
35+//go:embed download.md.tpl
36+var downloadDescriptionTmpl []byte
37+
38+var downloadDescriptionTpl = template.Must(
39+ template.New("downloadDescription").
40+ Parse(string(downloadDescriptionTmpl)),
41+)
42+
43+type downloadDescriptionData struct {
44+ MaxDownloadTimeout int
45+}
46+
47+func downloadDescription() string {
48+ return renderTemplate(downloadDescriptionTpl, downloadDescriptionData{
49+ MaxDownloadTimeout: 600,
50+ })
51+}
3652
3753func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
3854 if client == nil {
@@ -48,7 +64,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
4864 }
4965 return fantasy.NewParallelAgentTool(
5066 DownloadToolName,
51− downloadDescription,
67+ downloadDescription(),
5268 func(ctx context.Context, params DownloadParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
5369 if params.URL == "" {
5470 return fantasy.NewTextErrorResponse("URL parameter is required"), nil
internal/agent/tools/download.mddeleted−1
@@ -1 +0,0 @@
1−Download a URL directly to a local file (binary-safe, streaming, max 100MB); overwrites without warning. For reading content into context use fetch.
internal/agent/tools/download.md.tplnew file+1
@@ -0,0 +1 @@
1+Download a URL directly to a local file (binary-safe, streaming, max {{ .MaxDownloadTimeout }}s timeout); overwrites without warning. For reading content into context use fetch.
internal/agent/tools/fetch.go+13−1
@@ -30,6 +30,18 @@ var fetchDescriptionTpl = template.Must(
3030 Parse(string(fetchDescriptionTmpl)),
3131)
3232
33+type fetchDescriptionData struct {
34+ GhAvailable bool
35+ MaxFetchSizeKB int
36+}
37+
38+func fetchDescription() string {
39+ return renderTemplate(fetchDescriptionTpl, fetchDescriptionData{
40+ GhAvailable: ghAvailable,
41+ MaxFetchSizeKB: MaxFetchSize / 1024,
42+ })
43+}
44+
3345func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
3446 if client == nil {
3547 transport := http.DefaultTransport.(*http.Transport).Clone()
@@ -45,7 +57,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
4557
4658 return fantasy.NewParallelAgentTool(
4759 FetchToolName,
48− renderToolDescription(fetchDescriptionTpl),
60+ fetchDescription(),
4961 func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
5062 if params.URL == "" {
5163 return fantasy.NewTextErrorResponse("URL parameter is required"), nil
internal/agent/tools/fetch.md.tpl+1−1
@@ -1,2 +1,2 @@
1−Fetch raw content from a URL as text, markdown, or html (max 100KB); no AI processing. For analysis or extraction use agentic_fetch.
1+Fetch raw content from a URL as text, markdown, or html (max {{ .MaxFetchSizeKB }}KB); no AI processing. For analysis or extraction use agentic_fetch.
22{{- if .GhAvailable }} For GitHub content when an exact repo, issue, or PR link is provided, use `gh` CLI in bash instead.{{- end }}
internal/agent/tools/glob.go+19−3
@@ -6,6 +6,7 @@ import (
66 "context"
77 _ "embed"
88 "fmt"
9+ "html/template"
910 "log/slog"
1011 "os/exec"
1112 "path/filepath"
@@ -19,8 +20,23 @@ import (
1920
2021const GlobToolName = "glob"
2122
22−//go:embed glob.md
23−var globDescription string
23+//go:embed glob.md.tpl
24+var globDescriptionTmpl []byte
25+
26+var globDescriptionTpl = template.Must(
27+ template.New("globDescription").
28+ Parse(string(globDescriptionTmpl)),
29+)
30+
31+type globDescriptionData struct {
32+ MaxResults int
33+}
34+
35+func globDescription() string {
36+ return renderTemplate(globDescriptionTpl, globDescriptionData{
37+ MaxResults: 100,
38+ })
39+}
2440
2541type GlobParams struct {
2642 Pattern string `json:"pattern" description:"The glob pattern to match files against"`
@@ -35,7 +51,7 @@ type GlobResponseMetadata struct {
3551func NewGlobTool(workingDir string) fantasy.AgentTool {
3652 return fantasy.NewAgentTool(
3753 GlobToolName,
38− globDescription,
54+ globDescription(),
3955 func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
4056 if params.Pattern == "" {
4157 return fantasy.NewTextErrorResponse("pattern is required"), nil
internal/agent/tools/glob.mddeleted−1
@@ -1 +0,0 @@
1−Find files by name/pattern (glob syntax), sorted by modification time; max 100 results; skips hidden files. Use grep to search file contents.
internal/agent/tools/glob.md.tplnew file+1
@@ -0,0 +1 @@
1+Find files by name/pattern (glob syntax), sorted by modification time; max {{ .MaxResults }} results; skips hidden files. Use grep to search file contents.
internal/agent/tools/grep.go+19−3
@@ -8,6 +8,7 @@ import (
88 _ "embed"
99 "encoding/json"
1010 "fmt"
11+ "html/template"
1112 "io"
1213 "net/http"
1314 "os"
@@ -89,8 +90,23 @@ const (
8990 maxGrepContentWidth = 500
9091)
9192
92−//go:embed grep.md
93−var grepDescription string
93+//go:embed grep.md.tpl
94+var grepDescriptionTmpl []byte
95+
96+var grepDescriptionTpl = template.Must(
97+ template.New("grepDescription").
98+ Parse(string(grepDescriptionTmpl)),
99+)
100+
101+type grepDescriptionData struct {
102+ MaxResults int
103+}
104+
105+func grepDescription() string {
106+ return renderTemplate(grepDescriptionTpl, grepDescriptionData{
107+ MaxResults: 100,
108+ })
109+}
94110
95111// escapeRegexPattern escapes special regex characters so they're treated as literal characters
96112func escapeRegexPattern(pattern string) string {
@@ -107,7 +123,7 @@ func escapeRegexPattern(pattern string) string {
107123func NewGrepTool(workingDir string, config config.ToolGrep) fantasy.AgentTool {
108124 return fantasy.NewAgentTool(
109125 GrepToolName,
110− grepDescription,
126+ grepDescription(),
111127 func(ctx context.Context, params GrepParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
112128 if params.Pattern == "" {
113129 return fantasy.NewTextErrorResponse("pattern is required"), nil
internal/agent/tools/grep.mddeleted−1
@@ -1 +0,0 @@
1−Search file contents by regex or literal text; returns matching file paths sorted by modification time (max 100); respects .gitignore. Use glob to filter by filename, not contents.
internal/agent/tools/grep.md.tplnew file+1
@@ -0,0 +1 @@
1+Search file contents by regex or literal text; returns matching file paths sorted by modification time (max {{ .MaxResults }}); respects .gitignore. Use glob to filter by filename, not contents.
internal/agent/tools/ls.go+19−3
@@ -5,6 +5,7 @@ import (
55 "context"
66 _ "embed"
77 "fmt"
8+ "html/template"
89 "os"
910 "path/filepath"
1011 "strings"
@@ -52,13 +53,28 @@ const (
5253 maxLSFiles = 1000
5354)
5455
55−//go:embed ls.md
56−var lsDescription string
56+//go:embed ls.md.tpl
57+var lsDescriptionTmpl []byte
58+
59+var lsDescriptionTpl = template.Must(
60+ template.New("lsDescription").
61+ Parse(string(lsDescriptionTmpl)),
62+)
63+
64+type lsDescriptionData struct {
65+ MaxFiles int
66+}
67+
68+func lsDescription() string {
69+ return renderTemplate(lsDescriptionTpl, lsDescriptionData{
70+ MaxFiles: maxLSFiles,
71+ })
72+}
5773
5874func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
5975 return fantasy.NewAgentTool(
6076 LSToolName,
61− lsDescription,
77+ lsDescription(),
6278 func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
6379 searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
6480 if err != nil {
internal/agent/tools/ls.mddeleted−1
@@ -1 +0,0 @@
1−List files and directories as a tree; skips hidden files and common system dirs; max 1000 files. Use glob to find files by pattern, grep to search contents.
internal/agent/tools/ls.md.tplnew file+1
@@ -0,0 +1 @@
1+List files and directories as a tree; skips hidden files and common system dirs; max {{ .MaxFiles }} files. Use glob to find files by pattern, grep to search contents.
internal/agent/tools/sourcegraph.go+19−3
@@ -6,6 +6,7 @@ import (
66 _ "embed"
77 "encoding/json"
88 "fmt"
9+ "html/template"
910 "io"
1011 "net/http"
1112 "strings"
@@ -28,8 +29,23 @@ type SourcegraphResponseMetadata struct {
2829
2930const SourcegraphToolName = "sourcegraph"
3031
31−//go:embed sourcegraph.md
32−var sourcegraphDescription string
32+//go:embed sourcegraph.md.tpl
33+var sourcegraphDescriptionTmpl []byte
34+
35+var sourcegraphDescriptionTpl = template.Must(
36+ template.New("sourcegraphDescription").
37+ Parse(string(sourcegraphDescriptionTmpl)),
38+)
39+
40+type sourcegraphDescriptionData struct {
41+ MaxResults int
42+}
43+
44+func sourcegraphDescription() string {
45+ return renderTemplate(sourcegraphDescriptionTpl, sourcegraphDescriptionData{
46+ MaxResults: 20,
47+ })
48+}
3349
3450func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
3551 if client == nil {
@@ -45,7 +61,7 @@ func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
4561 }
4662 return fantasy.NewParallelAgentTool(
4763 SourcegraphToolName,
48− sourcegraphDescription,
64+ sourcegraphDescription(),
4965 func(ctx context.Context, params SourcegraphParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
5066 if params.Query == "" {
5167 return fantasy.NewTextErrorResponse("Query parameter is required"), nil
internal/agent/tools/sourcegraph.md → internal/agent/tools/sourcegraph.md.tplrenamed+1−1
@@ -1 +1 @@
1−Search code across public GitHub repositories via Sourcegraph; supports regex, language/repo/file filters, and symbol search (max 20 results). Only searches public repos.
1+Search code across public GitHub repositories via Sourcegraph; supports regex, language/repo/file filters, and symbol search (max {{ .MaxResults }} results). Only searches public repos.
internal/agent/tools/tools.go+9
@@ -90,3 +90,12 @@ func renderToolDescription(tmpl *template.Template) string {
9090 }
9191 return out.String()
9292}
93+
94+// renderTemplate renders a Go template with the given data.
95+func renderTemplate(tmpl *template.Template, data any) string {
96+ var out bytes.Buffer
97+ if err := tmpl.Execute(&out, data); err != nil {
98+ panic("failed to execute tool description template: " + err.Error())
99+ }
100+ return out.String()
101+}
internal/agent/tools/view.go+21−3
@@ -6,6 +6,7 @@ import (
66 _ "embed"
77 "errors"
88 "fmt"
9+ "html/template"
910 "io"
1011 "io/fs"
1112 "net/http"
@@ -23,8 +24,25 @@ import (
2324 "github.com/charmbracelet/crush/internal/skills"
2425)
2526
26−//go:embed view.md
27−var viewDescription string
27+//go:embed view.md.tpl
28+var viewDescriptionTmpl []byte
29+
30+var viewDescriptionTpl = template.Must(
31+ template.New("viewDescription").
32+ Parse(string(viewDescriptionTmpl)),
33+)
34+
35+type viewDescriptionData struct {
36+ DefaultReadLimit int
37+ MaxViewSizeKB int
38+}
39+
40+func viewDescription() string {
41+ return renderTemplate(viewDescriptionTpl, viewDescriptionData{
42+ DefaultReadLimit: DefaultReadLimit,
43+ MaxViewSizeKB: MaxViewSize / 1024,
44+ })
45+}
2846
2947type ViewParams struct {
3048 FilePath string `json:"file_path" description:"The path to the file to read"`
@@ -79,7 +97,7 @@ func NewViewTool(
7997) fantasy.AgentTool {
8098 return fantasy.NewAgentTool(
8199 ViewToolName,
82− viewDescription,
100+ viewDescription(),
83101 func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
84102 if params.FilePath == "" {
85103 return fantasy.NewTextErrorResponse("file_path is required"), nil
internal/agent/tools/view.mddeleted−1
@@ -1 +0,0 @@
1−Read a file by path with line numbers; supports offset and line limit (default 2000, max 200KB); renders images (PNG, JPEG, GIF, BMP, SVG, WebP); use ls for directories.
internal/agent/tools/view.md.tplnew file+1
@@ -0,0 +1 @@
1+Read a file by path with line numbers; supports offset and line limit (default {{ .DefaultReadLimit }}, max {{ .MaxViewSizeKB }}KB returned file content section); renders images (PNG, JPEG, GIF, WebP); use ls for directories.