From 4f390477bd162ef8d72df3fcfd894b957669f2c3 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 6 Feb 2026 11:39:13 +0100 Subject: [PATCH 1/2] Add skill invocation via slash commands Skills can now be invoked as /commands in the TUI, both through the command palette and by typing /skill-name [args] directly. When skills are enabled for the current agent, they appear in a "Skills" category in the command palette. Skill resolution follows the same pattern as agent commands: the App resolves /skill-name to a prompt string by reading the SKILL.md file, then the chat page sends it as a regular message to the agent. Assisted-By: cagent --- cagent-schema.json | 4 + cmd/root/new.go | 11 +-- pkg/app/app.go | 129 ++++++++++++-------------- pkg/app/app_test.go | 45 ++++++++- pkg/runtime/commands_test.go | 15 +++ pkg/runtime/remote_runtime.go | 26 +++++- pkg/runtime/runtime.go | 90 ++++++++++++++++++ pkg/tui/commands/commands.go | 30 ++++++ pkg/tui/components/sidebar/sidebar.go | 22 +++++ pkg/tui/page/chat/chat.go | 8 +- pkg/tui/page/chat/runtime_events.go | 1 + 11 files changed, 294 insertions(+), 87 deletions(-) diff --git a/cagent-schema.json b/cagent-schema.json index 4b18b6e05..cdad6b556 100644 --- a/cagent-schema.json +++ b/cagent-schema.json @@ -284,6 +284,10 @@ "hooks": { "$ref": "#/definitions/HooksConfig", "description": "Lifecycle hooks for executing shell commands at various points in the agent's execution" + }, + "skills": { + "type": "boolean", + "description": "Enable skills discovery for this agent. When enabled, the agent can discover and load skill files (SKILL.md) from the workspace." } }, "additionalProperties": false diff --git a/cmd/root/new.go b/cmd/root/new.go index 801d4f468..165935d3f 100644 --- a/cmd/root/new.go +++ b/cmd/root/new.go @@ -12,7 +12,6 @@ import ( "github.com/docker/cagent/pkg/creator" "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/session" - "github.com/docker/cagent/pkg/sessiontitle" "github.com/docker/cagent/pkg/telemetry" "github.com/docker/cagent/pkg/tui" tuiinput "github.com/docker/cagent/pkg/tui/input" @@ -88,13 +87,9 @@ func (f *newFlags) runNewCommand(cmd *cobra.Command, args []string) error { } func runTUI(ctx context.Context, rt runtime.Runtime, sess *session.Session, opts ...app.Opt) error { - // For local runtime, create and pass a title generator. - if pr, ok := rt.(*runtime.PersistentRuntime); ok { - if a := pr.CurrentAgent(); a != nil { - if model := a.Model(); model != nil { - opts = append(opts, app.WithTitleGenerator(sessiontitle.New(model, a.FallbackModels()...))) - } - } + // If the runtime can provide a title generator, use it. + if gen := rt.TitleGenerator(); gen != nil { + opts = append(opts, app.WithTitleGenerator(gen)) } a := app.New(ctx, rt, sess, opts...) diff --git a/pkg/app/app.go b/pkg/app/app.go index c59999ed7..9090bf63d 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "os/exec" "slices" "strings" @@ -11,7 +12,6 @@ import ( "time" tea "charm.land/bubbletea/v2" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/docker/cagent/pkg/app/export" "github.com/docker/cagent/pkg/app/transcript" @@ -21,6 +21,7 @@ import ( "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/sessiontitle" + "github.com/docker/cagent/pkg/skills" "github.com/docker/cagent/pkg/tools" mcptools "github.com/docker/cagent/pkg/tools/mcp" "github.com/docker/cagent/pkg/tui/messages" @@ -144,6 +145,55 @@ func (a *App) CurrentAgentCommands(ctx context.Context) types.Commands { return a.runtime.CurrentAgentInfo(ctx).Commands } +// CurrentAgentSkills returns the available skills if skills are enabled for the current agent. +func (a *App) CurrentAgentSkills() []skills.Skill { + if a.runtime.CurrentAgentSkillsEnabled() { + return skills.Load() + } + return nil +} + +// ResolveSkillCommand checks if the input matches a skill slash command (e.g. /skill-name args). +// If matched, it reads the skill file and returns the resolved prompt. Otherwise returns "". +func (a *App) ResolveSkillCommand(input string) (string, error) { + if !strings.HasPrefix(input, "/") { + return "", nil + } + + cmd, arg, _ := strings.Cut(input[1:], " ") + arg = strings.TrimSpace(arg) + + for _, skill := range a.CurrentAgentSkills() { + if skill.Name != cmd { + continue + } + + content, err := os.ReadFile(skill.FilePath) + if err != nil { + return "", fmt.Errorf("reading skill %q: %w", skill.Name, err) + } + + if arg != "" { + return fmt.Sprintf("Use the following skill.\n\nUser's request: %s\n\n\n%s\n", arg, skill.Name, string(content)), nil + } + return fmt.Sprintf("Use the following skill.\n\n\n%s\n", skill.Name, string(content)), nil + } + + return "", nil +} + +// ResolveInput resolves the user input by trying skill commands first, +// then agent commands. Returns the resolved content ready to send to the agent. +func (a *App) ResolveInput(ctx context.Context, input string) string { + if resolved, err := a.ResolveSkillCommand(input); err != nil { + return fmt.Sprintf("Error loading skill: %v", err) + } else if resolved != "" { + return resolved + } + + return a.ResolveCommand(ctx, input) +} + // CurrentAgentModel returns the model ID for the current agent. // Returns the tracked model from AgentInfoEvent, or falls back to session overrides. // Returns empty string if no model information is available (fail-open scenario). @@ -169,56 +219,12 @@ func (a *App) TrackCurrentAgentModel(model string) { // CurrentMCPPrompts returns the available MCP prompts for the active agent func (a *App) CurrentMCPPrompts(ctx context.Context) map[string]mcptools.PromptInfo { - if localRuntime, ok := a.runtime.(*runtime.LocalRuntime); ok { - return localRuntime.CurrentMCPPrompts(ctx) - } - return make(map[string]mcptools.PromptInfo) + return a.runtime.CurrentMCPPrompts(ctx) } // ExecuteMCPPrompt executes an MCP prompt with provided arguments and returns the content func (a *App) ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) { - localRuntime, ok := a.runtime.(*runtime.LocalRuntime) - if !ok { - return "", fmt.Errorf("MCP prompts are only supported with local runtime") - } - - currentAgent := localRuntime.CurrentAgent() - if currentAgent == nil { - return "", fmt.Errorf("no current agent available") - } - - for _, toolset := range currentAgent.ToolSets() { - if mcpToolset, ok := tools.As[*mcptools.Toolset](toolset); ok { - result, err := mcpToolset.GetPrompt(ctx, promptName, arguments) - if err == nil { - // Convert the MCP result to a string format suitable for the editor - // The result contains Messages which are the prompt content - if len(result.Messages) == 0 { - return "No content returned from MCP prompt", nil - } - - var content string - for i, message := range result.Messages { - if i > 0 { - content += "\n\n" - } - if textContent, ok := message.Content.(*mcp.TextContent); ok { - content += textContent.Text - } else { - content += fmt.Sprintf("[Non-text content: %T]", message.Content) - } - } - return content, nil - } - // If error is "prompt not found", continue to next toolset - // Otherwise, return the error - if err.Error() != "prompt not found" { - return "", fmt.Errorf("error executing prompt '%s': %w", promptName, err) - } - } - } - - return "", fmt.Errorf("MCP prompt '%s' not found in any active toolset", promptName) + return a.runtime.ExecuteMCPPrompt(ctx, promptName, arguments) } // ResolveCommand converts /command to its prompt text @@ -829,19 +835,9 @@ func (a *App) UpdateSessionTitle(ctx context.Context, title string) error { return ErrTitleGenerating } - // Update in-memory session - a.session.Title = title - - // Check if runtime is a RemoteRuntime and use its UpdateSessionTitle method - if remoteRT, ok := a.runtime.(*runtime.RemoteRuntime); ok { - if err := remoteRT.UpdateSessionTitle(ctx, title); err != nil { - return fmt.Errorf("failed to update session title on remote: %w", err) - } - } else if store := a.runtime.SessionStore(); store != nil { - // For local runtime, persist via session store - if err := store.UpdateSession(ctx, a.session); err != nil { - return fmt.Errorf("failed to persist session title: %w", err) - } + // Persist the title through the runtime + if err := a.runtime.UpdateSessionTitle(ctx, a.session, title); err != nil { + return fmt.Errorf("failed to update session title: %w", err) } // Emit a SessionTitleEvent to update the UI consistently @@ -876,18 +872,9 @@ func (a *App) generateTitle(ctx context.Context, userMessages []string) { return } - // Update the session title - a.session.Title = title - // Persist the title - if remoteRT, ok := a.runtime.(*runtime.RemoteRuntime); ok { - if err := remoteRT.UpdateSessionTitle(ctx, title); err != nil { - slog.Error("Failed to persist title on remote", "session_id", a.session.ID, "error", err) - } - } else if store := a.runtime.SessionStore(); store != nil { - if err := store.UpdateSession(ctx, a.session); err != nil { - slog.Error("Failed to persist title", "session_id", a.session.ID, "error", err) - } + if err := a.runtime.UpdateSessionTitle(ctx, a.session, title); err != nil { + slog.Error("Failed to persist title", "session_id", a.session.ID, "error", err) } // Emit the title event to update the UI diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 1abb35e48..f2ec8a89f 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -10,7 +10,9 @@ import ( "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/sessiontitle" "github.com/docker/cagent/pkg/tools" + mcptools "github.com/docker/cagent/pkg/tools/mcp" ) // mockRuntime is a minimal mock for testing App without a real runtime @@ -43,7 +45,21 @@ func (m *mockRuntime) SessionStore() session.Store { return nil } func (m *mockRuntime) Summarize(ctx context.Context, sess *session.Session, additionalPrompt string, events chan runtime.Event) { } func (m *mockRuntime) PermissionsInfo() *runtime.PermissionsInfo { return nil } -func (m *mockRuntime) Stop() {} +func (m *mockRuntime) CurrentAgentSkillsEnabled() bool { return false } +func (m *mockRuntime) CurrentMCPPrompts(context.Context) map[string]mcptools.PromptInfo { + return make(map[string]mcptools.PromptInfo) +} + +func (m *mockRuntime) ExecuteMCPPrompt(context.Context, string, map[string]string) (string, error) { + return "", nil +} + +func (m *mockRuntime) UpdateSessionTitle(_ context.Context, sess *session.Session, title string) error { + sess.Title = title + return nil +} +func (m *mockRuntime) TitleGenerator() *sessiontitle.Generator { return nil } +func (m *mockRuntime) Stop() {} // Verify mockRuntime implements runtime.Runtime var _ runtime.Runtime = (*mockRuntime)(nil) @@ -213,6 +229,33 @@ func TestApp_UpdateSessionTitle(t *testing.T) { }) } +func TestApp_ResolveSkillCommand_NoLocalRuntime(t *testing.T) { + t.Parallel() + + ctx := t.Context() + rt := &mockRuntime{} + sess := session.New() + app := New(ctx, rt, sess) + + // mockRuntime is not a LocalRuntime, so no skills should be returned + resolved, err := app.ResolveSkillCommand("/some-skill") + require.NoError(t, err) + assert.Empty(t, resolved) +} + +func TestApp_ResolveSkillCommand_NotSlashCommand(t *testing.T) { + t.Parallel() + + ctx := t.Context() + rt := &mockRuntime{} + sess := session.New() + app := New(ctx, rt, sess) + + resolved, err := app.ResolveSkillCommand("not a slash command") + require.NoError(t, err) + assert.Empty(t, resolved) +} + func TestApp_RegenerateSessionTitle(t *testing.T) { t.Parallel() diff --git a/pkg/runtime/commands_test.go b/pkg/runtime/commands_test.go index f5b6b4637..55d5f1b22 100644 --- a/pkg/runtime/commands_test.go +++ b/pkg/runtime/commands_test.go @@ -8,7 +8,9 @@ import ( "github.com/docker/cagent/pkg/config/types" "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/sessiontitle" "github.com/docker/cagent/pkg/tools" + mcptools "github.com/docker/cagent/pkg/tools/mcp" ) // mockRuntime implements Runtime interface for testing @@ -49,6 +51,19 @@ func (m *mockRuntime) SessionStore() session.Store { return nil } func (m *mockRuntime) Summarize(context.Context, *session.Session, string, chan Event) { } func (m *mockRuntime) PermissionsInfo() *PermissionsInfo { return nil } +func (m *mockRuntime) CurrentAgentSkillsEnabled() bool { return false } +func (m *mockRuntime) CurrentMCPPrompts(context.Context) map[string]mcptools.PromptInfo { + return make(map[string]mcptools.PromptInfo) +} + +func (m *mockRuntime) ExecuteMCPPrompt(context.Context, string, map[string]string) (string, error) { + return "", nil +} + +func (m *mockRuntime) UpdateSessionTitle(context.Context, *session.Session, string) error { + return nil +} +func (m *mockRuntime) TitleGenerator() *sessiontitle.Generator { return nil } func (m *mockRuntime) RegenerateTitle(context.Context, *session.Session, chan Event) { } diff --git a/pkg/runtime/remote_runtime.go b/pkg/runtime/remote_runtime.go index 47661cdd6..049091169 100644 --- a/pkg/runtime/remote_runtime.go +++ b/pkg/runtime/remote_runtime.go @@ -14,6 +14,7 @@ import ( "github.com/docker/cagent/pkg/chat" "github.com/docker/cagent/pkg/config/latest" "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/sessiontitle" "github.com/docker/cagent/pkg/team" "github.com/docker/cagent/pkg/tools" "github.com/docker/cagent/pkg/tools/mcp" @@ -420,12 +421,35 @@ func (r *RemoteRuntime) PermissionsInfo() *PermissionsInfo { func (r *RemoteRuntime) ResetStartupInfo() { } +// CurrentAgentSkillsEnabled returns whether skills are enabled for the current agent. +// It reads the agent config from the remote API to determine the skills setting. +func (r *RemoteRuntime) CurrentAgentSkillsEnabled() bool { + cfg := r.readCurrentAgentConfig(context.Background()) + return cfg.Skills != nil && *cfg.Skills +} + // UpdateSessionTitle updates the title of the current session on the remote server. -func (r *RemoteRuntime) UpdateSessionTitle(ctx context.Context, title string) error { +func (r *RemoteRuntime) UpdateSessionTitle(ctx context.Context, sess *session.Session, title string) error { + sess.Title = title if r.sessionID == "" { return fmt.Errorf("cannot update session title: no session ID available") } return r.client.UpdateSessionTitle(ctx, r.sessionID, title) } +// CurrentMCPPrompts is not supported on remote runtimes. +func (r *RemoteRuntime) CurrentMCPPrompts(context.Context) map[string]mcp.PromptInfo { + return make(map[string]mcp.PromptInfo) +} + +// ExecuteMCPPrompt is not supported on remote runtimes. +func (r *RemoteRuntime) ExecuteMCPPrompt(context.Context, string, map[string]string) (string, error) { + return "", fmt.Errorf("MCP prompts are not supported by remote runtimes") +} + +// TitleGenerator is not supported on remote runtimes (titles are generated server-side). +func (r *RemoteRuntime) TitleGenerator() *sessiontitle.Generator { + return nil +} + var _ Runtime = (*RemoteRuntime)(nil) diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 6755ec8ca..c42967ef3 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -31,6 +31,7 @@ import ( "github.com/docker/cagent/pkg/rag" ragtypes "github.com/docker/cagent/pkg/rag/types" "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/sessiontitle" "github.com/docker/cagent/pkg/team" "github.com/docker/cagent/pkg/telemetry" "github.com/docker/cagent/pkg/tools" @@ -135,6 +136,23 @@ type Runtime interface { // PermissionsInfo returns the team-level permission patterns (allow/deny). // Returns nil if no permissions are configured. PermissionsInfo() *PermissionsInfo + + // CurrentAgentSkillsEnabled returns whether skills are enabled for the current agent. + CurrentAgentSkillsEnabled() bool + + // CurrentMCPPrompts returns MCP prompts available from the current agent's toolsets. + // Returns an empty map if no MCP prompts are available. + CurrentMCPPrompts(ctx context.Context) map[string]mcptools.PromptInfo + + // ExecuteMCPPrompt executes a named MCP prompt with the given arguments. + ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) + + // UpdateSessionTitle persists a new title for the current session. + UpdateSessionTitle(ctx context.Context, sess *session.Session, title string) error + + // TitleGenerator returns a generator for automatic session titles, or nil + // if the runtime does not support local title generation (e.g. remote runtimes). + TitleGenerator() *sessiontitle.Generator } // PermissionsInfo contains the allow and deny patterns for tool permissions. @@ -513,6 +531,69 @@ func (r *LocalRuntime) CurrentAgent() *agent.Agent { return current } +// CurrentAgentSkillsEnabled returns whether skills are enabled for the current agent. +func (r *LocalRuntime) CurrentAgentSkillsEnabled() bool { + a := r.CurrentAgent() + return a != nil && a.SkillsEnabled() +} + +// ExecuteMCPPrompt executes an MCP prompt with provided arguments and returns the content. +func (r *LocalRuntime) ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) { + currentAgent := r.CurrentAgent() + if currentAgent == nil { + return "", fmt.Errorf("no current agent available") + } + + for _, toolset := range currentAgent.ToolSets() { + mcpToolset, ok := tools.As[*mcptools.Toolset](toolset) + if !ok { + continue + } + + result, err := mcpToolset.GetPrompt(ctx, promptName, arguments) + if err != nil { + // If error is "prompt not found", continue to next toolset + if err.Error() == "prompt not found" { + continue + } + return "", fmt.Errorf("error executing prompt '%s': %w", promptName, err) + } + + // Convert the MCP result to a string format + if len(result.Messages) == 0 { + return "No content returned from MCP prompt", nil + } + + var content strings.Builder + for i, message := range result.Messages { + if i > 0 { + content.WriteString("\n\n") + } + if textContent, ok := message.Content.(*mcp.TextContent); ok { + content.WriteString(textContent.Text) + } else { + content.WriteString(fmt.Sprintf("[Non-text content: %T]", message.Content)) + } + } + return content.String(), nil + } + + return "", fmt.Errorf("MCP prompt '%s' not found in any active toolset", promptName) +} + +// TitleGenerator returns a title generator for automatic session title generation. +func (r *LocalRuntime) TitleGenerator() *sessiontitle.Generator { + a := r.CurrentAgent() + if a == nil { + return nil + } + model := a.Model() + if model == nil { + return nil + } + return sessiontitle.New(model, a.FallbackModels()...) +} + // getHooksExecutor creates a hooks executor for the given agent func (r *LocalRuntime) getHooksExecutor(a *agent.Agent) *hooks.Executor { hooksCfg := hooks.FromConfig(a.Hooks()) @@ -590,6 +671,15 @@ func (r *LocalRuntime) SessionStore() session.Store { return r.sessionStore } +// UpdateSessionTitle persists the session title via the session store. +func (r *LocalRuntime) UpdateSessionTitle(ctx context.Context, sess *session.Session, title string) error { + sess.Title = title + if r.sessionStore != nil { + return r.sessionStore.UpdateSession(ctx, sess) + } + return nil +} + // PermissionsInfo returns the team-level permission patterns. // Returns nil if no permissions are configured. func (r *LocalRuntime) PermissionsInfo() *PermissionsInfo { diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 003ce995b..34a978aed 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -388,6 +388,36 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor }) } + // Add skill commands if skills are enabled for the current agent + skillsList := application.CurrentAgentSkills() + if len(skillsList) > 0 { + skillCommands := make([]Item, 0, len(skillsList)) + for _, skill := range skillsList { + skillName := skill.Name + description := toolcommon.TruncateText(skill.Description, 55) + + skillCommands = append(skillCommands, Item{ + ID: "skill." + skillName, + Label: skillName, + Description: description, + Category: "Skills", + SlashCommand: "/" + skillName, + Execute: func(arg string) tea.Cmd { + input := "/" + skillName + if arg = strings.TrimSpace(arg); arg != "" { + input += " " + arg + } + return core.CmdHandler(messages.SendMsg{Content: input}) + }, + }) + } + + categories = append(categories, Category{ + Name: "Skills", + Commands: skillCommands, + }) + } + return categories } diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 2feb4a603..dddd7ae86 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -50,6 +50,7 @@ type Model interface { SetTeamInfo(availableAgents []runtime.AgentDetails) SetAgentSwitching(switching bool) SetToolsetInfo(availableTools int, loading bool) + SetSkillsInfo(availableSkills int) SetSessionStarred(starred bool) SetQueuedMessages(messages ...string) GetSize() (width, height int) @@ -122,6 +123,7 @@ type model struct { availableAgents []runtime.AgentDetails agentSwitching bool availableTools int + availableSkills int toolsLoading bool // true when more tools may still be loading sessionState *service.SessionState workingAgent string // Name of the agent currently working (empty if none) @@ -285,6 +287,12 @@ func (m *model) SetToolsetInfo(availableTools int, loading bool) { m.invalidateCache() } +// SetSkillsInfo sets the number of available skills +func (m *model) SetSkillsInfo(availableSkills int) { + m.availableSkills = availableSkills + m.invalidateCache() +} + // SetSessionStarred sets the starred status of the current session func (m *model) SetSessionStarred(starred bool) { m.sessionStarred = starred @@ -1125,6 +1133,11 @@ func (m *model) toolsetInfo(contentWidth int) string { // Tools status line lines = append(lines, m.renderToolsStatus()) + // Skills status line + if m.availableSkills > 0 { + lines = append(lines, m.renderSkillsStatus()) + } + // Toggle indicators with shortcuts // Only show "Thinking enabled" if the model supports reasoning toggles := []struct { @@ -1165,6 +1178,15 @@ func (m *model) renderToolsStatus() string { return "" } +// renderSkillsStatus renders the skills available status line +func (m *model) renderSkillsStatus() string { + label := "skills available" + if m.availableSkills == 1 { + label = "skill available" + } + return styles.TabAccentStyle.Render("█") + styles.TabPrimaryStyle.Render(fmt.Sprintf(" %d %s", m.availableSkills, label)) +} + // renderToggleIndicator renders a toggle status with its keyboard shortcut func (m *model) renderToggleIndicator(label, shortcut string, contentWidth int) string { indicator := styles.TabAccentStyle.Render("✓") + styles.TabPrimaryStyle.Render(" "+label) diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 73039781f..04dde365d 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -976,13 +976,9 @@ func (p *chatPage) processMessage(msg msgtypes.SendMsg) tea.Cmd { } // Run command resolution and agent execution in a goroutine - // so the UI can update with the spinner before any blocking operations. + // so the UI stays responsive while skill/agent commands are resolved. go func() { - // Resolve agent commands (e.g., /fix-lint -> prompt text) - // This can execute tools and take time, but the spinner is already showing. - resolvedContent := p.app.ResolveCommand(ctx, msg.Content) - - p.app.Run(ctx, p.msgCancel, resolvedContent, msg.Attachments) + p.app.Run(ctx, p.msgCancel, p.app.ResolveInput(ctx, msg.Content), msg.Attachments) }() return tea.Batch(p.messages.ScrollToBottom(), spinnerCmd, loadingCmd) diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index 6c08960bc..af126020f 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -116,6 +116,7 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) { case *runtime.ToolsetInfoEvent: p.sidebar.SetToolsetInfo(msg.AvailableTools, msg.Loading) + p.sidebar.SetSkillsInfo(len(p.app.CurrentAgentSkills())) return true, nil case *runtime.SessionTitleEvent: From 66c9f5e773a0512ab42e741d759c1d766b8f22ff Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 6 Feb 2026 11:26:27 +0100 Subject: [PATCH 2/2] Add a skill to bump dependencies Signed-off-by: David Gageot --- .agents/skills/bump-go-dependencies/SKILL.md | 84 ++++++++++++++++++++ examples/gopher.yaml | 1 + 2 files changed, 85 insertions(+) create mode 100644 .agents/skills/bump-go-dependencies/SKILL.md diff --git a/.agents/skills/bump-go-dependencies/SKILL.md b/.agents/skills/bump-go-dependencies/SKILL.md new file mode 100644 index 000000000..d670642a1 --- /dev/null +++ b/.agents/skills/bump-go-dependencies/SKILL.md @@ -0,0 +1,84 @@ +--- +name: bump-go-dependencies +description: Update direct Go module dependencies one by one, validating each bump with tests and linter, committing individually, and producing a summary table for a PR description +--- + +# Bump Direct Go Dependencies + +When asked to update or bump Go dependencies, follow this procedure. + +## 1. List Outdated Direct Dependencies + +Run the following to get a list of direct dependencies that have newer versions available: + +```sh +go list -m -u -json all 2>/dev/null | jq -r 'select(.Indirect != true and .Update != null) | "\(.Path) \(.Version) \(.Update.Path) \(.Update.Version)"' +``` + +This produces lines of the form: + +``` +module/path current_version update_path new_version +``` + +If the command produces no output, all direct dependencies are already up to date. Inform the user and stop. + +## 2. Update Each Dependency One by One + +For **each** outdated dependency, perform the following steps in order: + +### a. Upgrade + +```sh +go get @ +``` + +### b. Tidy + +```sh +go mod tidy +``` + +### c. Validate + +Run the linter and the tests: + +```sh +task lint +task test +``` + +### d. Decide + +- **If both pass**: stage and commit the changes: + ```sh + git add -A + git commit -m "bump from to " -m "" -m "Assisted-By: cagent" + ``` + Record the dependency as **bumped** in your tracking table. + +- **If either fails**: revert all changes and move on: + ```sh + git checkout -- . + ``` + Record the dependency as **skipped** in your tracking table, noting the reason (lint failure, test failure, or both). + +## 3. Produce a Summary Table + +After processing every dependency, output a **copy-pastable** Markdown table inside a fenced code block. +The table must list every dependency that was considered, with columns for the module path, old version, new version, and status. +Don't use emojis, just plain markdown. + +Example: + +~~~ +```markdown +| Module | From | To | Status | +|--------|------|----|--------| +| github.com/example/foo | v1.2.0 | v1.3.0 | bumped | +| github.com/example/bar | v0.4.1 | v0.5.0 | skipped — test failure | +| golang.org/x/text | v0.21.0 | v0.22.0 | bumped | +``` +~~~ + +This table is meant to be pasted directly into a pull-request description. diff --git a/examples/gopher.yaml b/examples/gopher.yaml index 7af096f82..9a0909a0f 100755 --- a/examples/gopher.yaml +++ b/examples/gopher.yaml @@ -12,6 +12,7 @@ agents: root: model: claude description: Expert Golang Developer specialized in implementing features and improving code quality. + skills: true instruction: | **Goal:** Help with Go code-related tasks by examining, modifying, and validating code changes.