Merge pull request #1893 from afjcjsbx/feat/skill-channel-commands

feat(skills): add channel commands to list and force installed skills
This commit is contained in:
Mauro 2026-03-23 09:04:06 +01:00 committed by GitHub
commit 054b55fdfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 577 additions and 5 deletions

View file

@ -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 3. Obtain the HTTP API Token
4. Fill in the Token in the configuration file 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`) 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 <skill> <message>` forces a skill for a single request.
- `/use <skill>` 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
```

View file

@ -33,3 +33,23 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
3. 获取 HTTP API Token 3. 获取 HTTP API Token
4. 将 Token 填入配置文件中 4. 将 Token 填入配置文件中
5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID) 5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
## 内置命令
Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start``/help``/show``/list``/use`
与技能相关的命令:
- `/list skills`:列出当前 Agent 可见的已安装技能。
- `/use <skill> <message>`:只在本次请求中强制使用指定技能。
- `/use <skill>`:为同一聊天中的下一条消息预先启用该技能。
- `/use clear`:清除待应用的技能覆盖。
示例:
```text
/list skills
/use git explain how to squash the last 3 commits
/use italiapersonalfinance
dammi le ultime news
```

View file

@ -61,11 +61,18 @@ picoclaw gateway
**4. Telegram command menu (auto-registered at startup)** **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. 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. 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 <skill> <message>`
- `/use <skill>` and then send the actual request in the next message
- `/use clear`
**4. Advanced Formatting** **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. 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.

View file

@ -65,6 +65,24 @@ For advanced/test setups, you can override the builtin skills root with:
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills 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 <skill> <message>` forces a specific skill for a single request.
- `/use <skill>` arms that skill for your next message in the same chat session.
- `/use clear` cancels a pending skill override created by `/use <skill>`.
Examples:
```text
/list skills
/use git explain how to squash the last 3 commits
/use italiapersonalfinance
dammi le ultime news
```
### Unified Command Execution Policy ### Unified Command Execution Policy
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. - Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.

View file

@ -64,11 +64,18 @@ picoclaw gateway
**4. Telegram 命令菜单(启动时自动注册)** **4. Telegram 命令菜单(启动时自动注册)**
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list``/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
你也可以直接在 Telegram 中管理已安装技能:
- `/list skills`
- `/use <skill> <message>`
- `/use <skill>`,然后在下一条消息里发送真正的请求
- `/use clear`
</details> </details>
<a id="discord"></a> <a id="discord"></a>

View file

@ -65,6 +65,24 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
``` ```
### 在聊天频道中使用技能
技能安装完成后,可以直接在聊天频道里查看并显式启用它们:
- `/list skills`:显示当前 Agent 可用的已安装技能名称。
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
示例:
```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` 统一执行。 - 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。

View file

