on main help sign in
feat(tools/crush_info): add skill status
commit6f7762fce8aca534b141d977d13c75237220aa63 authorChristian Rocha <christian@rocha.is> dateSun 5 Apr 2026, 22:18 (6 months ago) parent816453b
11 files changed+411−47
internal/agent/agentic_fetch_tool.go+1−1 internal/agent/common_test.go+1−1 internal/agent/coordinator.go+54−11 internal/agent/tools/crush_info.go+60−2 internal/agent/tools/crush_info.md+4−2 internal/agent/tools/crush_info_test.go+121−18 internal/agent/tools/crush_logs_test.go+5−6 internal/agent/tools/view.go+6−2 internal/agent/tools/view_test.go+4−4 internal/skills/tracker.go+53 internal/skills/tracker_test.go+102
internal/agent/agentic_fetch_tool.go+1−1
@@ -169,7 +169,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
169169 tools.NewGlobTool(tmpDir),
170170 tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep),
171171 tools.NewSourcegraphTool(client),
172− tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
172+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, nil, tmpDir),
173173 }
174174
175175 agent := NewSessionAgent(SessionAgentOptions{
internal/agent/common_test.go+1−1
@@ -217,7 +217,7 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
217217 tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep),
218218 tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
219219 tools.NewSourcegraphTool(r.GetDefaultClient()),
220− tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
220+ tools.NewViewTool(nil, env.permissions, *env.filetracker, nil, env.workingDir),
221221 tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
222222 }
223223
internal/agent/coordinator.go+54−11
@@ -25,6 +25,7 @@ import (
2525 "github.com/charmbracelet/crush/internal/config"
2626 "github.com/charmbracelet/crush/internal/filetracker"
2727 "github.com/charmbracelet/crush/internal/history"
28+ "github.com/charmbracelet/crush/internal/home"
2829 "github.com/charmbracelet/crush/internal/log"
2930 "github.com/charmbracelet/crush/internal/lsp"
3031 "github.com/charmbracelet/crush/internal/message"
@@ -32,6 +33,7 @@ import (
3233 "github.com/charmbracelet/crush/internal/permission"
3334 "github.com/charmbracelet/crush/internal/pubsub"
3435 "github.com/charmbracelet/crush/internal/session"
36+ "github.com/charmbracelet/crush/internal/skills"
3537 "golang.org/x/sync/errgroup"
3638
3739 "charm.land/fantasy/providers/anthropic"
@@ -87,6 +89,11 @@ type coordinator struct {
8789 currentAgent SessionAgent
8890 agents map[string]SessionAgent
8991
92+ // Skills discovery results (session-start snapshot).
93+ allSkills []*skills.Skill // Pre-filter: all discovered after dedup.
94+ activeSkills []*skills.Skill // Post-filter: active skills only.
95+ skillTracker *skills.Tracker
96+
9097 readyWg errgroup.Group
9198}
9299
@@ -101,16 +108,23 @@ func NewCoordinator(
101108 lspManager *lsp.Manager,
102109 notify pubsub.Publisher[notify.Notification],
103110) (Coordinator, error) {
111+ // Discover skills once at session start.
112+ allSkills, activeSkills := discoverSkills(cfg)
113+ skillTracker := skills.NewTracker(activeSkills)
114+
104115 c := &coordinator{
105− cfg: cfg,
106− sessions: sessions,
107− messages: messages,
108− permissions: permissions,
109− history: history,
110− filetracker: filetracker,
111− lspManager: lspManager,
112− notify: notify,
113− agents: make(map[string]SessionAgent),
116+ cfg: cfg,
117+ sessions: sessions,
118+ messages: messages,
119+ permissions: permissions,
120+ history: history,
121+ filetracker: filetracker,
122+ lspManager: lspManager,
123+ notify: notify,
124+ agents: make(map[string]SessionAgent),
125+ allSkills: allSkills,
126+ activeSkills: activeSkills,
127+ skillTracker: skillTracker,
114128 }
115129
116130 agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
@@ -450,7 +464,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
450464
451465 allTools = append(allTools,
452466 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName),
453− tools.NewCrushInfoTool(c.cfg, c.lspManager),
467+ tools.NewCrushInfoTool(c.cfg, c.lspManager, c.allSkills, c.activeSkills, c.skillTracker),
454468 tools.NewCrushLogsTool(logFile),
455469 tools.NewJobOutputTool(),
456470 tools.NewJobKillTool(),
@@ -463,7 +477,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
463477 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
464478 tools.NewSourcegraphTool(nil),
465479 tools.NewTodosTool(c.sessions),
466− tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
480+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.skillTracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
467481 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
468482 )
469483
@@ -1043,3 +1057,32 @@ func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionI
10431057
10441058 return nil
10451059}
1060+
1061+// discoverSkills runs the skill discovery pipeline and returns both the
1062+// pre-filter (all discovered, after dedup) and post-filter (active) lists.
1063+func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1064+ discovered := skills.DiscoverBuiltin()
1065+
1066+ opts := cfg.Config().Options
1067+ if opts != nil && len(opts.SkillsPaths) > 0 {
1068+ expandedPaths := make([]string, 0, len(opts.SkillsPaths))
1069+ for _, pth := range opts.SkillsPaths {
1070+ expanded := home.Long(pth)
1071+ if strings.HasPrefix(expanded, "$") {
1072+ if resolved, err := cfg.Resolver().ResolveValue(expanded); err == nil {
1073+ expanded = resolved
1074+ }
1075+ }
1076+ expandedPaths = append(expandedPaths, expanded)
1077+ }
1078+ discovered = append(discovered, skills.Discover(expandedPaths)...)
1079+ }
1080+
1081+ allSkills = skills.Deduplicate(discovered)
1082+ var disabledSkills []string
1083+ if opts != nil {
1084+ disabledSkills = opts.DisabledSkills
1085+ }
1086+ activeSkills = skills.Filter(allSkills, disabledSkills)
1087+ return allSkills, activeSkills
1088+}
internal/agent/tools/crush_info.go+60−2
@@ -11,6 +11,7 @@ import (
1111 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1212 "github.com/charmbracelet/crush/internal/config"
1313 "github.com/charmbracelet/crush/internal/lsp"
14+ "github.com/charmbracelet/crush/internal/skills"
1415)
1516
1617const CrushInfoToolName = "crush_info"
@@ -23,16 +24,19 @@ type CrushInfoParams struct{}
2324func NewCrushInfoTool(
2425 cfg *config.ConfigStore,
2526 lspManager *lsp.Manager,
27+ allSkills []*skills.Skill,
28+ activeSkills []*skills.Skill,
29+ skillTracker *skills.Tracker,
2630) fantasy.AgentTool {
2731 return fantasy.NewAgentTool(
2832 CrushInfoToolName,
2933 string(crushInfoDescription),
3034 func(ctx context.Context, _ CrushInfoParams, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
31− return fantasy.NewTextResponse(buildCrushInfo(cfg, lspManager)), nil
35+ return fantasy.NewTextResponse(buildCrushInfo(cfg, lspManager, allSkills, activeSkills, skillTracker)), nil
3236 })
3337}
3438
35−func buildCrushInfo(cfg *config.ConfigStore, lspManager *lsp.Manager) string {
39+func buildCrushInfo(cfg *config.ConfigStore, lspManager *lsp.Manager, allSkills []*skills.Skill, activeSkills []*skills.Skill, skillTracker *skills.Tracker) string {
3640 var b strings.Builder
3741
3842 writeConfigFiles(&b, cfg)
@@ -41,6 +45,7 @@ func buildCrushInfo(cfg *config.ConfigStore, lspManager *lsp.Manager) string {
4145 writeProviders(&b, cfg)
4246 writeLSP(&b, lspManager, cfg)
4347 writeMCP(&b, mcp.GetStates(), cfg)
48+ writeSkills(&b, allSkills, activeSkills, skillTracker, cfg)
4449 writePermissions(&b, cfg)
4550 writeDisabledTools(&b, cfg)
4651 writeOptions(&b, cfg)
@@ -284,6 +289,59 @@ func writeMCP(b *strings.Builder, states map[string]mcp.ClientInfo, cfg *config.
284289 }
285290}
286291
292+func writeSkills(b *strings.Builder, allSkills []*skills.Skill, activeSkills []*skills.Skill, tracker *skills.Tracker, cfg *config.ConfigStore) {
293+ var disabled []string
294+ if cfg.Config().Options != nil {
295+ disabled = cfg.Config().Options.DisabledSkills
296+ }
297+ if len(activeSkills) == 0 && len(disabled) == 0 {
298+ return
299+ }
300+
301+ // Build origin map from the pre-filter list.
302+ originMap := make(map[string]string, len(allSkills))
303+ for _, s := range allSkills {
304+ if s.Builtin {
305+ originMap[s.Name] = "builtin"
306+ } else {
307+ originMap[s.Name] = "user"
308+ }
309+ }
310+
311+ type entry struct {
312+ name string
313+ origin string
314+ state string
315+ }
316+ var entries []entry
317+
318+ // Active skills: loaded or unloaded.
319+ for _, s := range activeSkills {
320+ state := "unloaded"
321+ if tracker.IsLoaded(s.Name) {
322+ state = "loaded"
323+ }
324+ origin := originMap[s.Name]
325+ entries = append(entries, entry{name: s.Name, origin: origin, state: state})
326+ }
327+
328+ // Disabled skills.
329+ for _, name := range disabled {
330+ origin := originMap[name]
331+ if origin == "" {
332+ origin = "user"
333+ }
334+ entries = append(entries, entry{name: name, origin: origin, state: "disabled"})
335+ }
336+
337+ slices.SortFunc(entries, func(a, b entry) int { return strings.Compare(a.name, b.name) })
338+ b.WriteString("[skills]\n")
339+ for _, e := range entries {
340+ fmt.Fprintf(b, "%s = %s, %s\n", e.name, e.origin, e.state)
341+ }
342+ b.WriteString("\n")
343+}
344+
287345func writePermissions(b *strings.Builder, cfg *config.ConfigStore) {
288346 c := cfg.Config()
289347 overrides := cfg.Overrides()
internal/agent/tools/crush_info.md+4−2
@@ -2,8 +2,8 @@ Get information about Crush's current runtime configuration and service
22state.
33
44<usage>
5−- Shows active model and provider, LSP/MCP server status, permissions mode,
6− disabled tools, and key options
5+- Shows active model and provider, LSP/MCP server status, skills,
6+ permissions mode, disabled tools, and key options
77- Use when diagnosing why something isn't working (missing diagnostics,
88 provider errors, MCP disconnections)
99- No parameters needed — always returns the full current state
@@ -12,5 +12,7 @@ state.
1212<tips>
1313- Check [lsp] and [mcp] sections for service health
1414- Check [providers] to see which providers are enabled and available
15+- Check [skills] to see which skills are available and whether they have been
16+ loaded this session
1517- Pair with the crush-config skill to fix configuration issues
1618</tips>
internal/agent/tools/crush_info_test.go+121−18
@@ -13,6 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/config"
1414 "github.com/charmbracelet/crush/internal/csync"
1515 "github.com/charmbracelet/crush/internal/lsp"
16+ "github.com/charmbracelet/crush/internal/skills"
1617 "github.com/stretchr/testify/require"
1718)
1819
@@ -22,7 +23,7 @@ func TestCrushInfo_MinimalConfig(t *testing.T) {
2223 cfg := config.NewTestStore(&config.Config{
2324 Providers: csync.NewMap[string, config.ProviderConfig](),
2425 })
25− output := buildCrushInfo(cfg, nil)
26+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
2627 require.NotContains(t, output, "[providers]")
2728 require.NotContains(t, output, "[lsp]")
2829 require.NotContains(t, output, "[mcp]")
@@ -38,7 +39,7 @@ func TestCrushInfo_ConfigFiles(t *testing.T) {
3839 "/home/user/.config/crush/crush.json",
3940 "/project/.crush/crush.json",
4041 )
41− output := buildCrushInfo(cfg, nil)
42+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
4243 require.Contains(t, output, "[config_files]")
4344 require.Contains(t, output, "/home/user/.config/crush/crush.json")
4445 require.Contains(t, output, "/project/.crush/crush.json")
@@ -54,7 +55,7 @@ func TestCrushInfo_Models(t *testing.T) {
5455 },
5556 Providers: csync.NewMap[string, config.ProviderConfig](),
5657 })
57− output := buildCrushInfo(cfg, nil)
58+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
5859 require.Contains(t, output, "[model]")
5960 require.Contains(t, output, "large = claude-sonnet-4-20250514 (anthropic)")
6061 require.Contains(t, output, "small = claude-haiku-3-20250307 (anthropic)")
@@ -68,7 +69,7 @@ func TestCrushInfo_Providers(t *testing.T) {
6869 providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
6970
7071 cfg := config.NewTestStore(&config.Config{Providers: providers})
71− output := buildCrushInfo(cfg, nil)
72+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
7273 require.Contains(t, output, "[providers]")
7374 anthropicIdx := strings.Index(output, "anthropic = enabled")
7475 openaiIdx := strings.Index(output, "openai = enabled")
@@ -87,7 +88,7 @@ func TestCrushInfo_DisabledProvidersOmitted(t *testing.T) {
8788 providers.Set("anthropic", config.ProviderConfig{Models: make([]catwalk.Model, 12)})
8889
8990 cfg := config.NewTestStore(&config.Config{Providers: providers})
90− output := buildCrushInfo(cfg, nil)
91+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
9192 require.Contains(t, output, "anthropic = enabled")
9293 require.NotContains(t, output, "openai")
9394}
@@ -107,7 +108,7 @@ func TestCrushInfo_LSPStates(t *testing.T) {
107108 mgr.Clients().Set("pyright", errorClient)
108109
109110 cfg := config.NewTestStore(&config.Config{Providers: csync.NewMap[string, config.ProviderConfig]()})
110− output := buildCrushInfo(cfg, mgr)
111+ output := buildCrushInfo(cfg, mgr, nil, nil, nil)
111112 require.Contains(t, output, "[lsp]")
112113 require.Contains(t, output, "gopls = ready")
113114 require.Contains(t, output, "pyright = error")
@@ -158,7 +159,7 @@ func TestCrushInfo_YoloMode(t *testing.T) {
158159 })
159160 cfg.Overrides().SkipPermissionRequests = true
160161
161− output := buildCrushInfo(cfg, nil)
162+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
162163 require.Contains(t, output, "[permissions]")
163164 require.Contains(t, output, "mode = yolo")
164165}
@@ -171,7 +172,7 @@ func TestCrushInfo_AllowedTools(t *testing.T) {
171172 Permissions: &config.Permissions{AllowedTools: []string{"edit:write", "bash"}},
172173 })
173174
174− output := buildCrushInfo(cfg, nil)
175+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
175176 require.Contains(t, output, "[permissions]")
176177 require.Contains(t, output, "allowed_tools = bash, edit:write")
177178}
@@ -184,7 +185,7 @@ func TestCrushInfo_DisabledTools(t *testing.T) {
184185 Options: &config.Options{DisabledTools: []string{"sourcegraph", "agentic_fetch"}},
185186 })
186187
187− output := buildCrushInfo(cfg, nil)
188+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
188189 require.Contains(t, output, "[tools]")
189190 require.Contains(t, output, "disabled = agentic_fetch, sourcegraph")
190191}
@@ -201,7 +202,7 @@ func TestCrushInfo_Options(t *testing.T) {
201202 },
202203 })
203204
204− output := buildCrushInfo(cfg, nil)
205+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
205206 require.Contains(t, output, "[options]")
206207 require.Contains(t, output, "auto_lsp = true")
207208 require.Contains(t, output, "auto_summarize = false")
@@ -216,14 +217,14 @@ func TestCrushInfo_AutoSummarizeInversion(t *testing.T) {
216217 Providers: csync.NewMap[string, config.ProviderConfig](),
217218 Options: &config.Options{DisableAutoSummarize: true},
218219 })
219− outputFalse := buildCrushInfo(cfgFalse, nil)
220+ outputFalse := buildCrushInfo(cfgFalse, nil, nil, nil, nil)
220221 require.Contains(t, outputFalse, "auto_summarize = false")
221222
222223 cfgTrue := config.NewTestStore(&config.Config{
223224 Providers: csync.NewMap[string, config.ProviderConfig](),
224225 Options: &config.Options{DisableAutoSummarize: false},
225226 })
226− outputTrue := buildCrushInfo(cfgTrue, nil)
227+ outputTrue := buildCrushInfo(cfgTrue, nil, nil, nil, nil)
227228 require.Contains(t, outputTrue, "auto_summarize = true")
228229}
229230
@@ -237,7 +238,7 @@ func TestCrushInfo_NoSecrets(t *testing.T) {
237238 })
238239
239240 cfg := config.NewTestStore(&config.Config{Providers: providers})
240− output := buildCrushInfo(cfg, nil)
241+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
241242 require.NotContains(t, output, "sk-super-secret-key-12345")
242243 require.NotContains(t, output, "secret")
243244 require.Contains(t, output, "openai = enabled (8 models)")
@@ -273,7 +274,7 @@ func TestCrushInfo_DeterministicOrdering(t *testing.T) {
273274 zMcpIdx := strings.Index(mcpOutput, "z-mcp = connected")
274275 require.Less(t, aMcpIdx, zMcpIdx)
275276
276− output := buildCrushInfo(cfg, nil)
277+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
277278
278279 alphaIdx := strings.Index(output, "alpha = enabled")
279280 middleIdx := strings.Index(output, "middle = enabled")
@@ -294,11 +295,12 @@ func TestCrushInfo_EmptySectionsOmitted(t *testing.T) {
294295 Options: &config.Options{},
295296 })
296297
297− output := buildCrushInfo(cfg, nil)
298+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
298299 require.NotContains(t, output, "[tools]")
299300 require.NotContains(t, output, "[permissions]")
300301 require.NotContains(t, output, "[lsp]")
301302 require.NotContains(t, output, "[mcp]")
303+ require.NotContains(t, output, "[skills]")
302304}
303305
304306func TestCrushInfo_ConfigStaleness_Clean(t *testing.T) {
@@ -315,7 +317,7 @@ func TestCrushInfo_ConfigStaleness_Clean(t *testing.T) {
315317 // Capture snapshot (normally done in Load)
316318 store.CaptureStalenessSnapshot([]string{configPath})
317319
318− output := buildCrushInfo(store, nil)
320+ output := buildCrushInfo(store, nil, nil, nil, nil)
319321 require.Contains(t, output, "[config]")
320322 require.Contains(t, output, "dirty = false")
321323 require.NotContains(t, output, "changed_paths")
@@ -340,7 +342,7 @@ func TestCrushInfo_ConfigStaleness_Dirty(t *testing.T) {
340342 time.Sleep(10 * time.Millisecond)
341343 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
342344
343− output := buildCrushInfo(store, nil)
345+ output := buildCrushInfo(store, nil, nil, nil, nil)
344346 require.Contains(t, output, "[config]")
345347 require.Contains(t, output, "dirty = true")
346348 require.Contains(t, output, "changed_paths")
@@ -364,9 +366,110 @@ func TestCrushInfo_ConfigStaleness_MissingPath(t *testing.T) {
364366 // Delete file to trigger missing state
365367 require.NoError(t, os.Remove(configPath))
366368
367− output := buildCrushInfo(store, nil)
369+ output := buildCrushInfo(store, nil, nil, nil, nil)
368370 require.Contains(t, output, "[config]")
369371 require.Contains(t, output, "dirty = true")
370372 require.Contains(t, output, "missing_paths")
371373 require.Contains(t, output, configPath)
372374}
375+
376+func TestCrushInfo_Skills_NoSkills(t *testing.T) {
377+ t.Parallel()
378+
379+ cfg := config.NewTestStore(&config.Config{
380+ Providers: csync.NewMap[string, config.ProviderConfig](),
381+ })
382+ output := buildCrushInfo(cfg, nil, nil, nil, nil)
383+ require.NotContains(t, output, "[skills]")
384+}
385+
386+func TestCrushInfo_Skills_MixedLoadedUnloaded(t *testing.T) {
387+ t.Parallel()
388+
389+ allSkills := []*skills.Skill{
390+ {Name: "go-doc", Builtin: false},
391+ {Name: "bash", Builtin: false},
392+ {Name: "crush-config", Builtin: true},
393+ }
394+ activeSkills := allSkills
395+
396+ tracker := skills.NewTracker(activeSkills)
397+ tracker.MarkLoaded("bash")
398+ tracker.MarkLoaded("crush-config")
399+
400+ cfg := config.NewTestStore(&config.Config{
401+ Providers: csync.NewMap[string, config.ProviderConfig](),
402+ })
403+ output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
404+ require.Contains(t, output, "[skills]")
405+ require.Contains(t, output, "bash = user, loaded")
406+ require.Contains(t, output, "crush-config = builtin, loaded")
407+ require.Contains(t, output, "go-doc = user, unloaded")
408+}
409+
410+func TestCrushInfo_Skills_DisabledSkills(t *testing.T) {
411+ t.Parallel()
412+
413+ allSkills := []*skills.Skill{
414+ {Name: "bash", Builtin: false},
415+ {Name: "crush-config", Builtin: true},
416+ {Name: "image-convert", Builtin: false},
417+ }
418+ activeSkills := []*skills.Skill{
419+ {Name: "bash", Builtin: false},
420+ {Name: "crush-config", Builtin: true},
421+ }
422+
423+ tracker := skills.NewTracker(activeSkills)
424+
425+ cfg := config.NewTestStore(&config.Config{
426+ Providers: csync.NewMap[string, config.ProviderConfig](),
427+ Options: &config.Options{DisabledSkills: []string{"image-convert"}},
428+ })
429+ output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
430+ require.Contains(t, output, "[skills]")
431+ require.Contains(t, output, "bash = user, unloaded")
432+ require.Contains(t, output, "crush-config = builtin, unloaded")
433+ require.Contains(t, output, "image-convert = user, disabled")
434+}
435+
436+func TestCrushInfo_Skills_Ordering(t *testing.T) {
437+ t.Parallel()
438+
439+ allSkills := []*skills.Skill{
440+ {Name: "z-skill", Builtin: false},
441+ {Name: "a-skill", Builtin: true},
442+ {Name: "m-skill", Builtin: false},
443+ }
444+ activeSkills := allSkills
445+ tracker := skills.NewTracker(activeSkills)
446+
447+ cfg := config.NewTestStore(&config.Config{
448+ Providers: csync.NewMap[string, config.ProviderConfig](),
449+ })
450+ output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
451+
452+ aIdx := strings.Index(output, "a-skill")
453+ mIdx := strings.Index(output, "m-skill")
454+ zIdx := strings.Index(output, "z-skill")
455+ require.Less(t, aIdx, mIdx)
456+ require.Less(t, mIdx, zIdx)
457+}
458+
459+func TestCrushInfo_Skills_BuiltinOrigin(t *testing.T) {
460+ t.Parallel()
461+
462+ allSkills := []*skills.Skill{
463+ {Name: "crush-config", Builtin: true},
464+ {Name: "my-skill", Builtin: false},
465+ }
466+ activeSkills := allSkills
467+ tracker := skills.NewTracker(activeSkills)
468+
469+ cfg := config.NewTestStore(&config.Config{
470+ Providers: csync.NewMap[string, config.ProviderConfig](),
471+ })
472+ output := buildCrushInfo(cfg, nil, allSkills, activeSkills, tracker)
473+ require.Contains(t, output, "crush-config = builtin, unloaded")
474+ require.Contains(t, output, "my-skill = user, unloaded")
475+}
internal/agent/tools/crush_logs_test.go+5−6
@@ -3,6 +3,7 @@ package tools
33import (
44 "encoding/json"
55 "fmt"
6+ "maps"
67 "os"
78 "path/filepath"
89 "strings"
@@ -43,9 +44,7 @@ func makeLogEntry(level, msg, source string, line int, extra map[string]any) map
4344 "line": line,
4445 },
4546 }
46− for k, v := range extra {
47− entry[k] = v
48− }
47+ maps.Copy(entry, extra)
4948 return entry
5049}
5150
@@ -88,7 +87,7 @@ func TestCrushLogs_DefaultLines(t *testing.T) {
8887 t.Parallel()
8988 // Create 100 log entries.
9089 var entries []map[string]any
91− for i := 0; i < 100; i++ {
90+ for i := range 100 {
9291 entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
9392 }
9493
@@ -109,7 +108,7 @@ func TestCrushLogs_MaxCap(t *testing.T) {
109108 t.Parallel()
110109 // Create 200 log entries.
111110 var entries []map[string]any
112− for i := 0; i < 200; i++ {
111+ for i := range 200 {
113112 entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
114113 }
115114
@@ -350,7 +349,7 @@ func TestCrushLogs_PartialTrailingLine(t *testing.T) {
350349 require.NoError(t, err)
351350
352351 // Create valid entries.
353− for i := 0; i < 5; i++ {
352+ for i := range 5 {
354353 entry := makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil)
355354 line, _ := json.Marshal(entry)
356355 file.WriteString(string(line) + "\n")
internal/agent/tools/view.go+6−2
@@ -63,6 +63,7 @@ func NewViewTool(
6363 lspManager *lsp.Manager,
6464 permissions permission.Service,
6565 filetracker filetracker.Service,
66+ skillTracker *skills.Tracker,
6667 workingDir string,
6768 skillsPaths ...string,
6869) fantasy.AgentTool {
@@ -76,7 +77,8 @@ func NewViewTool(
7677
7778 // Handle builtin skill files (crush: prefix).
7879 if strings.HasPrefix(params.FilePath, skills.BuiltinPrefix) {
79− return readBuiltinFile(params)
80+ resp, err := readBuiltinFile(params, skillTracker)
81+ return resp, err
8082 }
8183
8284 // Handle relative paths
@@ -222,6 +224,7 @@ func NewViewTool(
222224 meta.ResourceType = ViewResourceSkill
223225 meta.ResourceName = skill.Name
224226 meta.ResourceDescription = skill.Description
227+ skillTracker.MarkLoaded(skill.Name)
225228 }
226229 }
227230
@@ -381,7 +384,7 @@ func isInSkillsPath(filePath string, skillsPaths []string) bool {
381384}
382385
383386// readBuiltinFile reads a file from the embedded builtin skills filesystem.
384−func readBuiltinFile(params ViewParams) (fantasy.ToolResponse, error) {
387+func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.ToolResponse, error) {
385388 embeddedPath := "builtin/" + strings.TrimPrefix(params.FilePath, skills.BuiltinPrefix)
386389 builtinFS := skills.BuiltinFS()
387390
@@ -425,6 +428,7 @@ func readBuiltinFile(params ViewParams) (fantasy.ToolResponse, error) {
425428 meta.ResourceType = ViewResourceSkill
426429 meta.ResourceName = skill.Name
427430 meta.ResourceDescription = skill.Description
431+ skillTracker.MarkLoaded(skill.Name)
428432 }
429433
430434 return fantasy.WithResponseMetadata(
internal/agent/tools/view_test.go+4−4
@@ -95,7 +95,7 @@ func TestReadBuiltinFile(t *testing.T) {
9595
9696 resp, err := readBuiltinFile(ViewParams{
9797 FilePath: "crush://skills/crush-config/SKILL.md",
98− })
98+ }, nil)
9999 require.NoError(t, err)
100100 require.NotEmpty(t, resp.Content)
101101 require.Contains(t, resp.Content, "Crush Configuration")
@@ -106,7 +106,7 @@ func TestReadBuiltinFile(t *testing.T) {
106106
107107 resp, err := readBuiltinFile(ViewParams{
108108 FilePath: "crush://skills/nonexistent/SKILL.md",
109− })
109+ }, nil)
110110 require.NoError(t, err)
111111 require.True(t, resp.IsError)
112112 })
@@ -116,7 +116,7 @@ func TestReadBuiltinFile(t *testing.T) {
116116
117117 resp, err := readBuiltinFile(ViewParams{
118118 FilePath: "crush://skills/crush-config/SKILL.md",
119− })
119+ }, nil)
120120 require.NoError(t, err)
121121
122122 var meta ViewResponseMetadata
@@ -132,7 +132,7 @@ func TestReadBuiltinFile(t *testing.T) {
132132 resp, err := readBuiltinFile(ViewParams{
133133 FilePath: "crush://skills/crush-config/SKILL.md",
134134 Offset: 5,
135− })
135+ }, nil)
136136 require.NoError(t, err)
137137 require.NotContains(t, resp.Content, " 1|")
138138 })
internal/skills/tracker.gonew file+53
@@ -0,0 +1,53 @@
1+package skills
2+
3+import "sync"
4+
5+// Tracker tracks which skills have been loaded (read) during a session.
6+// It is safe for concurrent use.
7+//
8+// Note: Tracking is name-based and limited to active skills only. If a builtin
9+// skill is overridden by a user skill, only the user skill (which is active)
10+// can be marked as loaded. This prevents misattribution when reading builtin
11+// files that have been overridden.
12+type Tracker struct {
13+ mu sync.RWMutex
14+ loaded map[string]bool
15+ activeNames map[string]bool // Set of active skill names (post-dedup, post-filter)
16+}
17+
18+// NewTracker creates a new skill tracker with the given active skill names.
19+// Only skills in activeSkills can be marked as loaded.
20+func NewTracker(activeSkills []*Skill) *Tracker {
21+ activeNames := make(map[string]bool, len(activeSkills))
22+ for _, s := range activeSkills {
23+ activeNames[s.Name] = true
24+ }
25+ return &Tracker{
26+ loaded: make(map[string]bool),
27+ activeNames: activeNames,
28+ }
29+}
30+
31+// MarkLoaded marks a skill as having been loaded.
32+// Only marks as loaded if the skill is in the active set (not overridden/disabled).
33+func (t *Tracker) MarkLoaded(name string) {
34+ if t == nil {
35+ return
36+ }
37+ t.mu.Lock()
38+ defer t.mu.Unlock()
39+ // Only track if this skill is actually active (not overridden by user skill).
40+ if t.activeNames[name] {
41+ t.loaded[name] = true
42+ }
43+}
44+
45+// IsLoaded returns true if the skill has been loaded.
46+func (t *Tracker) IsLoaded(name string) bool {
47+ if t == nil {
48+ return false
49+ }
50+ t.mu.RLock()
51+ defer t.mu.RUnlock()
52+ return t.loaded[name]
53+}
internal/skills/tracker_test.gonew file+102
@@ -0,0 +1,102 @@
1+package skills
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestTracker_MarkLoadedAndIsLoaded(t *testing.T) {
10+ t.Parallel()
11+
12+ activeSkills := []*Skill{
13+ {Name: "go-doc"},
14+ {Name: "bash"},
15+ }
16+ tracker := NewTracker(activeSkills)
17+
18+ // Initially not loaded.
19+ require.False(t, tracker.IsLoaded("go-doc"))
20+ require.False(t, tracker.IsLoaded("bash"))
21+
22+ // Mark as loaded.
23+ tracker.MarkLoaded("go-doc")
24+ require.True(t, tracker.IsLoaded("go-doc"))
25+ require.False(t, tracker.IsLoaded("bash"))
26+
27+ // Mark another.
28+ tracker.MarkLoaded("bash")
29+ require.True(t, tracker.IsLoaded("go-doc"))
30+ require.True(t, tracker.IsLoaded("bash"))
31+}
32+
33+func TestTracker_NonActiveSkillCannotBeMarkedLoaded(t *testing.T) {
34+ t.Parallel()
35+
36+ activeSkills := []*Skill{
37+ {Name: "go-doc"},
38+ }
39+ tracker := NewTracker(activeSkills)
40+
41+ // Cannot mark non-active skill as loaded.
42+ tracker.MarkLoaded("bash")
43+ require.False(t, tracker.IsLoaded("bash"))
44+
45+ // Can mark active skill as loaded.
46+ tracker.MarkLoaded("go-doc")
47+ require.True(t, tracker.IsLoaded("go-doc"))
48+}
49+
50+func TestTracker_NilSafety(t *testing.T) {
51+ t.Parallel()
52+
53+ var tracker *Tracker
54+
55+ // Should not panic.
56+ tracker.MarkLoaded("go-doc")
57+ require.False(t, tracker.IsLoaded("go-doc"))
58+}
59+
60+func TestTracker_BuiltinSkillTracking(t *testing.T) {
61+ t.Parallel()
62+
63+ // Simulate active skills including a builtin skill (crush-config).
64+ activeSkills := []*Skill{
65+ {Name: "crush-config", Description: "Crush config", Builtin: true},
66+ {Name: "go-doc", Description: "Go docs", Builtin: false},
67+ }
68+ tracker := NewTracker(activeSkills)
69+
70+ // Initially not loaded.
71+ require.False(t, tracker.IsLoaded("crush-config"))
72+ require.False(t, tracker.IsLoaded("go-doc"))
73+
74+ // Mark builtin skill as loaded (simulating read via crush://...).
75+ tracker.MarkLoaded("crush-config")
76+ require.True(t, tracker.IsLoaded("crush-config"))
77+
78+ // Mark user skill as loaded.
79+ tracker.MarkLoaded("go-doc")
80+ require.True(t, tracker.IsLoaded("go-doc"))
81+}
82+
83+func TestTracker_OverriddenBuiltinNotTracked(t *testing.T) {
84+ t.Parallel()
85+
86+ // Simulate scenario where builtin "bash" is overridden by user "bash".
87+ // After dedup, only user "bash" is active.
88+ activeSkills := []*Skill{
89+ {Name: "bash", Description: "User bash override", Builtin: false},
90+ }
91+ tracker := NewTracker(activeSkills)
92+
93+ // Trying to mark the builtin "bash" as loaded should not work
94+ // because the active skill is the user override.
95+ tracker.MarkLoaded("bash")
96+ require.True(t, tracker.IsLoaded("bash"))
97+
98+ // But if we somehow tried to mark a different builtin that's not active,
99+ // it wouldn't get marked.
100+ tracker.MarkLoaded("nonexistent-builtin")
101+ require.False(t, tracker.IsLoaded("nonexistent-builtin"))
102+}