Expose slash commands through skills
Delegate scoped command discovery to the Commands plugin and let skills_tool list or read effective slash-command definitions without invoking them.
Alessandro committed
Jul 28, 2026 at 11:33 UTC
63a52b3a4a42c0a247fcbea122ac56bafdcc0c3d
7 files changed
+193
-17
helpers/skills.py
+48
@@ -398,6 +398,54 @@ def list_skills(
398
return _filter_hidden_skills(agent, result)
399
400
401
+def list_slash_commands(agent: Agent | None = None) -> list[dict[str, Any]]:
402
+ """List effective, picker-visible slash commands for the agent's project."""
403
+ # Local import avoids the commands helper's import of split_frontmatter above.
404
+ from plugins._commands.helpers import commands as commands_helper
405
+
406
+ commands, _ = commands_helper.list_effective_commands(
407
+ _get_agent_project_name(agent)
408
+ )
409
+ return [
410
+ command
411
+ for command in commands
412
+ if not bool((command.get("frontmatter_extra") or {}).get("webui_hidden"))
413
+ ]
414
+
415
+
416
+def find_slash_command(
417
+ command_name: str,
418
+ agent: Agent | None = None,
419
+) -> dict[str, Any] | None:
420
+ """Find one effective slash command by its canonical ``/name``."""
421
+ target = str(command_name or "").strip().lstrip("/").lower()
422
+ if not target:
423
+ return None
424
+ return next(
425
+ (command for command in list_slash_commands(agent) if command["name"] == target),
426
+ None,
427
+ )
428
+
429
+
430
+def format_slash_command(command: dict[str, Any]) -> str:
431
+ """Render a slash command definition for a skills-tool result."""
432
+ lines = [f"Slash command: /{command['name']}"]
433
+ if description := str(command.get("description") or "").strip():
434
+ lines.append(f"Description: {description}")
435
+ if argument_hint := str(command.get("argument_hint") or "").strip():
436
+ lines.append(f"Arguments: {argument_hint}")
437
+ lines.append(f"Type: {command.get('command_type') or 'text'}")
438
+ if scope := str(command.get("scope_label") or "").strip():
439
+ lines.append(f"Scope: {scope}")
440
+
441
+ body = str(command.get("body") or "").strip()
442
+ if body:
443
+ if len(body) > 24000:
444
+ body = body[:24000].rstrip() + "\n\n[truncated]"
445
+ lines.extend(["", "Definition:", body])
446
+ return "\n".join(lines)
447
+
448
+
449
def delete_skill(
450
skill_path: str,
451
) -> None:
helpers/skills.py.dox.md
+4
@@ -27,6 +27,9 @@
27
- `parse_frontmatter(frontmatter_text: str) -> Tuple[Dict[str, Any], List[str]]`: Parse YAML frontmatter with PyYAML when available,
28
- `skill_from_markdown(skill_md_path: Path, include_content: bool=..., validate: bool=...) -> Optional[Skill]`
29
- `list_skills(agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> List[Skill]`: List skills, optionally filtered by agent scope.
30
+- `list_slash_commands(agent: Agent | None=...) -> list[dict[str, Any]]`: List picker-visible effective slash commands for the agent project.
31
+- `find_slash_command(command_name: str, agent: Agent | None=...) -> dict[str, Any] | None`
32
+- `format_slash_command(command: dict[str, Any]) -> str`
33
- `delete_skill(skill_path: str) -> None`: Delete a skill directory.
34
- `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=..., validate: bool=...) -> Optional[Skill]`
35
- `load_skill_for_agent(skill_name: str, agent: Agent | None=...) -> str`: Load skill and format it as a complete string for agent context.
@@ -60,6 +63,7 @@
63
- `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol.
64
- `search_skills()` normalizes query words, scores normal terms against skill names, and scores only long terms against tags/triggers; descriptions match only full query phrases so generic prose does not produce irrelevant suggestions.
65
- `find_skill(validate=False)` lets validation tooling resolve a skill with incomplete metadata while preserving runtime validation by default.
66
+- Slash command discovery is delegated to the built-in `_commands` helper through a local import, preserving its project/global/bundled/plugin precedence and avoiding its `split_frontmatter` import cycle. Picker-hidden commands remain hidden from Skills; reading a command returns its definition without executing it.
67
- Invalid `SKILL.md` frontmatter emits a once-per-path scan warning with the skipped skill path/name and a line number when the parser can identify one directly.
68
- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
69
- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
prompts/agent.system.tool.skills.md
+1
-1
@@ -5,7 +5,7 @@ common args: action skill_name query file_path
5
workflow:
6
- action `search`: find candidate skills by keywords or trigger phrases from the current task
7
- action `list`: discover available skills
8
-- action `load`: append one skill's full instructions to chat history by `skill_name`
8
+- action `load`: append one skill's full instructions to chat history by `skill_name`; use `skill_name=/command` to read an effective slash-command definition without invoking it
9
- action `read_file`: open one file inside a loaded skill directory
10
if the user says "find/search a skill", call `search` before `load` even when the likely skill name seems obvious
11
`read_file` requires both `skill_name` and `file_path`; load the skill first, then read `SKILL.md` or the named relative file
tests/test_skills_runtime.py
+57
@@ -129,6 +129,63 @@ def test_active_skills_cap_is_twenty():
129
assert runtime.get_max_active_skills() == 20
130
131
132
+def test_slash_commands_use_agent_scope_and_hide_picker_hidden(monkeypatch):
133
+ plugins_pkg = types.ModuleType("plugins")
134
+ plugins_pkg.__path__ = []
135
+ commands_plugin_pkg = types.ModuleType("plugins._commands")
136
+ commands_plugin_pkg.__path__ = []
137
+ commands_helpers_pkg = types.ModuleType("plugins._commands.helpers")
138
+ commands_helpers_pkg.__path__ = []
139
+ commands = types.ModuleType("plugins._commands.helpers.commands")
140
+ calls = []
141
+ commands.list_effective_commands = lambda project_name: (
142
+ [
143
+ {
144
+ "name": "visible",
145
+ "description": "Visible command.",
146
+ "argument_hint": "<text>",
147
+ "command_type": "text",
148
+ "scope_label": "Project",
149
+ "body": "Template {text}",
150
+ "frontmatter_extra": {},
151
+ },
152
+ {
153
+ "name": "hidden",
154
+ "frontmatter_extra": {"webui_hidden": True},
155
+ },
156
+ ],
157
+ {"project_name": project_name},
158
+ )
159
+ commands_helpers_pkg.commands = commands
160
+ for name, module in (
161
+ ("plugins", plugins_pkg),
162
+ ("plugins._commands", commands_plugin_pkg),
163
+ ("plugins._commands.helpers", commands_helpers_pkg),
164
+ ("plugins._commands.helpers.commands", commands),
165
+ ):
166
+ monkeypatch.setitem(sys.modules, name, module)
167
+ monkeypatch.setattr(
168
+ runtime,
169
+ "_get_agent_project_name",
170
+ lambda _agent: calls.append("project") or "project",
171
+ )
172
+
173
+ command = runtime.find_slash_command("/visible", DummyAgent())
174
+
175
+ assert calls == ["project"]
176
+ assert command["name"] == "visible"
177
+ assert runtime.find_slash_command("/hidden", DummyAgent()) is None
178
+ assert runtime.format_slash_command(command) == (
179
+ "Slash command: /visible\n"
180
+ "Description: Visible command.\n"
181
+ "Arguments: <text>\n"
182
+ "Type: text\n"
183
+ "Scope: Project\n\n"
184
+ "Definition:\n"
185
+ "Template {text}"
186
+ )
187
+
188
+
189
def test_skills_config_can_raise_active_cap_above_default():
190
config = runtime.normalize_skills_config(
191
{
tests/test_tool_action_contracts.py
+36
@@ -130,6 +130,20 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
130
tags=[],
131
)
132
skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
133
+ fake_command = {
134
+ "name": "summarize",
135
+ "description": "Summarize the current work.",
136
+ "argument_hint": "[focus]",
137
+ }
138
+ skills_stub.list_slash_commands = lambda *args, **kwargs: [fake_command]
139
+ skills_stub.find_slash_command = (
140
+ lambda command_name, *args, **kwargs: (
141
+ fake_command if command_name == "/summarize" else None
142
+ )
143
+ )
144
+ skills_stub.format_slash_command = (
145
+ lambda command: f"Slash command: /{command['name']}\nDefinition: prompt"
146
+ )
147
skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
148
skills_stub.find_skill = lambda *args, **kwargs: fake_skill
149
skills_stub.load_skill_for_agent = (
@@ -336,6 +350,28 @@ def test_skills_tool_defaults_missing_action_to_list(monkeypatch, tmp_path: Path
350
351
assert "Available skills" in response.message
352
assert "browser-form-workflows" in response.message
353
+ assert "Available slash commands" in response.message
354
+ assert "/summarize [focus]" in response.message
355
+
356
+
357
+def test_skills_tool_load_reads_slash_command_without_loading_a_skill(
358
+ monkeypatch, tmp_path: Path
359
+):
360
+ module = _load_skills_tool(monkeypatch, tmp_path)
361
+ agent = _FakeAgent()
362
+ tool = module.SkillsTool(
363
+ agent,
364
+ "skills_tool",
365
+ None,
366
+ {"action": "load", "skill_name": "/summarize"},
367
+ "",
368
+ None,
369
+ )
370
+
371
+ response = asyncio.run(tool.execute(**tool.args))
372
+
373
+ assert response.message == "Slash command: /summarize\nDefinition: prompt"
374
+ assert agent.context.get_data("loaded_skills") is None
375
376
377
def test_skills_tool_load_appends_skill_instructions_as_tool_result(
tools/skills_tool.py
+45
-15
@@ -18,7 +18,7 @@ class SkillsTool(Tool):
18
Actions (tool_args.action):
19
- list
20
- search (query)
21
- - load (skill_name)
21
+ - load (skill_name, or /command)
22
- read_file (skill_name, file_path)
23
24
Script execution is handled by code_execution_tool directly.
@@ -144,23 +144,38 @@ class SkillsTool(Tool):
144
agent=self.agent,
145
include_content=False,
146
)
147
- if not skills:
148
- return "No skills found."
149
-
150
- # Stable output: sort by name
151
- skills_sorted = sorted(skills, key=lambda s: s.name.lower())
147
+ commands = skills_helper.list_slash_commands(agent=self.agent)
148
+ if not skills and not commands:
149
+ return "No skills or slash commands found."
150
151
lines: List[str] = []
154
- lines.append(f"Available skills ({len(skills_sorted)}):")
155
- for s in skills_sorted:
156
- tags = f" tags={','.join(s.tags)}" if s.tags else ""
157
- ver = f" v{s.version}" if s.version else ""
158
- desc = (s.description or "").strip()
159
- if len(desc) > 200:
160
- desc = desc[:200].rstrip() + "…"
161
- lines.append(f"- {s.name}{ver}{tags}: {desc}")
152
+ if skills:
153
+ # Stable output: sort by name
154
+ skills_sorted = sorted(skills, key=lambda s: s.name.lower())
155
+ lines.append(f"Available skills ({len(skills_sorted)}):")
156
+ for s in skills_sorted:
157
+ tags = f" tags={','.join(s.tags)}" if s.tags else ""
158
+ ver = f" v{s.version}" if s.version else ""
159
+ desc = (s.description or "").strip()
160
+ if len(desc) > 200:
161
+ desc = desc[:200].rstrip() + "..."
162
+ lines.append(f"- {s.name}{ver}{tags}: {desc}")
163
+
164
+ if commands:
165
+ if lines:
166
+ lines.append("")
167
+ lines.append(f"Available slash commands ({len(commands)}):")
168
+ for command in commands:
169
+ arguments = str(command.get("argument_hint") or "").strip()
170
+ desc = str(command.get("description") or "").strip()
171
+ if len(desc) > 200:
172
+ desc = desc[:200].rstrip() + "..."
173
+ suffix = f" {arguments}" if arguments else ""
174
+ lines.append(f"- /{command['name']}{suffix}: {desc}")
175
lines.append("")
163
- lines.append("Tip: use skills_tool action=search or action=load for details.")
176
+ lines.append(
177
+ "Tip: use skills_tool action=search for skills or action=load skill_name=/name to read a slash command."
178
+ )
179
return "\n".join(lines)
180
181
def _search(self, query: str) -> str:
@@ -197,6 +212,21 @@ class SkillsTool(Tool):
212
break_loop=False,
213
)
214
215
+ if skill_name.startswith("/"):
216
+ command = skills_helper.find_slash_command(skill_name, agent=self.agent)
217
+ if not command:
218
+ return Response(
219
+ message=(
220
+ f"Error: slash command not found: {skill_name!r}. "
221
+ "Try skills_tool action=list."
222
+ ),
223
+ break_loop=False,
224
+ )
225
+ return Response(
226
+ message=skills_helper.format_slash_command(command),
227
+ break_loop=False,
228
+ )
229
+
230
# Verify skill exists
231
skill = skills_helper.find_skill(
232
skill_name,
tools/skills_tool.py.dox.md
+2
-1
@@ -3,7 +3,7 @@
3
## Purpose
4
5
- Own the `skills_tool.py` agent tool.
6
-- This module searches, loads, and lists Agent Zero skills for the agent.
6
+- This module searches, loads, and lists Agent Zero skills and exposes effective slash-command definitions for the agent.
7
- Keep this file-level DOX profile synchronized with `skills_tool.py` because this directory is intentionally flat.
8
9
## Ownership
@@ -30,6 +30,7 @@
30
- Loaded skill IDs are stored in chat-wide context data.
31
- Duplicate loads omit the full body when the same skill name remains visible in model history.
32
- Missing or empty `action` defaults to `list`, and legacy `method` is accepted as a deprecated alias when `action` is absent.
33
+- `list` includes picker-visible slash commands for the active project. `load` with `skill_name=/name` reads the effective command definition without executing it or adding it to the loaded-skill ledger.
34
- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence, chat history persistence.
35
- Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
36