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