@ -509,6 +509,7 @@ func (cb *ContextBuilder) BuildMessages(
currentMessage string, currentMessage string,
media []string, media []string,
channel, chatID, senderID, senderDisplayName string, channel, chatID, senderID, senderDisplayName string,
activeSkills ...string,
) []providers.Message { ) []providers.Message {
messages := []providers.Message{} messages := []providers.Message{}
@ -542,6 +543,11 @@ func (cb *ContextBuilder) BuildMessages(
{Type: "text", Text: dynamicCtx}, {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 != "" { if summary != "" {
summaryText := fmt.Sprintf( summaryText := fmt.Sprintf(
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
@ -749,6 +755,68 @@ func (cb *ContextBuilder) AddAssistantMessage(
return messages 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. // GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any { func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills() allSkills := cb.skillsLoader.ListSkills()

View file

@ -56,6 +56,7 @@ type AgentLoop struct {
mcp mcpRuntime mcp mcpRuntime
hookRuntime hookRuntime hookRuntime hookRuntime
steering *steeringQueue steering *steeringQueue
pendingSkills sync.Map
mu sync.RWMutex mu sync.RWMutex
// Concurrent turn management (from HEAD) // Concurrent turn management (from HEAD)
@ -77,6 +78,7 @@ type processOptions struct {
SenderID string // Current sender ID for dynamic context SenderID string // Current sender ID for dynamic context
SenderDisplayName string // Current sender display name for dynamic context SenderDisplayName string // Current sender display name for dynamic context
UserMessage string // User message content (may include prefix) UserMessage string // User message content (may include prefix)
ForcedSkills []string // Skills explicitly requested for this message
SystemPromptOverride string // Override the default system prompt (Used by SubTurns) SystemPromptOverride string // Override the default system prompt (Used by SubTurns)
Media []string // media:// refs from inbound message Media []string // media:// refs from inbound message
InitialSteeringMessages []providers.Message // Steering messages from refactor/agent InitialSteeringMessages []providers.Message // Steering messages from refactor/agent
@ -1328,6 +1330,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return response, nil 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) return al.runAgentLoop(ctx, agent, opts)
} }
@ -1621,6 +1632,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
ts.chatID, ts.chatID,
ts.opts.SenderID, ts.opts.SenderID,
ts.opts.SenderDisplayName, ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
) )
cfg := al.GetConfig() cfg := al.GetConfig()
@ -1650,6 +1662,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
newHistory, newSummary, ts.userMessage, newHistory, newSummary, ts.userMessage,
ts.media, ts.channel, ts.chatID, ts.media, ts.channel, ts.chatID,
ts.opts.SenderID, ts.opts.SenderDisplayName, ts.opts.SenderID, ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
) )
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
} }
@ -2014,8 +2027,8 @@ turnLoop:
newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey)
messages = ts.agent.ContextBuilder.BuildMessages( messages = ts.agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "", newHistory, newSummary, "",
nil, ts.channel, ts.chatID, nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName,
"", "", // Empty SenderID and SenderDisplayName for retry activeSkillNames(ts.agent, ts.opts)...,
) )
callMessages = messages callMessages = messages
if gracefulTerminal { if gracefulTerminal {
@ -3095,6 +3108,10 @@ func (al *AgentLoop) handleCommand(
return "", false return "", false
} }
if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched {
return reply, handled
}
if al.cmdRegistry == nil { if al.cmdRegistry == nil {
return "", false return "", false
} }
@ -3158,6 +3175,9 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return nil return nil
}, },
} }
if agent != nil && agent.ContextBuilder != nil {
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
}
rt.ReloadConfig = func() error { rt.ReloadConfig = func() error {
if al.reloadFunc == nil { if al.reloadFunc == nil {
return fmt.Errorf("reload not configured") return fmt.Errorf("reload not configured")
@ -3217,6 +3237,146 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return rt 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 <skill> [message]"
}
names := agent.ContextBuilder.ListSkillNames()
if len(names) == 0 {
return "Usage: /use <skill> [message]\nNo installed skills found."
}
return fmt.Sprintf(
"Usage: /use <skill> [message]\n\nInstalled Skills:\n- %s\n\nUse /use <skill> to apply a skill to your next message, or /use <skill> <message> 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 { func mapCommandError(result commands.ExecuteResult) string {
if result.Command == "" { if result.Command == "" {
return fmt.Sprintf("Failed to execute command: %v", result.Err) return fmt.Sprintf("Failed to execute command: %v", result.Err)

View file

@ -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) { func TestRecordLastChannel(t *testing.T) {
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
defer cleanup() defer cleanup()

View file

@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition {
helpCommand(), helpCommand(),
showCommand(), showCommand(),
listCommand(), listCommand(),
useCommand(),
switchCommand(), switchCommand(),
checkCommand(), checkCommand(),
clearCommand(), clearCommand(),

View file

@ -39,9 +39,14 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
if !strings.Contains(reply, "/show [model|channel|agents]") { if !strings.Contains(reply, "/show [model|channel|agents]") {
t.Fatalf("/help reply missing /show usage, got %q", reply) 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) t.Fatalf("/help reply missing /list usage, got %q", reply)
} }
if !strings.Contains(reply, "/use <skill> <message>") {
if !strings.Contains(reply, "/use <skill> [message]") {
t.Fatalf("/help reply missing /use usage, got %q", reply)
}
}
} }
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { 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) 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")
}
}

View file

@ -47,6 +47,23 @@ func listCommand() Definition {
Description: "Registered agents", Description: "Registered agents",
Handler: agentsHandler(), 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 <skill> <message> to force one for a single request, or /use <skill> to apply it to your next message.",
strings.Join(names, "\n- "),
))
},
},
}, },
} }
} }

9
pkg/commands/cmd_use.go Normal file
View file

@ -0,0 +1,9 @@
package commands
func useCommand() Definition {
return Definition{
Name: "use",
Description: "Force a specific installed skill for one request",
Usage: "/use <skill> [message]",
}
}

View file

@ -41,6 +41,11 @@ func parseCommandName(input string) (string, bool) {
return name, true 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) { func trimCommandPrefix(token string) (string, bool) {
for _, prefix := range commandPrefixes { for _, prefix := range commandPrefixes {
if strings.HasPrefix(token, prefix) { if strings.HasPrefix(token, prefix) {

View file

@ -10,6 +10,7 @@ type Runtime struct {
GetModelInfo func() (name, provider string) GetModelInfo func() (name, provider string)
ListAgentIDs func() []string ListAgentIDs func() []string
ListDefinitions func() []Definition ListDefinitions func() []Definition
ListSkillNames func() []string
GetEnabledChannels func() []string GetEnabledChannels func() []string
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
SwitchModel func(value string) (oldModel string, err error) SwitchModel func(value string) (oldModel string, err error)

View file

@ -61,6 +61,9 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
GetEnabledChannels: func() []string { GetEnabledChannels: func() []string {
return []string{"telegram"} return []string{"telegram"}
}, },
ListSkillNames: func() []string {
return []string{"shell"}
},
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
@ -82,4 +85,20 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
if !strings.Contains(reply, "telegram") { if !strings.Contains(reply, "telegram") {
t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply) 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)
}
} }