From d7d2bf69bfc697e61bffc62e3dd61dd195d81e04 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 22 Mar 2026 15:33:25 +0100 Subject: [PATCH] feat(skills): add channel commands to list and force installed skills --- docs/channels/telegram/README.md | 20 +++ docs/channels/telegram/README.zh.md | 20 +++ docs/chat-apps.md | 9 +- docs/configuration.md | 18 +++ docs/zh/chat-apps.md | 9 +- docs/zh/configuration.md | 18 +++ pkg/agent/context.go | 68 ++++++++++ pkg/agent/loop.go | 160 ++++++++++++++++++++++++ pkg/agent/loop_test.go | 157 +++++++++++++++++++++++ pkg/commands/builtin.go | 1 + pkg/commands/builtin_test.go | 47 ++++++- pkg/commands/cmd_list.go | 17 +++ pkg/commands/cmd_use.go | 9 ++ pkg/commands/request.go | 5 + pkg/commands/runtime.go | 1 + pkg/commands/show_list_handlers_test.go | 19 +++ 16 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 pkg/commands/cmd_use.go diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index a3e057ba4..4995ad216 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -33,3 +33,23 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co 3. Obtain the HTTP API Token 4. Fill in the Token in the configuration file 5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`) + +## Built-in Commands + +Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`. + +Skill-related commands: + +- `/list skills` lists the installed skills visible to the current agent. +- `/use ` forces a skill for a single request. +- `/use ` arms the skill for your next message in the same chat. +- `/use clear` clears a pending skill override. + +Examples: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use git +explain how to squash the last 3 commits +``` diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index f50c712ce..7dfd3027a 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -33,3 +33,23 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器 3. 获取 HTTP API Token 4. 将 Token 填入配置文件中 5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID) + +## 内置命令 + +Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start`、`/help`、`/show`、`/list` 和 `/use`。 + +与技能相关的命令: + +- `/list skills`:列出当前 Agent 可见的已安装技能。 +- `/use `:只在本次请求中强制使用指定技能。 +- `/use `:为同一聊天中的下一条消息预先启用该技能。 +- `/use clear`:清除待应用的技能覆盖。 + +示例: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use italiapersonalfinance +dammi le ultime news +``` diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 07297952a..a2441a9bf 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -60,11 +60,18 @@ picoclaw gateway **4. Telegram command menu (auto-registered at startup)** -PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync. +PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) so command menu and runtime behavior stay in sync. Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. +You can also manage installed skills directly from Telegram: + +- `/list skills` +- `/use ` +- `/use ` and then send the actual request in the next message +- `/use clear` + **4. Advanced Formatting** You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks. diff --git a/docs/configuration.md b/docs/configuration.md index b5d652a85..26d31902b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,6 +65,24 @@ For advanced/test setups, you can override the builtin skills root with: export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Using Skills From Chat Channels + +Once skills are installed, you can inspect and force them directly from a chat channel: + +- `/list skills` shows the installed skill names available to the current agent. +- `/use ` forces a specific skill for a single request. +- `/use ` arms that skill for your next message in the same chat session. +- `/use clear` cancels a pending skill override created by `/use `. + +Examples: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use italiapersonalfinance +dammi le ultime news +``` + ### Unified Command Execution Policy - Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md index 2d6e55c3d..a2613aa57 100644 --- a/docs/zh/chat-apps.md +++ b/docs/zh/chat-apps.md @@ -63,11 +63,18 @@ picoclaw gateway **4. Telegram 命令菜单(启动时自动注册)** -PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 +PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 +你也可以直接在 Telegram 中管理已安装技能: + +- `/list skills` +- `/use ` +- `/use `,然后在下一条消息里发送真正的请求 +- `/use clear` +
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 68fb1fd1a..6eb53cb0f 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -65,6 +65,24 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### 在聊天频道中使用技能 + +技能安装完成后,可以直接在聊天频道里查看并显式启用它们: + +- `/list skills`:显示当前 Agent 可用的已安装技能名称。 +- `/use `:只对当前这一条请求强制使用指定技能。 +- `/use `:为同一会话中的下一条消息预先启用该技能。 +- `/use clear`:取消通过 `/use ` 设置的待应用技能。 + +示例: + +```text +/list skills +/use git explain how to squash the last 3 commits +/use italiapersonalfinance +dammi le ultime news +``` + ### 统一命令执行策略 - 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 8db8f0b5e..a1a735886 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -497,6 +497,7 @@ func (cb *ContextBuilder) BuildMessages( currentMessage string, media []string, channel, chatID, senderID, senderDisplayName string, + activeSkills ...string, ) []providers.Message { messages := []providers.Message{} @@ -530,6 +531,11 @@ func (cb *ContextBuilder) BuildMessages( {Type: "text", Text: dynamicCtx}, } + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { + stringParts = append(stringParts, skillsText) + contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText}) + } + if summary != "" { summaryText := fmt.Sprintf( "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ @@ -737,6 +743,68 @@ func (cb *ContextBuilder) AddAssistantMessage( return messages } +func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string { + if cb.skillsLoader == nil || len(skillNames) == 0 { + return "" + } + + var ordered []string + seen := make(map[string]struct{}, len(skillNames)) + for _, name := range skillNames { + canonical, ok := cb.ResolveSkillName(name) + if !ok { + continue + } + if _, exists := seen[canonical]; exists { + continue + } + seen[canonical] = struct{}{} + ordered = append(ordered, canonical) + } + if len(ordered) == 0 { + return "" + } + + content := cb.skillsLoader.LoadSkillsForContext(ordered) + if strings.TrimSpace(content) == "" { + return "" + } + + return fmt.Sprintf(`# Active Skills + +The following skills are active for this request. Follow them when relevant. + +%s`, content) +} + +func (cb *ContextBuilder) ListSkillNames() []string { + if cb.skillsLoader == nil { + return nil + } + + allSkills := cb.skillsLoader.ListSkills() + names := make([]string, 0, len(allSkills)) + for _, skill := range allSkills { + names = append(names, skill.Name) + } + return names +} + +func (cb *ContextBuilder) ResolveSkillName(name string) (string, bool) { + name = strings.TrimSpace(name) + if name == "" || cb.skillsLoader == nil { + return "", false + } + + for _, skill := range cb.skillsLoader.ListSkills() { + if strings.EqualFold(skill.Name, name) { + return skill.Name, true + } + } + + return "", false +} + // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ed5c73afc..81036e499 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -48,6 +48,7 @@ type AgentLoop struct { transcriber voice.Transcriber cmdRegistry *commands.Registry mcp mcpRuntime + pendingSkills sync.Map mu sync.RWMutex reloadFunc func() error // Track active requests for safe provider cleanup @@ -62,6 +63,7 @@ type processOptions struct { SenderID string // Current sender ID for dynamic context SenderDisplayName string // Current sender display name for dynamic context UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message Media []string // media:// refs from inbound message DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization @@ -782,6 +784,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return response, nil } + if pending := al.takePendingSkills(opts.SessionKey); len(pending) > 0 { + opts.ForcedSkills = append(opts.ForcedSkills, pending...) + logger.InfoCF("agent", "Applying pending skill override", + map[string]any{ + "session_key": opts.SessionKey, + "skills": strings.Join(pending, ","), + }) + } + return al.runAgentLoop(ctx, agent, opts) } @@ -915,6 +926,7 @@ func (al *AgentLoop) runAgentLoop( opts.ChatID, opts.SenderID, opts.SenderDisplayName, + activeSkillNames(agent, opts)..., ) // Resolve media:// refs: images→base64 data URLs, non-images→local paths in content @@ -1226,6 +1238,7 @@ func (al *AgentLoop) runLLMIteration( messages = agent.ContextBuilder.BuildMessages( newHistory, newSummary, "", nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName, + activeSkillNames(agent, opts)..., ) continue } @@ -1942,6 +1955,10 @@ func (al *AgentLoop) handleCommand( return "", false } + if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched { + return reply, handled + } + if al.cmdRegistry == nil { return "", false } @@ -1998,6 +2015,9 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return nil }, } + if agent != nil && agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } rt.ReloadConfig = func() error { if al.reloadFunc == nil { return fmt.Errorf("reload not configured") @@ -2057,6 +2077,146 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return rt } +func activeSkillNames(agent *AgentInstance, opts processOptions) []string { + var out []string + seen := make(map[string]struct{}) + + appendNames := func(names []string) { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + } + + if agent != nil { + appendNames(agent.SkillsFilter) + } + appendNames(opts.ForcedSkills) + + return out +} + +func (al *AgentLoop) applyExplicitSkillCommand( + raw string, + agent *AgentInstance, + opts *processOptions, +) (matched bool, handled bool, reply string) { + commandName, ok := commands.CommandName(raw) + if !ok || commandName != "use" { + return false, false, "" + } + + if agent == nil || agent.ContextBuilder == nil { + return true, true, commandsUnavailableSkillMessage() + } + + fields := strings.Fields(strings.TrimSpace(raw)) + if len(fields) < 2 { + return true, true, buildUseCommandHelp(agent) + } + + if strings.EqualFold(fields[1], "clear") || strings.EqualFold(fields[1], "off") { + al.clearPendingSkills(opts.SessionKey) + return true, true, "Cleared pending skill override." + } + + canonicalSkill, ok := agent.ContextBuilder.ResolveSkillName(fields[1]) + if !ok { + return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", fields[1]) + } + + if len(fields) == 2 { + al.setPendingSkills(opts.SessionKey, []string{canonicalSkill}) + return true, true, fmt.Sprintf( + "Skill %q is armed for your next message.\nSend your next request normally, or use /use clear to cancel.", + canonicalSkill, + ) + } + + message := strings.TrimSpace(strings.Join(fields[2:], " ")) + if message == "" { + return true, true, buildUseCommandHelp(agent) + } + + opts.UserMessage = message + opts.ForcedSkills = append(opts.ForcedSkills, canonicalSkill) + return true, false, "" +} + +func commandsUnavailableSkillMessage() string { + return "Skill selection is unavailable in the current context." +} + +func buildUseCommandHelp(agent *AgentInstance) string { + if agent == nil || agent.ContextBuilder == nil { + return "Usage: /use [message]" + } + + names := agent.ContextBuilder.ListSkillNames() + if len(names) == 0 { + return "Usage: /use [message]\nNo installed skills found." + } + + return fmt.Sprintf( + "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.", + strings.Join(names, "\n- "), + ) +} + +func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" || len(skillNames) == 0 { + return + } + + filtered := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + name = strings.TrimSpace(name) + if name != "" { + filtered = append(filtered, name) + } + } + if len(filtered) == 0 { + return + } + + al.pendingSkills.Store(sessionKey, filtered) +} + +func (al *AgentLoop) takePendingSkills(sessionKey string) []string { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return nil + } + + value, ok := al.pendingSkills.LoadAndDelete(sessionKey) + if !ok { + return nil + } + + skills, ok := value.([]string) + if !ok { + return nil + } + + return append([]string(nil), skills...) +} + +func (al *AgentLoop) clearPendingSkills(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingSkills.Delete(sessionKey) +} + func mapCommandError(result commands.ExecuteResult) string { if result.Command == "" { return fmt.Sprintf("Failed to execute command: %v", result.Err) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 28eab03db..d6260c990 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -132,6 +132,163 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { } } +func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { + tmpDir := t.TempDir() + skillDir := filepath.Join(tmpDir, "skills", "shell") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("# shell\n\nPrefer concise shell commands and explain them briefly."), + 0o644, + ); err != nil { + t.Fatalf("write skill file: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use shell explain how to list files", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "# Active Skills") { + t.Fatalf("system prompt missing active skills section:\n%s", systemPrompt) + } + if !strings.Contains(systemPrompt, "### Skill: shell") { + t.Fatalf("system prompt missing requested skill content:\n%s", systemPrompt) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + t.Fatalf("last provider message = %+v, want rewritten user message", lastMessage) + } +} + +func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.GetRegistry().GetDefaultAgent() + + opts := processOptions{} + reply, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use missing explain how to list files", + }, agent, &opts) + if !handled { + t.Fatal("expected /use with unknown skill to be handled") + } + if !strings.Contains(reply, "Unknown skill: missing") { + t.Fatalf("reply = %q, want unknown skill error", reply) + } +} + +func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { + tmpDir := t.TempDir() + skillDir := filepath.Join(tmpDir, "skills", "shell") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("# shell\n\nPrefer concise shell commands and explain them briefly."), + 0o644, + ); err != nil { + t.Fatalf("write skill file: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/use shell", + }) + if err != nil { + t.Fatalf("processMessage() arm error = %v", err) + } + if !strings.Contains(response, `Skill "shell" is armed for your next message.`) { + t.Fatalf("arm response = %q, want armed confirmation", response) + } + + response, err = al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "explain how to list files", + }) + if err != nil { + t.Fatalf("processMessage() follow-up error = %v", err) + } + if response != "Mock response" { + t.Fatalf("follow-up response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "### Skill: shell") { + t.Fatalf("system prompt missing pending skill content:\n%s", systemPrompt) + } + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" { + t.Fatalf("last provider message = %+v, want unchanged follow-up user message", lastMessage) + } +} + func TestRecordLastChannel(t *testing.T) { al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) defer cleanup() diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 6d9ece82f..9be8fdf04 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition { helpCommand(), showCommand(), listCommand(), + useCommand(), switchCommand(), checkCommand(), clearCommand(), diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index 66a84825e..5fd8dd9bc 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -39,9 +39,14 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { if !strings.Contains(reply, "/show [model|channel|agents]") { t.Fatalf("/help reply missing /show usage, got %q", reply) } - if !strings.Contains(reply, "/list [models|channels|agents]") { + if !strings.Contains(reply, "/list [models|channels|agents|skills]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } + if !strings.Contains(reply, "/use ") { + if !strings.Contains(reply, "/use [message]") { + t.Fatalf("/help reply missing /use usage, got %q", reply) + } + } } func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { @@ -143,3 +148,43 @@ func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) { t.Fatalf("/list agents reply=%q, want agent IDs", reply) } } + +func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) { + rt := &Runtime{ + ListSkillNames: func() []string { + return []string{"shell", "git"} + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list skills", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list skills: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "shell") || !strings.Contains(reply, "git") { + t.Fatalf("/list skills reply=%q, want installed skill names", reply) + } +} + +func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), nil) + + res := ex.Execute(context.Background(), Request{ + Text: "/use shell run ls", + }) + if res.Outcome != OutcomePassthrough { + t.Fatalf("/use outcome=%v, want=%v", res.Outcome, OutcomePassthrough) + } + if res.Command != "use" { + t.Fatalf("/use command=%q, want=%q", res.Command, "use") + } +} diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go index bf47b6e9c..7186a6c25 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -47,6 +47,23 @@ func listCommand() Definition { Description: "Registered agents", Handler: agentsHandler(), }, + { + Name: "skills", + Description: "Installed skills", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListSkillNames == nil { + return req.Reply(unavailableMsg) + } + names := rt.ListSkillNames() + if len(names) == 0 { + return req.Reply("No installed skills") + } + return req.Reply(fmt.Sprintf( + "Installed Skills:\n- %s\n\nUse /use to force one for a single request, or /use to apply it to your next message.", + strings.Join(names, "\n- "), + )) + }, + }, }, } } diff --git a/pkg/commands/cmd_use.go b/pkg/commands/cmd_use.go new file mode 100644 index 000000000..4698f5f5e --- /dev/null +++ b/pkg/commands/cmd_use.go @@ -0,0 +1,9 @@ +package commands + +func useCommand() Definition { + return Definition{ + Name: "use", + Description: "Force a specific installed skill for one request", + Usage: "/use [message]", + } +} diff --git a/pkg/commands/request.go b/pkg/commands/request.go index 62ee600f2..233b3ef9c 100644 --- a/pkg/commands/request.go +++ b/pkg/commands/request.go @@ -41,6 +41,11 @@ func parseCommandName(input string) (string, bool) { return name, true } +// CommandName returns the normalized command name for an input if present. +func CommandName(input string) (string, bool) { + return parseCommandName(input) +} + func trimCommandPrefix(token string) (string, bool) { for _, prefix := range commandPrefixes { if strings.HasPrefix(token, prefix) { diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 84f775808..69403606e 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -10,6 +10,7 @@ type Runtime struct { GetModelInfo func() (name, provider string) ListAgentIDs func() []string ListDefinitions func() []Definition + ListSkillNames func() []string GetEnabledChannels func() []string SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error diff --git a/pkg/commands/show_list_handlers_test.go b/pkg/commands/show_list_handlers_test.go index 047708f0f..28d481b67 100644 --- a/pkg/commands/show_list_handlers_test.go +++ b/pkg/commands/show_list_handlers_test.go @@ -61,6 +61,9 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { GetEnabledChannels: func() []string { return []string{"telegram"} }, + ListSkillNames: func() []string { + return []string{"shell"} + }, } ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) @@ -82,4 +85,20 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { if !strings.Contains(reply, "telegram") { t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply) } + + reply = "" + res = ex.Execute(context.Background(), Request{ + Channel: "whatsapp", + Text: "/list skills", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("whatsapp /list skills outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "shell") { + t.Fatalf("whatsapp /list skills reply=%q, expected installed skills content", reply) + } }