Add built-in slash commands plugin

Introduce the _commands plugin with command storage, picker UI, bundled canonical command pack, and legacy migration into the built-in namespace. Polish the Web UI picker behavior, hide WebUI-only-inappropriate commands from the popover, make /models always open model configuration, and add regression coverage for command CRUD, plugin discovery, migration, canonical names, hidden commands, and picker flows.

Alessandro committed Jul 9, 2026 at 16:00 UTC 9c9a4e00ca8483295ae44309dd1c571671a951c0
46 files changed +4717
plugins/AGENTS.md
+1
@@ -70,6 +70,7 @@ Direct child DOX files:
70 | [_browser/AGENTS.md](_browser/AGENTS.md) | Playwright browser tool, helpers, viewer, and browser panel UI. |
71 | [_chat_branching/AGENTS.md](_chat_branching/AGENTS.md) | Chat branching from an existing message. |
72 | [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. |
73 +| [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. |
74 | [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. |
75 | [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. |
76 | [_discovery/AGENTS.md](_discovery/AGENTS.md) | Welcome-screen plugin discovery cards and promotions. |
plugins/_commands/AGENTS.md new
+44
@@ -0,0 +1,44 @@
1 +# Commands Plugin DOX
2 +
3 +## Purpose
4 +
5 +- Own the built-in slash command manager and chat composer slash picker.
6 +- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
7 +
8 +## Ownership
9 +
10 +- `plugin.yaml` owns the built-in `_commands` plugin metadata.
11 +- `helpers/commands.py` owns command name sanitization, argument parsing, scope resolution, file persistence, plugin command discovery, and command invocation resolution.
12 +- `api/commands.py` owns the Commands API actions used by the WebUI.
13 +- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
14 +- `commands/` owns bundled read-only slash command definitions shipped by `_commands`.
15 +- `extensions/` owns the chat composer picker and sidebar quick-action entry.
16 +- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
17 +- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
18 +- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
19 +
20 +## Local Contracts
21 +
22 +- The plugin identity is `_commands`; user-created command files live under `usr/plugins/_commands/commands/` or `usr/projects/<project>/.a0proj/plugins/_commands/commands/`.
23 +- Each command is one `.command.yaml` config plus one same-directory `.txt` text template or `.py` script hook.
24 +- Project commands override global commands, global commands override bundled `_commands/commands/` defaults, and bundled defaults override other plugin-distributed commands with the same name.
25 +- Bundled `_commands/commands/` definitions and commands contributed by other plugins are read-only from this manager.
26 +- Bundled command files use canonical command names only; do not ship alias-only built-ins such as `/img` for `/attach`.
27 +- Command configs may set `webui_hidden: true` to stay resolvable but be omitted from the chat composer picker.
28 +- Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
29 +- On startup, `_commands` copies legacy `usr/plugins/commands` command and skill files into `usr/plugins/_commands` without overwriting existing files, copies scoped legacy command folders to `_commands`, and disables the legacy `commands` plugin roots to prevent duplicate WebUI popovers.
30 +- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
31 +
32 +## Work Guidance
33 +
34 +- Keep the command storage and route namespace aligned with `_commands`.
35 +- Preserve unknown command config keys when editing commands.
36 +- Keep WebUI paths pointed at `/plugins/_commands/...`.
37 +
38 +## Verification
39 +
40 +- Run `conda run -n a0 pytest plugins/_commands/tests` after backend or command contract changes.
41 +
42 +## Child DOX Index
43 +
44 +No child DOX files.
plugins/_commands/LICENSE new
+21
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Commands plugin contributors
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
plugins/_commands/README.md new
+146
@@ -0,0 +1,146 @@
1 +# Commands
2 +
3 +YAML-configured slash commands for Agent Zero.
4 +
5 +This plugin lets you define reusable `/commands` as `.command.yaml` files with either:
6 +
7 +- a `.txt` template body
8 +- a `.py` script hook
9 +
10 +Commands are managed from the plugin modal and can be inserted directly from the chat composer when the first token starts with `/`.
11 +
12 +## Features
13 +
14 +- `.command.yaml` config files with command metadata
15 +- Text template commands with `{}` placeholders and parsed args
16 +- Python hook commands with parsed args and optional chat history payload
17 +- Unified parser for positional args, free-form tail, and flags
18 +- Scope-aware command resolution across project and global scopes
19 +- Built-in A0 CLI connector command pack for common session, queue, model, project, browser, and connector status commands
20 +- Slash picker in the chat composer with keyboard navigation and create-on-empty flow
21 +
22 +## Command File Model
23 +
24 +Each command is defined by one config file plus one content file in the same scope directory.
25 +Set `webui_hidden: true` to keep a command resolvable while omitting it from the chat composer picker.
26 +
27 +Example text command:
28 +
29 +`scan.command.yaml`
30 +
31 +```yaml
32 +name: scan
33 +description: Scan a Git repository.
34 +argument_hint: /scan --git-url https://github.com/org/repo
35 +type: text
36 +template_path: scan.txt
37 +```
38 +
39 +`scan.txt`
40 +
41 +```txt
42 +Please scan repository: {args.flags.git_url}
43 +
44 +Raw input:
45 +{raw}
46 +```
47 +
48 +Example python hook command:
49 +
50 +`optimize.command.yaml`
51 +
52 +```yaml
53 +name: optimize
54 +description: Optimize the current request.
55 +argument_hint: /optimize 30%
56 +type: script
57 +script_path: optimize.py
58 +include_history: true
59 +```
60 +
61 +`optimize.py`
62 +
63 +```python
64 +def run(payload):
65 + args = payload["arguments"]
66 + pct = args["positional"][0] if args["positional"] else "10%"
67 + return {
68 + "text": f"Optimize this response by {pct}.",
69 + "effects": [],
70 + }
71 +```
72 +
73 +## Argument Parsing
74 +
75 +The parser supports:
76 +
77 +- Positional input: `/scan https://github.com/org/repo`
78 +- Long flags: `/scan --git-url https://github.com/org/repo`
79 +- Long flags with equals: `/scan --git-url=https://github.com/org/repo`
80 +- Short flags and bundles: `/scan -v -q` or `/scan -vq`
81 +
82 +Parsed data is available to:
83 +
84 +- Text templates via `{}` placeholders:
85 + - `{raw}`
86 + - `{args.positional.0}`
87 + - `{args.flags.git_url}`
88 +- Python scripts via `payload["arguments"]`
89 +
90 +## Script Hook Contract
91 +
92 +Python hook file must expose:
93 +
94 +```python
95 +def run(payload): ...
96 +```
97 +
98 +It can return:
99 +
100 +- `str` (used as replacement text)
101 +- `dict` with:
102 + - `text: str` (replacement text)
103 + - `effects: list[dict]`
104 +
105 +Supported frontend effects:
106 +
107 +- `{"type": "replace_input", "text": "..."}`
108 +- `{"type": "append_input", "text": "..."}`
109 +- `{"type": "toast", "level": "info|error|success", "message": "..."}`
110 +- Built-in UI effects for existing WebUI actions such as chat switching, modals, attachments, compaction, queue actions, transcript copy, and toast output
111 +
112 +## Scope Resolution
113 +
114 +Commands are discovered from these scope folders:
115 +
116 +- Project: `usr/projects/<project>/.a0proj/plugins/_commands/commands/`
117 +- Global fallback: `usr/plugins/_commands/commands/`
118 +- Built-in defaults: `plugins/_commands/commands/`
119 +- Other enabled plugins: `plugins/<plugin>/commands/` or `usr/plugins/<plugin>/commands/`
120 +
121 +Precedence in the chat picker:
122 +
123 +1. Project
124 +2. Global
125 +3. Built-in `_commands`
126 +4. Other plugin-distributed commands
127 +
128 +## Legacy Community Plugin Migration
129 +
130 +When the built-in `_commands` plugin starts, it migrates files from the older community `commands` plugin namespace:
131 +
132 +- Copies `usr/plugins/commands/commands/` into `usr/plugins/_commands/commands/`
133 +- Copies `usr/plugins/commands/skills/` into `usr/plugins/_commands/skills/`
134 +- Copies project and agent scoped `plugins/commands/commands/` folders to matching `plugins/_commands/commands/` folders
135 +- Skips existing destination files
136 +- Disables the legacy `commands` plugin roots so the WebUI does not load two slash-command popovers
137 +
138 +## UI Surfaces
139 +
140 +- Plugin modal: open the Commands manager from the Plugins dialog
141 +- Sidebar quick action: terminal icon next to the Plugins button
142 +- Chat composer: type `/` at the start of the inline input to browse commands
143 +
144 +## Agent Skill
145 +
146 +The plugin ships with `commands-create-slash-command`, a plugin-scoped skill that helps Agent Zero create or update command files.
plugins/_commands/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""Commands plugin package."""
plugins/_commands/api/commands.py new
+165
@@ -0,0 +1,165 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +
5 +from plugins._commands.helpers import commands as commands_helper
6 +
7 +
8 +class Commands(ApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + action = str(input.get("action", "") or "").strip()
11 +
12 + if action == "list_effective":
13 + return self._list_effective(input)
14 + if action == "list_scope":
15 + return self._list_scope(input)
16 + if action == "get":
17 + return self._get(input)
18 + if action == "save":
19 + return self._save(input)
20 + if action == "delete":
21 + return self._delete(input)
22 + if action == "duplicate":
23 + return self._duplicate(input)
24 + if action == "scope_info":
25 + return self._scope_info(input)
26 + if action == "resolve":
27 + return await self._resolve(input)
28 +
29 + return Response(status=400, response=f"Unknown action: {action}")
30 +
31 + def _list_effective(self, input: dict) -> dict | Response:
32 + context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or ""))
33 + commands, scope = commands_helper.list_effective_commands(
34 + project_name=context_scope["project_name"],
35 + )
36 + commands = [
37 + command
38 + for command in commands
39 + if not command.get("frontmatter_extra", {}).get("webui_hidden")
40 + ]
41 + return {
42 + "ok": True,
43 + "commands": commands,
44 + "scope": scope,
45 + }
46 +
47 + def _list_scope(self, input: dict) -> dict | Response:
48 + commands, scope = commands_helper.list_scope_commands(
49 + project_name=str(input.get("project_name", "") or ""),
50 + )
51 + return {
52 + "ok": True,
53 + "commands": commands,
54 + "scope": scope,
55 + }
56 +
57 + def _get(self, input: dict) -> dict | Response:
58 + path = str(input.get("path", "") or "")
59 + if not path:
60 + return Response(status=400, response="Missing path")
61 +
62 + try:
63 + command = commands_helper.get_command(
64 + path,
65 + project_name=str(input.get("project_name", "") or ""),
66 + )
67 + except FileNotFoundError:
68 + return Response(status=404, response="Command not found")
69 + except ValueError as error:
70 + return Response(status=400, response=str(error))
71 +
72 + return {"ok": True, "command": command}
73 +
74 + def _save(self, input: dict) -> dict | Response:
75 + try:
76 + command = commands_helper.save_command(
77 + project_name=str(input.get("project_name", "") or ""),
78 + existing_path=str(input.get("existing_path", "") or ""),
79 + name=str(input.get("name", "") or ""),
80 + description=str(input.get("description", "") or ""),
81 + argument_hint=str(input.get("argument_hint", "") or ""),
82 + command_type=str(input.get("command_type", "text") or "text"),
83 + body=str(input.get("body", "") or ""),
84 + include_history=bool(input.get("include_history", False)),
85 + extra_frontmatter=input.get("extra_frontmatter", {}) or {},
86 + )
87 + except FileExistsError as error:
88 + return Response(status=409, response=str(error))
89 + except ValueError as error:
90 + return Response(status=400, response=str(error))
91 +
92 + return {"ok": True, "command": command}
93 +
94 + def _delete(self, input: dict) -> dict | Response:
95 + path = str(input.get("path", "") or "")
96 + if not path:
97 + return Response(status=400, response="Missing path")
98 +
99 + try:
100 + commands_helper.delete_command(
101 + path,
102 + project_name=str(input.get("project_name", "") or ""),
103 + )
104 + except FileNotFoundError:
105 + return Response(status=404, response="Command not found")
106 + except ValueError as error:
107 + return Response(status=400, response=str(error))
108 +
109 + return {"ok": True}
110 +
111 + def _duplicate(self, input: dict) -> dict | Response:
112 + path = str(input.get("path", "") or "")
113 + if not path:
114 + return Response(status=400, response="Missing path")
115 +
116 + try:
117 + command = commands_helper.duplicate_command(
118 + path,
119 + project_name=str(input.get("project_name", "") or ""),
120 + )
121 + except FileNotFoundError:
122 + return Response(status=404, response="Command not found")
123 + except ValueError as error:
124 + return Response(status=400, response=str(error))
125 +
126 + return {"ok": True, "command": command}
127 +
128 + def _scope_info(self, input: dict) -> dict | Response:
129 + explicit_project = str(input.get("project_name", "") or "")
130 + context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or ""))
131 +
132 + project_name = explicit_project if "project_name" in input else context_scope["project_name"]
133 +
134 + scope = commands_helper.get_scope_payload(
135 + project_name=project_name,
136 + ensure_directory=bool(input.get("ensure_directory", False)),
137 + )
138 + return {
139 + "ok": True,
140 + "scope": commands_helper.strip_private_scope(scope),
141 + "context_scope": context_scope,
142 + }
143 +
144 + async def _resolve(self, input: dict) -> dict | Response:
145 + path = str(input.get("path", "") or "")
146 + if not path:
147 + return Response(status=400, response="Missing path")
148 +
149 + slash_text = str(input.get("slash_text", "") or "")
150 + if not slash_text:
151 + return Response(status=400, response="Missing slash_text")
152 +
153 + try:
154 + resolution = await commands_helper.resolve_command_invocation(
155 + path=path,
156 + slash_text=slash_text,
157 + project_name=str(input.get("project_name", "") or ""),
158 + context_id=str(input.get("context_id", "") or ""),
159 + )
160 + except FileNotFoundError:
161 + return Response(status=404, response="Command not found")
162 + except ValueError as error:
163 + return Response(status=400, response=str(error))
164 +
165 + return {"ok": True, "resolution": resolution}
plugins/_commands/commands/attach.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: attach
2 +description: Attach local image file(s) to the next message.
3 +argument_hint: Choose local file(s) from the WebUI picker.
4 +type: script
5 +script_path: connector_commands.py
plugins/_commands/commands/browser.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: browser
2 +description: Choose Browser host/container mode and manage host-browser control.
3 +argument_hint: "[host|container|status]"
4 +type: script
5 +script_path: connector_commands.py
plugins/_commands/commands/chat.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: chat
2 +description: Switch to a chat context by id.
3 +argument_hint: "<context_id>"
4 +type: script
5 +script_path: connector_commands.py
plugins/_commands/commands/chats.command.yaml new
+6
@@ -0,0 +1,6 @@
1 +name: chats
2 +description: List previous chats (default sorted by last updated). Use --project to filter by active project.
3 +argument_hint: "[--project|--all-projects] [--sort=updated|created|name]"
4 +type: script
5 +script_path: connector_commands.py
6 +webui_hidden: true
plugins/_commands/commands/clear.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: clear
2 +description: Clear the visible chat log.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/compact.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: compact
2 +description: Open the connector-backed compaction confirmation flow.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/computer-use.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: computer-use
2 +description: Turn local Computer Use on or off.
3 +argument_hint: "[on|off|status]"
4 +type: script
5 +script_path: connector_commands.py
plugins/_commands/commands/connector_commands.py new
+305
@@ -0,0 +1,305 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from agent import AgentContext
6 +from helpers import message_queue as mq
7 +from helpers import plugins, projects
8 +from helpers.integration_commands import try_handle_command
9 +from helpers.state_monitor_integration import mark_dirty_for_context
10 +
11 +CLI_ONLY = {
12 + "quit": "Quit is an A0 CLI shell command. Close this browser tab or stop the WebUI session when you are done.",
13 +}
14 +
15 +
16 +def run(payload: dict[str, Any]) -> dict[str, Any]:
17 + invocation = payload.get("invocation") or {}
18 + raw_name = str(invocation.get("command_name") or "").strip().lower()
19 + command = raw_name
20 + raw_args = str(invocation.get("raw_arguments") or "").strip()
21 + arguments = invocation.get("arguments") if isinstance(invocation.get("arguments"), dict) else {}
22 + context_id = str((payload.get("context") or {}).get("context_id") or "").strip()
23 + context = _context(context_id)
24 +
25 + if command == "new":
26 + return _effects(_toast("Created a new chat."), {"type": "new_chat"})
27 + if command == "chat":
28 + return _handle_chat(arguments)
29 + if command == "chats":
30 + return _show_markdown("Chats", _chat_list(context, arguments))
31 + if command == "clear":
32 + return _effects(_toast("Visible transcript cleared."), {"type": "clear_transcript"})
33 + if command == "project":
34 + return _handle_project(context, raw_args)
35 + if command == "profile":
36 + return _handle_profile(context, raw_args)
37 + if command == "plugins":
38 + return _effects({"type": "open_modal", "path": "/components/plugins/list/plugin-list.html"})
39 + if command == "compact":
40 + return _effects({"type": "compact_chat"})
41 + if command == "pause":
42 + return _effects(_toast("Pause requested."), {"type": "pause_agent", "paused": True})
43 + if command == "resume":
44 + return _effects(_toast("Resume requested."), {"type": "pause_agent", "paused": False})
45 + if command == "nudge":
46 + return _effects(_toast("Nudge sent."), {"type": "nudge_agent"})
47 + if command == "send":
48 + return _handle_queue(context, ["send"])
49 + if command == "queue":
50 + return _handle_queue(context, list(arguments.get("tokens") or []))
51 + if command == "presets":
52 + return _effects({"type": "open_modal", "path": "/plugins/_model_config/webui/main.html"})
53 + if command == "models":
54 + return _handle_models(context, raw_args)
55 + if command == "browser":
56 + return _handle_browser(context, raw_args)
57 + if command == "attach":
58 + return _effects({"type": "attach_files"})
59 + if command == "computer-use":
60 + return _show_markdown("Computer Use", _computer_use_status(context_id, raw_args))
61 + if command == "copy":
62 + return _effects({"type": "copy_transcript"})
63 + if command == "status":
64 + return _show_markdown("Status", _status(context))
65 + if command in CLI_ONLY:
66 + return _effects(_toast(CLI_ONLY[command], level="info"))
67 +
68 + return _effects(_toast(f"Unknown command: /{raw_name or command}", level="error"))
69 +
70 +
71 +def _context(context_id: str) -> AgentContext | None:
72 + if context_id:
73 + return AgentContext.get(context_id)
74 + return AgentContext.current() or AgentContext.first()
75 +
76 +
77 +def _require_context(context: AgentContext | None) -> str | None:
78 + if context:
79 + return None
80 + return "Open or create a chat context first."
81 +
82 +
83 +def _effects(*effects: dict[str, Any]) -> dict[str, Any]:
84 + return {"text": "", "effects": [effect for effect in effects if effect]}
85 +
86 +
87 +def _toast(message: str, *, level: str = "success") -> dict[str, Any]:
88 + return {"type": "toast", "message": message, "level": level}
89 +
90 +
91 +def _show_markdown(title: str, content: str) -> dict[str, Any]:
92 + return _effects({"type": "show_markdown", "title": title, "content": content})
93 +
94 +
95 +def _handle_chat(arguments: dict[str, Any]) -> dict[str, Any]:
96 + selector = str((arguments.get("positional") or [""])[0] or "").strip()
97 + if not selector:
98 + return _effects(_toast("Usage: /chat <context_id>", level="error"))
99 + if not AgentContext.get(selector):
100 + return _effects(_toast(f"Chat context '{selector}' was not found.", level="error"))
101 + return _effects(_toast(f"Switched to {selector}."), {"type": "select_chat", "context_id": selector})
102 +
103 +
104 +def _handle_project(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
105 + if not raw_args:
106 + return _effects({"type": "open_modal", "path": "/components/projects/project-list.html"})
107 + error = _require_context(context)
108 + if error:
109 + return _effects(_toast(error, level="error"))
110 + return _show_markdown("Project", try_handle_command(context, f"/project {raw_args}") or "")
111 +
112 +
113 +def _handle_profile(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
114 + if not raw_args:
115 + return _effects({"type": "open_modal", "path": "/components/settings/settings.html"})
116 + error = _require_context(context)
117 + if error:
118 + return _effects(_toast(error, level="error"))
119 + return _show_markdown("Agent Profile", try_handle_command(context, f"/agent {raw_args}") or "")
120 +
121 +
122 +def _handle_models(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
123 + return _effects({"type": "open_plugin_config", "plugin": "_model_config"})
124 +
125 +
126 +def _handle_browser(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
127 + args = raw_args.strip().lower().replace("-", "_").split()
128 + action = args[0] if args else ""
129 + if not action:
130 + return _effects({"type": "open_modal", "path": "/plugins/_browser/webui/main.html"})
131 + if action in {"status", "state"}:
132 + return _show_markdown("Browser", _browser_status(context))
133 + if action not in {"host", "container", "docker"}:
134 + return _effects(_toast("Usage: /browser [host|container|status]", level="error"))
135 +
136 + project_name = projects.get_context_project_name(context) if context else ""
137 + settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {}
138 + settings["runtime_backend"] = "host_required" if action == "host" else "container"
139 + plugins.save_plugin_config("_browser", project_name or "", "", settings)
140 + if context:
141 + mark_dirty_for_context(context.id, reason="plugins._commands.browser_runtime")
142 + label = "Host browser through A0 CLI" if settings["runtime_backend"] == "host_required" else "Internal Docker browser"
143 + return _effects(_toast(f"Browser runtime set to {label}."))
144 +
145 +
146 +def _browser_status(context: AgentContext | None) -> str:
147 + project_name = projects.get_context_project_name(context) if context else ""
148 + settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {}
149 + runtime = str(settings.get("runtime_backend") or "container")
150 + label = "Host browser through A0 CLI" if runtime == "host_required" else "Internal Docker browser"
151 + return f"Browser runtime: {label}\n\nUse `/browser host` or `/browser container` to switch."
152 +
153 +
154 +def _handle_queue(context: AgentContext | None, tokens: list[str]) -> dict[str, Any]:
155 + error = _require_context(context)
156 + if error:
157 + return _effects(_toast(error, level="error"))
158 +
159 + queue = mq.get_queue(context)
160 + if not tokens:
161 + return _show_markdown("Queue", _queue_summary(queue))
162 +
163 + action = str(tokens[0] or "").lower()
164 + if action in {"send", "all", "flush"}:
165 + if not queue:
166 + return _effects(_toast("No queued messages."))
167 + sent_count = mq.send_all_aggregated(context)
168 + mark_dirty_for_context(context.id, reason="plugins._commands.queue_send")
169 + noun = "message" if sent_count == 1 else "messages"
170 + return _effects(_toast(f"Sent {sent_count} queued {noun}."))
171 +
172 + if action in {"clear", "delete"} and len(tokens) == 1:
173 + mq.remove(context)
174 + mark_dirty_for_context(context.id, reason="plugins._commands.queue_clear")
175 + return _effects(_toast("Queue cleared."))
176 +
177 + if action in {"remove", "rm", "delete"}:
178 + if len(tokens) < 2:
179 + return _effects(_toast("Usage: /queue remove <number|id>", level="error"))
180 + item_id = _queue_selector_to_id(queue, str(tokens[1]))
181 + if not item_id:
182 + return _effects(_toast(f"No queued message matches '{tokens[1]}'.", level="error"))
183 + mq.remove(context, item_id)
184 + mark_dirty_for_context(context.id, reason="plugins._commands.queue_remove")
185 + return _effects(_toast("Queued message removed."))
186 +
187 + return _effects(_toast("Usage: /queue [send|clear|remove <number|id>]", level="error"))
188 +
189 +
190 +def _queue_summary(queue: list[dict[str, Any]]) -> str:
191 + if not queue:
192 + return "No queued messages."
193 + lines = [f"Queued messages ({len(queue)}):"]
194 + for index, item in enumerate(queue, start=1):
195 + text = str(item.get("text") or "").strip() or "(attachment only)"
196 + if len(text) > 100:
197 + text = text[:97].rstrip() + "..."
198 + attachments = item.get("attachments") or []
199 + suffix = f" [{len(attachments)} files]" if attachments else ""
200 + lines.append(f"{index}. {text}{suffix}")
201 + return "\n".join(lines)
202 +
203 +
204 +def _queue_selector_to_id(queue: list[dict[str, Any]], selector: str) -> str:
205 + value = selector.strip()
206 + if value.isdigit():
207 + index = int(value) - 1
208 + if 0 <= index < len(queue):
209 + return str(queue[index].get("id") or "")
210 + return ""
211 + return value
212 +
213 +
214 +def _chat_list(context: AgentContext | None, arguments: dict[str, Any]) -> str:
215 + items = list(AgentContext.all())
216 + flags = arguments.get("flags") or {}
217 + active_project_only = bool(flags.get("project") or flags.get("active_project") or flags.get("p"))
218 + sort_by = str(flags.get("sort") or "").lower()
219 + positional = [str(item).lower() for item in (arguments.get("positional") or [])]
220 + if not sort_by:
221 + sort_by = next((item for item in positional if item in {"updated", "created", "name"}), "updated")
222 + if sort_by not in {"updated", "created", "name"}:
223 + return "Usage: /chats [--project|--all-projects] [--sort=updated|created|name]"
224 +
225 + if active_project_only and context:
226 + project_name = projects.get_context_project_name(context) or ""
227 + items = [item for item in items if (projects.get_context_project_name(item) or "") == project_name]
228 +
229 + def sort_key(item: AgentContext) -> Any:
230 + output = item.output()
231 + if sort_by == "name":
232 + return (item.name or item.id).casefold()
233 + if sort_by == "created":
234 + return str(output.get("created_at") or "")
235 + return str(output.get("last_message") or output.get("created_at") or "")
236 +
237 + items = sorted(items, key=sort_key, reverse=sort_by != "name")
238 + if not items:
239 + return "No chats found."
240 +
241 + lines = ["| Chat | Context | State |", "| --- | --- | --- |"]
242 + for item in items[:30]:
243 + marker = "current" if context and item.id == context.id else ("running" if item.is_running() else "idle")
244 + lines.append(f"| {_escape_cell(item.name or item.id)} | `{item.id}` | {marker} |")
245 + if len(items) > 30:
246 + lines.append(f"\nShowing 30 of {len(items)} chats.")
247 + return "\n".join(lines)
248 +
249 +
250 +def _status(context: AgentContext | None) -> str:
251 + error = _require_context(context)
252 + if error:
253 + return error
254 + project_name = projects.get_context_project_name(context) or "none"
255 + profile = getattr(context.agent0.config, "profile", "default") if context.agent0 else "default"
256 + running = "running" if context.is_running() else "idle"
257 + if getattr(context, "paused", False):
258 + running = "paused"
259 + return "\n".join(
260 + [
261 + f"Context: `{context.id}`",
262 + f"State: {running}",
263 + f"Project: {project_name}",
264 + f"Agent profile: {profile}",
265 + f"Queued messages: {len(mq.get_queue(context))}",
266 + ]
267 + )
268 +
269 +
270 +def _computer_use_status(context_id: str, raw_args: str) -> str:
271 + from plugins._a0_connector.helpers import ws_runtime
272 +
273 + action = "-".join(part.strip().lower().replace("_", "-") for part in raw_args.split()) or "status"
274 + sids = ws_runtime.remote_tool_sids_for_context(context_id) if context_id else sorted(ws_runtime.connected_sids())
275 + if not sids:
276 + return (
277 + "No A0 CLI is connected to this WebUI session.\n\n"
278 + "Computer Use requires the CLI because the desktop permission prompt and native backend live on the CLI host. "
279 + "Start A0 CLI, connect it to this Agent Zero instance, then run `/computer-use on` in the CLI."
280 + )
281 +
282 + if action in {"on", "off", "enable", "disable", "enabled", "disabled", "true", "false", "yes", "no", "1", "0"}:
283 + return (
284 + "Computer Use must be armed from the connected A0 CLI because it controls local desktop permissions.\n\n"
285 + "Run `/computer-use on` or `/computer-use off` in the CLI terminal."
286 + )
287 +
288 + lines = ["Connected A0 CLI sessions:"]
289 + for sid in sids:
290 + metadata = ws_runtime.computer_use_metadata_for_sid(sid) or {}
291 + if not metadata:
292 + lines.append(f"- `{sid}`: connected, but not advertising Computer Use metadata.")
293 + continue
294 + state = "enabled" if metadata.get("enabled") else "disabled"
295 + supported = "supported" if metadata.get("supported") else "unsupported"
296 + status = str(metadata.get("status") or "unknown")
297 + detail = str(metadata.get("last_error") or metadata.get("support_reason") or "").strip()
298 + suffix = f" ({detail})" if detail else ""
299 + lines.append(f"- `{sid}`: {state}, {supported}, status: {status}{suffix}")
300 + lines.append("\nUse `/computer-use on|off|status` in the CLI to change local Computer Use.")
301 + return "\n".join(lines)
302 +
303 +
304 +def _escape_cell(value: str) -> str:
305 + return str(value).replace("|", "\\|")
plugins/_commands/commands/copy.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: copy
2 +description: Copy the currently visible transcript text to the clipboard.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/models.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: models
2 +description: Open Main/Utility model runtime editor.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/new.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: new
2 +description: Create a brand-new empty chat context.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/nudge.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: nudge
2 +description: Nudge the current agent run.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/pause.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: pause
2 +description: Pause the active agent run.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/plugins.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: plugins
2 +description: Open the installed-only Agent Zero plugin toggle view.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/presets.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: presets
2 +description: Open preset picker with Main/Utility model details.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/profile.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: profile
2 +description: Pick or set the active Agent Zero Core profile.
3 +argument_hint: "[profile]"
4 +type: script
5 +script_path: connector_commands.py
plugins/_commands/commands/project.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: project
2 +description: Open the project menu, or switch directly with /project <name>.
3 +argument_hint: "[name]"
4 +type: script
5 +script_path: connector_commands.py
plugins/_commands/commands/queue.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: queue
2 +description: Show, send, clear, or remove queued messages.
3 +argument_hint: "[send|clear|remove <number|id>]"
4 +type: script
5 +script_path: connector_commands.py
plugins/_commands/commands/quit.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: quit
2 +description: Disconnect and exit the CLI.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/resume.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: resume
2 +description: Resume a paused agent run.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/send.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: send
2 +description: Send all queued messages now.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/commands/status.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: status
2 +description: Show this chat's project, model, agent, and queue state.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/extensions/python/startup_migration/_20_migrate_legacy_commands.py new
+124
@@ -0,0 +1,124 @@
1 +from __future__ import annotations
2 +
3 +import shutil
4 +from pathlib import Path
5 +from typing import Any
6 +
7 +from helpers import cache, files, plugins
8 +from helpers.extension import Extension
9 +from helpers.print_style import PrintStyle
10 +
11 +
12 +LEGACY_PLUGIN_NAME = "commands"
13 +PLUGIN_NAME = "_commands"
14 +COMMANDS_DIR = "commands"
15 +SKILLS_DIR = "skills"
16 +
17 +
18 +class LegacyCommandsMigration(Extension):
19 + def execute(self, **kwargs):
20 + result = migrate_legacy_commands()
21 + if result["copied_commands"] or result["copied_skills"] or result["disabled_roots"]:
22 + PrintStyle.info("Migrated legacy commands plugin data:", result)
23 +
24 +
25 +def migrate_legacy_commands(base_dir: str | Path | None = None) -> dict[str, Any]:
26 + root = Path(base_dir or files.get_abs_path("")).resolve()
27 + result: dict[str, Any] = {
28 + "copied_commands": 0,
29 + "copied_skills": 0,
30 + "disabled_roots": 0,
31 + }
32 +
33 + legacy_plugin_dir = root / "usr" / "plugins" / LEGACY_PLUGIN_NAME
34 + if not legacy_plugin_dir.exists() and not _legacy_scoped_plugin_dirs(root):
35 + return result
36 +
37 + for legacy_commands_dir in _legacy_command_dirs(root):
38 + target = _replace_plugin_segment(legacy_commands_dir, PLUGIN_NAME)
39 + result["copied_commands"] += _copy_tree_files(legacy_commands_dir, target)
40 +
41 + result["copied_skills"] += _copy_tree_files(
42 + legacy_plugin_dir / SKILLS_DIR,
43 + root / "usr" / "plugins" / PLUGIN_NAME / SKILLS_DIR,
44 + )
45 +
46 + for legacy_plugin_root in _legacy_plugin_roots(root):
47 + if _disable_legacy_plugin_root(legacy_plugin_root):
48 + result["disabled_roots"] += 1
49 +
50 + if result["disabled_roots"]:
51 + _clear_runtime_caches()
52 +
53 + return result
54 +
55 +
56 +def _legacy_command_dirs(root: Path) -> list[Path]:
57 + return [
58 + plugin_root / COMMANDS_DIR
59 + for plugin_root in _legacy_plugin_roots(root)
60 + if (plugin_root / COMMANDS_DIR).is_dir()
61 + ]
62 +
63 +
64 +def _legacy_plugin_roots(root: Path) -> list[Path]:
65 + roots = []
66 + for candidate in [
67 + root / "usr" / "plugins" / LEGACY_PLUGIN_NAME,
68 + *root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
69 + *root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
70 + *root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
71 + ]:
72 + if candidate.exists() and candidate not in roots:
73 + roots.append(candidate)
74 + return roots
75 +
76 +
77 +def _legacy_scoped_plugin_dirs(root: Path) -> list[Path]:
78 + return [
79 + *root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
80 + *root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
81 + *root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
82 + ]
83 +
84 +
85 +def _replace_plugin_segment(path: Path, plugin_name: str) -> Path:
86 + parts = list(path.parts)
87 + for index in range(len(parts) - 1):
88 + if parts[index] == "plugins" and parts[index + 1] == LEGACY_PLUGIN_NAME:
89 + parts[index + 1] = plugin_name
90 + return Path(*parts)
91 + return path
92 +
93 +
94 +def _copy_tree_files(source: Path, target: Path) -> int:
95 + if not source.is_dir():
96 + return 0
97 +
98 + copied = 0
99 + for source_file in source.rglob("*"):
100 + if not source_file.is_file():
101 + continue
102 + relative_path = source_file.relative_to(source)
103 + target_file = target / relative_path
104 + if target_file.exists():
105 + continue
106 + target_file.parent.mkdir(parents=True, exist_ok=True)
107 + shutil.copy2(source_file, target_file)
108 + copied += 1
109 + return copied
110 +
111 +
112 +def _disable_legacy_plugin_root(plugin_root: Path) -> bool:
113 + plugin_root.mkdir(parents=True, exist_ok=True)
114 + enabled_file = plugin_root / plugins.ENABLED_FILE_NAME
115 + disabled_file = plugin_root / plugins.DISABLED_FILE_NAME
116 + changed = enabled_file.exists() or not disabled_file.exists()
117 + enabled_file.unlink(missing_ok=True)
118 + disabled_file.write_text("", encoding="utf-8")
119 + return changed
120 +
121 +
122 +def _clear_runtime_caches() -> None:
123 + cache.clear("*(plugins)*")
124 + cache.clear("*(extensions)*")
plugins/_commands/extensions/webui/chat-input-box-start/commands-menu.html new
+192
@@ -0,0 +1,192 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_commands/webui/commands-slash-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div x-data>
9 + <template x-if="$store.commandsSlash">
10 + <div class="commands-slash-menu-root" x-create="$store.commandsSlash.onMount()" x-destroy="$store.commandsSlash.cleanup()">
11 + <div class="commands-slash-menu" x-show="$store.commandsSlash.menuVisible" x-transition.opacity.duration.120ms>
12 + <template x-if="$store.commandsSlash.loading">
13 + <div class="commands-slash-loading">
14 + <span class="material-symbols-outlined spinning">progress_activity</span>
15 + <span>Loading slash commands...</span>
16 + </div>
17 + </template>
18 +
19 + <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0">
20 + <div class="commands-slash-results">
21 + <template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path">
22 + <button type="button"
23 + class="commands-slash-item"
24 + :class="{ active: index === $store.commandsSlash.selectedIndex }"
25 + @mouseenter="$store.commandsSlash.selectedIndex = index"
26 + @mousedown.prevent
27 + @click.prevent="$store.commandsSlash.applySelection(command)">
28 + <div class="commands-slash-item-header">
29 + <div class="commands-slash-item-name">
30 + <span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
31 + </div>
32 + <span class="commands-slash-scope" x-text="command.source_scope_label"></span>
33 + </div>
34 + <div class="commands-slash-item-description" x-text="command.description"></div>
35 + <template x-if="command.argument_hint">
36 + <div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
37 + </template>
38 + </button>
39 + </template>
40 + </div>
41 + </template>
42 +
43 + <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0">
44 + <div class="commands-slash-empty">
45 + <div class="commands-slash-empty-copy">
46 + No matching slash commands.
47 + </div>
48 + <button type="button"
49 + class="commands-slash-create"
50 + @mousedown.prevent
51 + @click.prevent="$store.commandsSlash.openCreateCommand()">
52 + <span class="material-symbols-outlined">add</span>
53 + <span x-text="$store.commandsSlash.emptyStateLabel"></span>
54 + </button>
55 + </div>
56 + </template>
57 + </div>
58 + </div>
59 + </template>
60 + </div>
61 +
62 + <style>
63 + .commands-slash-menu-root {
64 + position: relative;
65 + z-index: 30;
66 + }
67 +
68 + .commands-slash-menu {
69 + margin-bottom: 0.55rem;
70 + border: 1px solid var(--color-border);
71 + border-radius: 8px;
72 + background: color-mix(in srgb, var(--color-panel) 96%, var(--color-background));
73 + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.14);
74 + overflow: hidden;
75 + }
76 +
77 + .commands-slash-results {
78 + max-height: 18rem;
79 + overflow-y: auto;
80 + }
81 +
82 + .commands-slash-item {
83 + display: flex;
84 + flex-direction: column;
85 + gap: 0.32rem;
86 + width: 100%;
87 + padding: var(--spacing-sm) 0.8rem;
88 + border: 0;
89 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
90 + background: transparent;
91 + color: inherit;
92 + text-align: left;
93 + cursor: pointer;
94 + }
95 +
96 + .commands-slash-item:last-child {
97 + border-bottom: 0;
98 + }
99 +
100 + .commands-slash-item.active,
101 + .commands-slash-item:hover {
102 + background: color-mix(in srgb, var(--color-highlight) 10%, transparent);
103 + }
104 +
105 + .commands-slash-item-header {
106 + display: flex;
107 + justify-content: space-between;
108 + gap: 0.75rem;
109 + align-items: center;
110 + }
111 +
112 + .commands-slash-item-name {
113 + font-weight: 600;
114 + }
115 +
116 + .commands-slash-prefix {
117 + color: var(--color-highlight);
118 + }
119 +
120 + .commands-slash-scope {
121 + padding: 0.18rem 0.45rem;
122 + border-radius: 999px;
123 + background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
124 + font-size: 0.72rem;
125 + font-weight: 600;
126 + white-space: nowrap;
127 + }
128 +
129 + .commands-slash-item-description {
130 + color: var(--color-text-secondary);
131 + font-size: 0.86rem;
132 + line-height: 1.4;
133 + }
134 +
135 + .commands-slash-item-hint {
136 + color: var(--color-text-secondary);
137 + font-family: "Roboto Mono", monospace;
138 + font-size: 0.77rem;
139 + opacity: 0.85;
140 + }
141 +
142 + .commands-slash-empty,
143 + .commands-slash-loading {
144 + display: flex;
145 + align-items: center;
146 + justify-content: space-between;
147 + gap: 0.75rem;
148 + padding: 0.9rem 1rem;
149 + }
150 +
151 + .commands-slash-empty-copy,
152 + .commands-slash-loading {
153 + color: var(--color-text-secondary);
154 + }
155 +
156 + .commands-slash-create {
157 + display: inline-flex;
158 + align-items: center;
159 + gap: 0.35rem;
160 + padding: 0.5rem 0.75rem;
161 + border: 1px solid var(--color-border);
162 + border-radius: 999px;
163 + background: transparent;
164 + color: var(--color-text);
165 + cursor: pointer;
166 + font-weight: 600;
167 + white-space: nowrap;
168 + }
169 +
170 + .spinning {
171 + animation: commands-slash-spin 1s linear infinite;
172 + }
173 +
174 + @keyframes commands-slash-spin {
175 + from { transform: rotate(0deg); }
176 + to { transform: rotate(360deg); }
177 + }
178 +
179 + @media (max-width: 640px) {
180 + .commands-slash-empty,
181 + .commands-slash-loading {
182 + flex-direction: column;
183 + align-items: stretch;
184 + }
185 +
186 + .commands-slash-create {
187 + justify-content: center;
188 + }
189 + }
190 + </style>
191 +</body>
192 +</html>
plugins/_commands/extensions/webui/sidebar-quick-actions-main-start/commands-entry.html new
+9
@@ -0,0 +1,9 @@
1 +<div x-data>
2 + <button x-move-after=".config-button#plugins"
3 + class="config-button"
4 + id="commands-plugin"
5 + title="Commands"
6 + @click="import('/plugins/_commands/webui/commands-store.js').then(({ store }) => store.openManager())">
7 + <span class="material-symbols-outlined">terminal</span>
8 + </button>
9 +</div>
plugins/_commands/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""Helpers for the commands plugin."""
plugins/_commands/helpers/commands.py new
+1150
@@ -0,0 +1,1150 @@
1 +from __future__ import annotations
2 +
3 +import inspect
4 +import json
5 +import os
6 +import re
7 +import runpy
8 +import shlex
9 +from pathlib import Path
10 +from typing import Any
11 +
12 +import yaml
13 +
14 +from agent import AgentContext
15 +from helpers import files, plugins, projects, yaml as yaml_helper
16 +from helpers.skills import split_frontmatter
17 +
18 +
19 +PLUGIN_NAME = "_commands"
20 +COMMANDS_DIR = "commands"
21 +COMMAND_CONFIG_SUFFIX = ".command.yaml"
22 +LEGACY_COMMAND_FILE_SUFFIX = ".command.md"
23 +TEXT_TEMPLATE_SUFFIX = ".txt"
24 +SCRIPT_TEMPLATE_SUFFIX = ".py"
25 +STANDARD_CONFIG_KEYS = {
26 + "name",
27 + "description",
28 + "argument_hint",
29 + "type",
30 + "template_path",
31 + "script_path",
32 + "include_history",
33 +}
34 +_INVALID_COMMAND_CHARS_RE = re.compile(r"[^a-z0-9_-]+")
35 +_MULTI_DASH_RE = re.compile(r"-{2,}")
36 +_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z0-9_.-]+)\}")
37 +
38 +
39 +def sanitize_command_name(raw_name: str) -> str:
40 + """Sanitize a raw command name to a lowercase, hyphen-separated slug.
41 +
42 + Strips whitespace, lowercases, replaces spaces and invalid characters with hyphens,
43 + collapses consecutive hyphens, and strips leading/trailing hyphens and underscores.
44 +
45 + Raises:
46 + ValueError: If the resulting name is empty.
47 +
48 + """
49 + raw = (raw_name or "").strip().lower()
50 + name = raw.replace(" ", "-")
51 + name = _INVALID_COMMAND_CHARS_RE.sub("-", name)
52 + name = _MULTI_DASH_RE.sub("-", name).strip("-_")
53 + if not name:
54 + raise ValueError("Command name must contain at least one letter or number")
55 + return name
56 +
57 +
58 +def normalize_command_type(raw_type: str) -> str:
59 + """Normalize a command type string to either ``"text"`` or ``"script"``."""
60 + command_type = (raw_type or "text").strip().lower()
61 + if command_type not in {"text", "script"}:
62 + raise ValueError('Command type must be either "text" or "script"')
63 + return command_type
64 +
65 +
66 +def command_file_name(command_name: str) -> str:
67 + """Return the ``.command.yaml`` config filename for *command_name*."""
68 + return f"{sanitize_command_name(command_name)}{COMMAND_CONFIG_SUFFIX}"
69 +
70 +
71 +def command_content_file_name(command_name: str, command_type: str) -> str:
72 + """Return the content filename (``.txt`` or ``.py``) for *command_name* and *command_type*."""
73 + suffix = (
74 + TEXT_TEMPLATE_SUFFIX
75 + if normalize_command_type(command_type) == "text"
76 + else SCRIPT_TEMPLATE_SUFFIX
77 + )
78 + return f"{sanitize_command_name(command_name)}{suffix}"
79 +
80 +
81 +def parse_slash_invocation(raw_message: str, *, fallback_command: str = "") -> dict[str, Any]:
82 + """Parse a raw slash-command message into its component parts.
83 +
84 + Returns a dict with keys: ``raw_text``, ``command_name``, ``raw_arguments``,
85 + and ``arguments`` (parsed by :func:`parse_arguments`).
86 +
87 + Args:
88 + raw_message: The full message string, e.g. ``"/scan --url https://example.com"``.
89 + fallback_command: Command name to use when no slash prefix is found.
90 +
91 + """
92 + text = (raw_message or "").strip()
93 + slash_match = re.match(r"^/([^\s]+)(?:\s+([\s\S]*))?$", text)
94 + if slash_match:
95 + try:
96 + command_name = sanitize_command_name(slash_match.group(1))
97 + except ValueError:
98 + command_name = sanitize_command_name(fallback_command) if fallback_command else ""
99 + raw_arguments = (slash_match.group(2) or "").strip()
100 + else:
101 + command_name = sanitize_command_name(fallback_command) if fallback_command else ""
102 + raw_arguments = text
103 +
104 + parsed_arguments = parse_arguments(raw_arguments)
105 + return {
106 + "raw_text": text,
107 + "command_name": command_name,
108 + "raw_arguments": raw_arguments,
109 + "arguments": parsed_arguments,
110 + }
111 +
112 +
113 +def parse_arguments(raw_arguments: str) -> dict[str, Any]:
114 + """Parse a raw argument string into positional args, flags, and tokens.
115 +
116 + Supports positional values, long flags (``--key value``, ``--key=value``),
117 + short flags (``-f``), and short flag bundles (``-vq``).
118 +
119 + Returns a dict with keys: ``raw``, ``tokens``, ``positional``, ``flags``.
120 +
121 + """
122 + normalized_arguments = (raw_arguments or "").strip()
123 + tokens = _split_arguments(normalized_arguments)
124 + positional: list[str] = []
125 + flags: dict[str, Any] = {}
126 +
127 + index = 0
128 + while index < len(tokens):
129 + token = tokens[index]
130 + if token.startswith("--") and len(token) > 2:
131 + key, value, consumed = _parse_long_flag(token, tokens, index)
132 + _set_flag_value(flags, key, value)
133 + index += consumed
134 + continue
135 +
136 + if token.startswith("-") and len(token) > 1:
137 + consumed = _parse_short_flag_bundle(token, tokens, index, flags)
138 + index += consumed
139 + continue
140 +
141 + positional.append(token)
142 + index += 1
143 +
144 + return {
145 + "raw": normalized_arguments,
146 + "tokens": tokens,
147 + "positional": positional,
148 + "flags": flags,
149 + }
150 +
151 +
152 +def render_command_body(
153 + body: str,
154 + raw_arguments: str,
155 + *,
156 + command_name: str = "",
157 + raw_message: str = "",
158 +) -> str:
159 + """Render *body* as a text template substituting placeholders from *raw_arguments*.
160 +
161 + Args:
162 + body: Template string with ``{placeholder}`` markers.
163 + raw_arguments: Unparsed argument string from the command invocation.
164 + command_name: Optional command name used for slash-invocation parsing fallback.
165 + raw_message: Full original message; when provided takes precedence over raw_arguments.
166 +
167 + """
168 + invocation = parse_slash_invocation(
169 + raw_message or raw_arguments,
170 + fallback_command=command_name,
171 + )
172 + if not raw_message:
173 + invocation["raw_arguments"] = (raw_arguments or "").strip()
174 + invocation["arguments"] = parse_arguments(invocation["raw_arguments"])
175 + return render_text_template(body, invocation)
176 +
177 +
178 +def render_text_template(body: str, invocation: dict[str, Any]) -> str:
179 + """Render *body* as a template substituting placeholders from *invocation* context.
180 +
181 + Unrecognised placeholders resolve to empty string. If *raw_arguments* is present
182 + and the template contains no argument references, the arguments are appended.
183 +
184 + """
185 + template = body or ""
186 + rendered = template
187 +
188 + context = _build_template_context(invocation)
189 + rendered = _PLACEHOLDER_RE.sub(
190 + lambda match: _resolve_placeholder(match.group(1), context),
191 + rendered,
192 + )
193 + rendered = _render_legacy_placeholders(rendered, invocation)
194 + rendered = rendered.strip()
195 +
196 + raw_arguments = invocation["raw_arguments"]
197 + if raw_arguments and not _template_references_arguments(template):
198 + suffix = f"Arguments:\n{raw_arguments}"
199 + rendered = f"{rendered}\n\n{suffix}" if rendered else suffix
200 +
201 + return rendered.strip()
202 +
203 +
204 +def get_scope_key(project_name: str = "", agent_profile: str = "") -> str:
205 + """Return the scope identifier key: ``'project'`` when a project is active, else ``'global'``."""
206 + if project_name:
207 + return "project"
208 + return "global"
209 +
210 +
211 +def get_scope_label(project_name: str = "", agent_profile: str = "") -> str:
212 + """Return the human-readable scope label: ``'Project'`` or ``'Global'``."""
213 + if project_name:
214 + return "Project"
215 + return "Global"
216 +
217 +
218 +def get_scope_directory(project_name: str = "", agent_profile: str = "") -> str:
219 + """Return the absolute filesystem path to the commands directory for the given scope."""
220 + return plugins.determine_plugin_asset_path(
221 + PLUGIN_NAME,
222 + project_name,
223 + "",
224 + COMMANDS_DIR,
225 + )
226 +
227 +
228 +def ensure_scope_directory(project_name: str = "", agent_profile: str = "") -> str:
229 + """Ensure the commands directory for the given scope exists, creating it when absent.
230 +
231 + Returns the absolute path to the directory.
232 +
233 + """
234 + directory = get_scope_directory(project_name, "")
235 + Path(directory).mkdir(parents=True, exist_ok=True)
236 + return directory
237 +
238 +
239 +def get_scope_payload(
240 + project_name: str = "",
241 + agent_profile: str = "",
242 + *,
243 + ensure_directory: bool = False,
244 +) -> dict[str, Any]:
245 + """Build a scope descriptor dict for the given project/agent context.
246 +
247 + Returns a dict containing ``project_name``, ``scope_key``, ``scope_label``,
248 + ``directory_path``, ``exists``, and the private ``_directory_abs_path`` key.
249 +
250 + Args:
251 + ensure_directory: When ``True``, create the directory if it does not exist.
252 +
253 + """
254 + directory_path = (
255 + ensure_scope_directory(project_name, "")
256 + if ensure_directory
257 + else get_scope_directory(project_name, "")
258 + )
259 + return {
260 + "project_name": project_name,
261 + "scope_key": get_scope_key(project_name, ""),
262 + "scope_label": get_scope_label(project_name, ""),
263 + "directory_path": _normalize_client_path(directory_path),
264 + "exists": os.path.isdir(directory_path),
265 + "_directory_abs_path": directory_path,
266 + }
267 +
268 +
269 +def get_context_scope(context_id: str = "") -> dict[str, str]:
270 + """Resolve the active project name for *context_id* and return a scope mapping.
271 +
272 + Returns ``{"project_name": str}`` — empty string when no project is associated.
273 +
274 + """
275 + context = _get_context(context_id)
276 + if not context:
277 + return {"project_name": ""}
278 +
279 + return {
280 + "project_name": projects.get_context_project_name(context) or "",
281 + }
282 +
283 +
284 +def list_scope_commands(
285 + project_name: str = "",
286 + agent_profile: str = "",
287 +) -> tuple[list[dict[str, Any]], dict[str, Any]]:
288 + """List all commands defined in *project_name* scope (not merged with global).
289 +
290 + Each command entry includes ``override_scopes`` and ``override_count`` fields
291 + indicating lower-scoped commands with the same name.
292 +
293 + Returns:
294 + Tuple of (commands list, stripped scope payload dict).
295 +
296 + """
297 + scope = get_scope_payload(project_name, "")
298 + commands = _load_scope_commands(project_name)
299 + overrides = _collect_lower_scope_matches(project_name)
300 +
301 + for command in commands:
302 + override_scopes = overrides.get(command["name"], [])
303 + command["override_scopes"] = override_scopes
304 + command["override_count"] = len(override_scopes)
305 +
306 + return commands, strip_private_scope(scope)
307 +
308 +
309 +def list_effective_commands(
310 + project_name: str = "",
311 + agent_profile: str = "",
312 +) -> tuple[list[dict[str, Any]], dict[str, Any]]:
313 + """Return the merged effective command list for *project_name* scope.
314 +
315 + Project-scoped commands take precedence over global commands of the same name.
316 + Commands are sorted alphabetically by name.
317 +
318 + Returns:
319 + Tuple of (sorted commands list, stripped scope payload dict).
320 +
321 + """
322 + resolved_scope = get_scope_payload(project_name, "")
323 + merged: dict[str, dict[str, Any]] = {}
324 +
325 + for scope_project in _iter_precedence_scopes(project_name):
326 + for command in _load_scope_commands(scope_project):
327 + merged.setdefault(command["name"], command)
328 +
329 + for command in _discover_builtin_commands():
330 + merged.setdefault(command["name"], command)
331 +
332 + for command in _discover_plugin_commands():
333 + merged.setdefault(command["name"], command)
334 +
335 + effective = sorted(merged.values(), key=lambda item: item["name"])
336 + return effective, strip_private_scope(resolved_scope)
337 +
338 +
339 +def get_command(
340 + path: str,
341 + project_name: str = "",
342 + agent_profile: str = "",
343 +) -> dict[str, Any]:
344 + """Load and return a single command by its config file *path*.
345 +
346 + Validates that *path* belongs to an effective scope for *project_name*.
347 +
348 + Raises:
349 + FileNotFoundError: If the command file does not exist.
350 + ValueError: If the path is outside all valid scopes, not a recognised
351 + config suffix, or the file content is invalid.
352 +
353 + """
354 + command_path = _validate_command_path(path, project_name, "", allow_plugin=True)
355 + # Determine actual scope from the resolved path to get correct metadata.
356 + # A global command loaded with a project context must report scope=global.
357 + actual_project = ""
358 + for scope in _iter_precedence_scopes(project_name):
359 + scope_dir = get_scope_directory(scope, "")
360 + if files.is_in_dir(command_path, scope_dir):
361 + actual_project = scope
362 + break
363 + command = _load_command_file(command_path, project_name=actual_project)
364 + if not command:
365 + raise ValueError("Command file is invalid or missing required configuration")
366 + if _is_builtin_command_path(command_path):
367 + return _mark_builtin_command(command)
368 + plugin_name = _plugin_name_for_commands_path(command_path)
369 + if plugin_name:
370 + _mark_plugin_command(command, plugin_name)
371 + return command
372 +
373 +
374 +def save_command(
375 + *,
376 + project_name: str = "",
377 + agent_profile: str = "",
378 + existing_path: str = "",
379 + name: str,
380 + description: str,
381 + argument_hint: str = "",
382 + command_type: str = "text",
383 + body: str = "",
384 + include_history: bool = False,
385 + extra_frontmatter: dict[str, Any] | None = None,
386 +) -> dict[str, Any]:
387 + """Create or update a command, writing both the config and content files.
388 +
389 + When *existing_path* is provided, the old files are removed after the new
390 + files are written (rename/move semantics).
391 +
392 + Returns:
393 + The fully-loaded command dict for the saved command.
394 +
395 + Raises:
396 + FileExistsError: If a command with the same name already exists in scope.
397 + ValueError: If required fields are missing or values are invalid.
398 +
399 + """
400 + command_name = sanitize_command_name(name)
401 + command_description = (description or "").strip()
402 + if not command_description:
403 + raise ValueError("Command description is required")
404 +
405 + normalized_type = normalize_command_type(command_type)
406 + scope_dir = ensure_scope_directory(project_name, "")
407 + target_config_path = files.get_abs_path(scope_dir, command_file_name(command_name))
408 + target_content_name = command_content_file_name(command_name, normalized_type)
409 + target_content_path = files.get_abs_path(scope_dir, target_content_name)
410 + existing_abs_path = ""
411 + existing_command: dict[str, Any] | None = None
412 + if existing_path:
413 + try:
414 + existing_abs_path = _validate_command_path(existing_path, project_name, "")
415 + existing_command = _load_command_file(existing_abs_path, project_name=project_name)
416 + except FileNotFoundError:
417 + existing_abs_path = ""
418 + existing_command = None
419 +
420 + if existing_abs_path and not os.path.exists(existing_abs_path):
421 + existing_abs_path = ""
422 + existing_command = None
423 +
424 + if os.path.exists(target_config_path) and not _paths_equal(
425 + target_config_path, existing_abs_path
426 + ):
427 + raise FileExistsError(f'A command named "{command_name}" already exists in this scope')
428 +
429 + existing_content_path = _to_abs_path(existing_command.get("content_path", "")) if existing_command else ""
430 + if os.path.exists(target_content_path) and not _paths_equal(
431 + target_content_path, existing_content_path
432 + ):
433 + raise FileExistsError(
434 + f'Command content file "{Path(target_content_path).name}" already exists in this scope'
435 + )
436 +
437 + content_key = "template_path" if normalized_type == "text" else "script_path"
438 + config = _build_command_config(
439 + name=command_name,
440 + description=command_description,
441 + argument_hint=argument_hint,
442 + command_type=normalized_type,
443 + content_path=target_content_name,
444 + include_history=include_history,
445 + extra_config=extra_frontmatter or {},
446 + )
447 + if content_key not in config:
448 + config[content_key] = target_content_name
449 +
450 + files.write_file(target_content_path, _normalize_command_body(body, normalized_type))
451 + files.write_file(target_config_path, _build_command_yaml(config))
452 +
453 + if existing_abs_path and not _paths_equal(existing_abs_path, target_config_path):
454 + files.delete_file(existing_abs_path)
455 +
456 + if (
457 + existing_content_path
458 + and os.path.exists(existing_content_path)
459 + and not _paths_equal(existing_content_path, target_content_path)
460 + ):
461 + files.delete_file(existing_content_path)
462 +
463 + return get_command(target_config_path, project_name, "")
464 +
465 +
466 +def delete_command(
467 + path: str,
468 + project_name: str = "",
469 + agent_profile: str = "",
470 +) -> None:
471 + """Delete the config file and associated content file for *path*.
472 +
473 + Raises:
474 + FileNotFoundError: If the command file does not exist.
475 + ValueError: If *path* is invalid or outside the allowed scope.
476 +
477 + """
478 + command = get_command(path, project_name, "")
479 + command_path = _validate_command_path(path, project_name, "")
480 + files.delete_file(command_path)
481 +
482 + content_path = _to_abs_path(command.get("content_path", ""))
483 + if content_path and os.path.exists(content_path):
484 + files.delete_file(content_path)
485 +
486 +
487 +def duplicate_command(
488 + path: str,
489 + project_name: str = "",
490 + agent_profile: str = "",
491 +) -> dict[str, Any]:
492 + """Duplicate an existing command, assigning it a unique ``-copy`` suffixed name.
493 +
494 + Returns the newly created command dict.
495 +
496 + Raises:
497 + FileNotFoundError: If the source command does not exist.
498 + ValueError: If the source path is invalid.
499 +
500 + """
501 + command = get_command(path, project_name, "")
502 + duplicated_name = _generate_duplicate_name(command["name"], project_name=project_name)
503 + return save_command(
504 + project_name=project_name,
505 + name=duplicated_name,
506 + description=command["description"],
507 + argument_hint=command.get("argument_hint", ""),
508 + command_type=command.get("command_type", "text"),
509 + body=command.get("body", ""),
510 + include_history=bool(command.get("include_history", False)),
511 + extra_frontmatter=command.get("frontmatter_extra", {}),
512 + )
513 +
514 +
515 +async def resolve_command_invocation(
516 + *,
517 + path: str,
518 + slash_text: str,
519 + project_name: str = "",
520 + context_id: str = "",
521 +) -> dict[str, Any]:
522 + """Resolve a slash command invocation, executing text rendering or a Python script hook.
523 +
524 + Args:
525 + path: Path to the command config file.
526 + slash_text: The full slash text entered by the user.
527 + project_name: Active project name (empty string for global scope).'
528 + context_id: Agent context ID, used for script commands that request history.
529 +
530 + Returns:
531 + Dict with keys ``command``, ``invocation``, and ``result``
532 + (``{"text": str, "effects": list}``).
533 +
534 + Raises:
535 + FileNotFoundError: If the command file is not found.
536 + ValueError: If path or slash_text is invalid.
537 +
538 + """
539 + command = get_command(path, project_name, "")
540 + invocation = parse_slash_invocation(slash_text, fallback_command=command["name"])
541 +
542 + if command.get("command_type") == "script":
543 + result = await _run_script_command(
544 + command=command,
545 + invocation=invocation,
546 + project_name=project_name,
547 + context_id=context_id,
548 + )
549 + else:
550 + text = render_text_template(command.get("body", ""), invocation)
551 + result = {"text": text, "effects": []}
552 +
553 + return {
554 + "command": _public_command_payload(command),
555 + "invocation": invocation,
556 + "result": result,
557 + }
558 +
559 +
560 +def _build_command_config(
561 + *,
562 + name: str,
563 + description: str,
564 + argument_hint: str,
565 + command_type: str,
566 + content_path: str,
567 + include_history: bool,
568 + extra_config: dict[str, Any],
569 +) -> dict[str, Any]:
570 + config: dict[str, Any] = {
571 + "name": name,
572 + "description": description,
573 + "type": command_type,
574 + }
575 + clean_argument_hint = (argument_hint or "").strip()
576 + if clean_argument_hint:
577 + config["argument_hint"] = clean_argument_hint
578 +
579 + if command_type == "text":
580 + config["template_path"] = content_path
581 + else:
582 + config["script_path"] = content_path
583 + if include_history:
584 + config["include_history"] = True
585 +
586 + for key, value in (extra_config or {}).items():
587 + if key in STANDARD_CONFIG_KEYS:
588 + continue
589 + config[key] = value
590 +
591 + return config
592 +
593 +
594 +def _build_command_yaml(config: dict[str, Any]) -> str:
595 + return f"{yaml_helper.dumps(config).strip()}\n"
596 +
597 +
598 +def _normalize_command_body(body: str, command_type: str) -> str:
599 + cleaned = (body or "").lstrip("\n").rstrip()
600 + if cleaned:
601 + return f"{cleaned}\n"
602 + return ""
603 +
604 +
605 +def _load_command_file(
606 + file_path: str,
607 + *,
608 + project_name: str = "",
609 +) -> dict[str, Any] | None:
610 + if file_path.endswith(COMMAND_CONFIG_SUFFIX):
611 + return _load_yaml_command_file(file_path, project_name=project_name)
612 + if file_path.endswith(LEGACY_COMMAND_FILE_SUFFIX):
613 + return _load_legacy_markdown_file(file_path, project_name=project_name)
614 + return None
615 +
616 +
617 +def _load_yaml_command_file(
618 + file_path: str,
619 + *,
620 + project_name: str = "",
621 +) -> dict[str, Any] | None:
622 + try:
623 + raw_content = files.read_file(file_path)
624 + except FileNotFoundError:
625 + return None
626 +
627 + try:
628 + parsed = yaml.safe_load(raw_content) or {}
629 + except yaml.YAMLError:
630 + return None
631 + if not isinstance(parsed, dict):
632 + return None
633 +
634 + raw_name = str(parsed.get("name") or "").strip()
635 + description = str(parsed.get("description") or "").strip()
636 + if not raw_name or not description:
637 + return None
638 +
639 + try:
640 + command_name = sanitize_command_name(raw_name)
641 + command_type = normalize_command_type(str(parsed.get("type") or "text"))
642 + except ValueError:
643 + return None
644 +
645 + directory_path = str(Path(file_path).parent)
646 + content_key = "template_path" if command_type == "text" else "script_path"
647 + configured_content_path = str(parsed.get(content_key) or "").strip() or command_content_file_name(
648 + command_name, command_type
649 + )
650 + content_abs_path = files.get_abs_path(directory_path, configured_content_path)
651 + # Content file must live in the same directory as its config file.
652 + # Using directory_path (not the project scope root) allows global commands
653 + # to load correctly even when a project context is active.
654 + if not files.is_in_dir(content_abs_path, directory_path):
655 + return None
656 +
657 + try:
658 + body = files.read_file(content_abs_path)
659 + except FileNotFoundError:
660 + body = ""
661 +
662 + argument_hint = str(parsed.get("argument_hint") or "").strip()
663 + include_history = bool(parsed.get("include_history", False))
664 + extra_config = {
665 + key: value for key, value in parsed.items() if key not in STANDARD_CONFIG_KEYS
666 + }
667 +
668 + return {
669 + "name": command_name,
670 + "description": description,
671 + "argument_hint": argument_hint,
672 + "command_type": command_type,
673 + "include_history": include_history,
674 + "body": body,
675 + "path": _normalize_client_path(file_path),
676 + "config_path": _normalize_client_path(file_path),
677 + "content_path": _normalize_client_path(content_abs_path),
678 + "directory_path": _normalize_client_path(directory_path),
679 + "project_name": project_name,
680 + "scope_key": get_scope_key(project_name, ""),
681 + "scope_label": get_scope_label(project_name, ""),
682 + "source_scope_key": get_scope_key(project_name, ""),
683 + "source_scope_label": get_scope_label(project_name, ""),
684 + "frontmatter_extra": extra_config,
685 + }
686 +
687 +
688 +def _load_legacy_markdown_file(
689 + file_path: str,
690 + *,
691 + project_name: str = "",
692 +) -> dict[str, Any] | None:
693 + try:
694 + content = files.read_file(file_path)
695 + except FileNotFoundError:
696 + return None
697 +
698 + frontmatter, body, errors = split_frontmatter(content)
699 + if errors:
700 + return None
701 +
702 + raw_name = str(frontmatter.get("name") or "").strip()
703 + description = str(frontmatter.get("description") or "").strip()
704 + if not raw_name or not description:
705 + return None
706 +
707 + try:
708 + command_name = sanitize_command_name(raw_name)
709 + except ValueError:
710 + return None
711 +
712 + argument_hint = str(frontmatter.get("argument_hint") or "").strip()
713 + extra_frontmatter = {
714 + key: value for key, value in frontmatter.items() if key not in {"name", "description", "argument_hint"}
715 + }
716 + directory_path = str(Path(file_path).parent)
717 + return {
718 + "name": command_name,
719 + "description": description,
720 + "argument_hint": argument_hint,
721 + "command_type": "text",
722 + "include_history": False,
723 + "body": body,
724 + "path": _normalize_client_path(file_path),
725 + "config_path": _normalize_client_path(file_path),
726 + "content_path": _normalize_client_path(file_path),
727 + "directory_path": _normalize_client_path(directory_path),
728 + "project_name": project_name,
729 + "scope_key": get_scope_key(project_name, ""),
730 + "scope_label": get_scope_label(project_name, ""),
731 + "source_scope_key": get_scope_key(project_name, ""),
732 + "source_scope_label": get_scope_label(project_name, ""),
733 + "frontmatter_extra": extra_frontmatter,
734 + }
735 +
736 +
737 +def _validate_command_path(
738 + path: str,
739 + project_name: str = "",
740 + agent_profile: str = "",
741 + *,
742 + allow_plugin: bool = False,
743 +) -> str:
744 + command_path = _to_abs_path(path)
745 + # Allow commands from any effective scope (project overrides global, but global is also valid)
746 + valid_roots = [get_scope_directory(scope, "") for scope in _iter_precedence_scopes(project_name)]
747 + if not any(files.is_in_dir(command_path, scope_root) for scope_root in valid_roots):
748 + is_builtin = _is_builtin_command_path(command_path)
749 + plugin_name = "" if is_builtin else _plugin_name_for_commands_path(command_path)
750 + if plugin_name:
751 + if not allow_plugin:
752 + raise ValueError("Plugin commands are read-only")
753 + elif is_builtin:
754 + if not allow_plugin:
755 + raise ValueError("Built-in commands are read-only")
756 + else:
757 + raise ValueError("Command path is outside the selected scope")
758 + if not (
759 + command_path.endswith(COMMAND_CONFIG_SUFFIX)
760 + or command_path.endswith(LEGACY_COMMAND_FILE_SUFFIX)
761 + ):
762 + raise ValueError("Command path must point to a .command.yaml or .command.md file")
763 + if not os.path.exists(command_path):
764 + raise FileNotFoundError("Command file not found")
765 + return command_path
766 +
767 +
768 +def _iter_precedence_scopes(project_name: str) -> list[str]:
769 + if project_name:
770 + return [project_name, ""]
771 + return [""]
772 +
773 +
774 +def _list_scope_files(scope_dir: str) -> list[str]:
775 + if not os.path.isdir(scope_dir):
776 + return []
777 + files_in_scope = [
778 + str(path)
779 + for suffix in (COMMAND_CONFIG_SUFFIX, LEGACY_COMMAND_FILE_SUFFIX)
780 + for path in Path(scope_dir).glob(f"*{suffix}")
781 + if path.is_file()
782 + ]
783 + files_in_scope.sort(key=lambda item: Path(item).name.lower())
784 + return files_in_scope
785 +
786 +
787 +def _load_scope_commands(project_name: str = "") -> list[dict[str, Any]]:
788 + commands: list[dict[str, Any]] = []
789 + scope_dir = get_scope_directory(project_name, "")
790 +
791 + for file_path in _list_scope_files(scope_dir):
792 + command = _load_command_file(file_path, project_name=project_name)
793 + if command:
794 + commands.append(command)
795 +
796 + commands.sort(key=lambda item: item["name"])
797 + return commands
798 +
799 +
800 +def _discover_plugin_commands() -> list[dict[str, Any]]:
801 + """Discover commands contributed by enabled plugins."""
802 + commands: list[dict[str, Any]] = []
803 + for plugin_name in plugins.get_enabled_plugins(None):
804 + if plugin_name == PLUGIN_NAME:
805 + continue
806 + plugin_dir = plugins.find_plugin_dir(plugin_name)
807 + if not plugin_dir:
808 + continue
809 + plugin_commands_dir = files.get_abs_path(plugin_dir, COMMANDS_DIR)
810 + if not os.path.isdir(plugin_commands_dir):
811 + continue
812 + for file_path in _list_scope_files(plugin_commands_dir):
813 + command = _load_command_file(file_path, project_name="")
814 + if command:
815 + commands.append(_mark_plugin_command(command, plugin_name))
816 + return commands
817 +
818 +
819 +def _discover_builtin_commands() -> list[dict[str, Any]]:
820 + plugin_dir = plugins.find_plugin_dir(PLUGIN_NAME)
821 + if not plugin_dir:
822 + return []
823 + commands_dir = files.get_abs_path(plugin_dir, COMMANDS_DIR)
824 + commands: list[dict[str, Any]] = []
825 + for file_path in _list_scope_files(commands_dir):
826 + command = _load_command_file(file_path, project_name="")
827 + if command:
828 + commands.append(_mark_builtin_command(command))
829 + return commands
830 +
831 +
832 +def _collect_lower_scope_matches(project_name: str = "") -> dict[str, list[str]]:
833 + lower_scope_matches: dict[str, list[str]] = {}
834 + if not project_name:
835 + return lower_scope_matches
836 +
837 + for command in _load_scope_commands(""):
838 + lower_scope_matches.setdefault(command["name"], []).append(get_scope_label("", ""))
839 +
840 + return lower_scope_matches
841 +
842 +
843 +def _generate_duplicate_name(
844 + command_name: str,
845 + *,
846 + project_name: str = "",
847 +) -> str:
848 + base_name = sanitize_command_name(f"{command_name}-copy")
849 + candidate = base_name
850 + counter = 2
851 + scope_dir = ensure_scope_directory(project_name, "")
852 +
853 + while os.path.exists(files.get_abs_path(scope_dir, command_file_name(candidate))):
854 + candidate = f"{base_name}-{counter}"
855 + counter += 1
856 +
857 + return candidate
858 +
859 +
860 +def _build_template_context(invocation: dict[str, Any]) -> dict[str, Any]:
861 + arguments = invocation.get("arguments", {})
862 + return {
863 + "full": invocation.get("raw_text", ""),
864 + "raw": invocation.get("raw_arguments", ""),
865 + "command": invocation.get("command_name", ""),
866 + "args": {
867 + "raw": arguments.get("raw", ""),
868 + "tokens": arguments.get("tokens", []),
869 + "positional": arguments.get("positional", []),
870 + "flags": arguments.get("flags", {}),
871 + },
872 + }
873 +
874 +
875 +def _resolve_placeholder(path: str, context: dict[str, Any]) -> str:
876 + resolved = _resolve_path(context, path)
877 + if resolved is None:
878 + return ""
879 + if isinstance(resolved, (dict, list)):
880 + return json.dumps(resolved, ensure_ascii=False)
881 + return str(resolved)
882 +
883 +
884 +def _resolve_path(value: Any, path: str) -> Any:
885 + current = value
886 + for part in path.split("."):
887 + if isinstance(current, dict):
888 + if part in current:
889 + current = current[part]
890 + continue
891 + part_with_dash = part.replace("_", "-")
892 + if part_with_dash in current:
893 + current = current[part_with_dash]
894 + continue
895 + return None
896 +
897 + if isinstance(current, list):
898 + if not part.isdigit():
899 + return None
900 + index = int(part)
901 + if index < 0 or index >= len(current):
902 + return None
903 + current = current[index]
904 + continue
905 +
906 + return None
907 + return current
908 +
909 +
910 +def _render_legacy_placeholders(template: str, invocation: dict[str, Any]) -> str:
911 + rendered = template
912 + arguments = invocation.get("arguments", {})
913 + positional = arguments.get("positional", [])
914 + for index in range(10):
915 + rendered = rendered.replace(f"${index}", positional[index] if index < len(positional) else "")
916 + rendered = rendered.replace("$ARGUMENTS", invocation.get("raw_arguments", ""))
917 + return rendered
918 +
919 +
920 +def _template_references_arguments(template: str) -> bool:
921 + if "$ARGUMENTS" in template:
922 + return True
923 + if any(f"${index}" in template for index in range(10)):
924 + return True
925 + if "{raw}" in template:
926 + return True
927 + return "{args." in template
928 +
929 +
930 +def _parse_long_flag(token: str, tokens: list[str], index: int) -> tuple[str, Any, int]:
931 + flag_token = token[2:]
932 + if "=" in flag_token:
933 + key, value = flag_token.split("=", 1)
934 + return _normalize_flag_name(key), value, 1
935 +
936 + key = _normalize_flag_name(flag_token)
937 + next_index = index + 1
938 + if next_index < len(tokens) and not tokens[next_index].startswith("-"):
939 + return key, tokens[next_index], 2
940 + return key, True, 1
941 +
942 +
943 +def _parse_short_flag_bundle(
944 + token: str, tokens: list[str], index: int, flags: dict[str, Any]
945 +) -> int:
946 + short_token = token[1:]
947 + if len(short_token) > 1 and "=" not in short_token:
948 + for char in short_token:
949 + _set_flag_value(flags, _normalize_flag_name(char), True)
950 + return 1
951 +
952 + if "=" in short_token:
953 + key, value = short_token.split("=", 1)
954 + _set_flag_value(flags, _normalize_flag_name(key), value)
955 + return 1
956 +
957 + key = _normalize_flag_name(short_token)
958 + _set_flag_value(flags, key, True)
959 + return 1
960 +
961 +
962 +def _set_flag_value(flags: dict[str, Any], key: str, value: Any) -> None:
963 + if key in flags:
964 + current = flags[key]
965 + if isinstance(current, list):
966 + current.append(value)
967 + else:
968 + flags[key] = [current, value]
969 + return
970 + flags[key] = value
971 +
972 +
973 +def _normalize_flag_name(raw_flag: str) -> str:
974 + return (raw_flag or "").strip().lower().replace("-", "_")
975 +
976 +
977 +def _split_arguments(raw_arguments: str) -> list[str]:
978 + if not raw_arguments:
979 + return []
980 + try:
981 + return shlex.split(raw_arguments)
982 + except ValueError:
983 + return raw_arguments.split()
984 +
985 +
986 +async def _run_script_command(
987 + *,
988 + command: dict[str, Any],
989 + invocation: dict[str, Any],
990 + project_name: str,
991 + context_id: str,
992 +) -> dict[str, Any]:
993 + script_path = _to_abs_path(command.get("content_path", ""))
994 + if not script_path:
995 + raise ValueError("Script command is missing script_path")
996 + if not os.path.exists(script_path):
997 + raise ValueError("Script file not found for this command")
998 +
999 + module_globals = runpy.run_path(script_path)
1000 + hook = module_globals.get("run")
1001 + if not callable(hook):
1002 + raise ValueError('Script command must expose a callable "run(payload)" function')
1003 +
1004 + context = _get_context(context_id)
1005 + history = _extract_chat_history(context) if command.get("include_history") else []
1006 + payload = {
1007 + "command": _public_command_payload(command),
1008 + "invocation": invocation,
1009 + "arguments": invocation.get("arguments", {}),
1010 + "context": {
1011 + "context_id": context_id,
1012 + "project_name": project_name,
1013 + "agent": getattr(context, "agent0", None) if context else None,
1014 + "chat_history": history,
1015 + },
1016 + }
1017 +
1018 + result = hook(payload)
1019 + if inspect.isawaitable(result):
1020 + result = await result
1021 + return _normalize_script_result(result)
1022 +
1023 +
1024 +def _normalize_script_result(result: Any) -> dict[str, Any]:
1025 + if isinstance(result, str):
1026 + return {"text": result, "effects": []}
1027 +
1028 + if isinstance(result, dict):
1029 + text = result.get("text")
1030 + if text is None:
1031 + text = result.get("replacement_text")
1032 +
1033 + effects = result.get("effects")
1034 + if effects is None:
1035 + effects = []
1036 + if not isinstance(effects, list):
1037 + raise ValueError("Script result.effects must be an array when provided")
1038 +
1039 + normalized_text = str(text) if text is not None else ""
1040 + return {"text": normalized_text, "effects": effects}
1041 +
1042 + raise ValueError("Script run(payload) must return either a string or an object")
1043 +
1044 +
1045 +def _extract_chat_history(context: AgentContext | None) -> list[Any]:
1046 + if not context:
1047 + return []
1048 +
1049 + for attribute in ("chat_history", "history", "messages"):
1050 + value = getattr(context, attribute, None)
1051 + if isinstance(value, list):
1052 + return value
1053 +
1054 + getter = getattr(context, "get_data", None)
1055 + if callable(getter):
1056 + for key in ("chat_history", "messages", "history"):
1057 + value = getter(key)
1058 + if isinstance(value, list):
1059 + return value
1060 +
1061 + return []
1062 +
1063 +
1064 +def _public_command_payload(command: dict[str, Any]) -> dict[str, Any]:
1065 + payload = dict(command)
1066 + payload.pop("body", None)
1067 + return payload
1068 +
1069 +
1070 +def _normalize_client_path(path: str) -> str:
1071 + return files.normalize_a0_path(path).replace("\\", "/")
1072 +
1073 +
1074 +def _paths_equal(path_a: str, path_b: str) -> bool:
1075 + if not path_a or not path_b:
1076 + return False
1077 + return os.path.normcase(os.path.normpath(path_a)) == os.path.normcase(
1078 + os.path.normpath(path_b)
1079 + )
1080 +
1081 +
1082 +def _get_context(context_id: str = "") -> AgentContext | None:
1083 + if context_id:
1084 + return AgentContext.get(context_id)
1085 + return AgentContext.current() or AgentContext.first()
1086 +
1087 +
1088 +def _to_abs_path(path: str) -> str:
1089 + return files.fix_dev_path(path)
1090 +
1091 +
1092 +def _plugin_scope_label(plugin_name: str) -> str:
1093 + return f"Plugin: {plugin_name}"
1094 +
1095 +
1096 +def _mark_plugin_command(command: dict[str, Any], plugin_name: str) -> dict[str, Any]:
1097 + scope_label = _plugin_scope_label(plugin_name)
1098 + command["source_plugin"] = plugin_name
1099 + command["scope_key"] = "plugin"
1100 + command["scope_label"] = scope_label
1101 + command["source_scope_key"] = "plugin"
1102 + command["source_scope_label"] = scope_label
1103 + return command
1104 +
1105 +
1106 +def _mark_builtin_command(command: dict[str, Any]) -> dict[str, Any]:
1107 + command["source_plugin"] = PLUGIN_NAME
1108 + command["scope_key"] = "builtin"
1109 + command["scope_label"] = "Built-in"
1110 + command["source_scope_key"] = "builtin"
1111 + command["source_scope_label"] = "Built-in"
1112 + return command
1113 +
1114 +
1115 +def _builtin_commands_dir() -> str:
1116 + plugin_dir = plugins.find_plugin_dir(PLUGIN_NAME)
1117 + return files.get_abs_path(plugin_dir, COMMANDS_DIR) if plugin_dir else ""
1118 +
1119 +
1120 +def _is_builtin_command_path(path: str) -> bool:
1121 + commands_dir = _builtin_commands_dir()
1122 + return bool(commands_dir and files.is_in_dir(_to_abs_path(path), commands_dir))
1123 +
1124 +
1125 +def _plugin_name_for_commands_path(path: str) -> str:
1126 + abs_path = _to_abs_path(path)
1127 + for plugin_name in plugins.get_enabled_plugins(None):
1128 + if plugin_name == PLUGIN_NAME:
1129 + continue
1130 + plugin_dir = plugins.find_plugin_dir(plugin_name)
1131 + if not plugin_dir:
1132 + continue
1133 + plugin_commands_dir = files.get_abs_path(plugin_dir, COMMANDS_DIR)
1134 + if files.is_in_dir(abs_path, plugin_commands_dir):
1135 + return plugin_name
1136 + return ""
1137 +
1138 +
1139 +def _is_plugin_commands_dir(path: str) -> bool:
1140 + """Check if a path is inside any installed plugin's commands/ subdirectory."""
1141 + return bool(_plugin_name_for_commands_path(path))
1142 +
1143 +
1144 +def strip_private_scope(scope: dict[str, Any]) -> dict[str, Any]:
1145 + """Return a copy of *scope* with all keys prefixed by ``_`` removed."""
1146 + return {key: value for key, value in scope.items() if not key.startswith("_")}
1147 +
1148 +
1149 +def _strip_private_scope(scope: dict[str, Any]) -> dict[str, Any]:
1150 + return strip_private_scope(scope)
plugins/_commands/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: _commands
2 +title: Commands
3 +description: YAML-configured slash commands with text templates or Python hooks.
4 +version: 0.5.0
5 +settings_sections: []
6 +per_project_config: false
7 +per_agent_config: false
8 +always_enabled: false
plugins/_commands/skills/commands-create-slash-command/SKILL.md new
+72
@@ -0,0 +1,72 @@
1 +---
2 +name: commands-create-slash-command
3 +description: Create or update Agent Zero slash commands for the built-in Commands plugin. Use when the user asks to add, edit, duplicate, or refine a reusable /command backed by YAML config plus text/python content files.
4 +version: 1.0.0
5 +tags: ["commands", "slash-commands", "plugin", "yaml", "python", "templates"]
6 +triggers:
7 + - create slash command
8 + - add slash command
9 + - update slash command
10 + - edit slash command
11 + - commands plugin
12 +---
13 +
14 +# Commands Plugin Slash Command Authoring
15 +
16 +Use this skill when the user wants a reusable `/command` for Agent Zero's built-in `_commands` plugin.
17 +
18 +## Source Of Truth
19 +
20 +- Slash commands are file-backed, not database rows.
21 +- Each command uses:
22 + - one config file: `<slug>.command.yaml`
23 + - one content file:
24 + - text template: `<slug>.txt`, or
25 + - python hook: `<slug>.py`
26 +- Required config keys:
27 + - `name`
28 + - `description`
29 + - `type` (`text` or `script`)
30 +- Optional config keys:
31 + - `argument_hint`
32 + - `include_history` (script commands)
33 +- Preserve unknown config keys when editing existing commands.
34 +
35 +## Scope Resolution
36 +
37 +Choose the target folder from the requested scope:
38 +
39 +- Project: `usr/projects/<project>/.a0proj/plugins/_commands/commands/`
40 +- Global fallback: `usr/plugins/_commands/commands/`
41 +
42 +If the user does not specify a scope, prefer the active chat scope when it is clear. Otherwise use the global scope.
43 +
44 +## File Rules
45 +
46 +- Config file format: `<slug>.command.yaml`
47 +- Slash command name should be lowercase and hyphenated, for example `explain-code`
48 +- For text commands, keep the `.txt` template concise and directly reusable
49 +- For script commands, implement `run(payload)` in the `.py` file
50 +- If the command expects trailing input, use `{raw}`, `{args.positional.0}`, or `{args.flags.some_flag}`
51 +
52 +Use the bundled templates in `template.command.yaml` and `template.command.txt` when creating a new text command from scratch.
53 +
54 +## Editing Workflow
55 +
56 +1. Determine scope and final slash command name.
57 +2. Check whether a command file already exists in that scope.
58 +3. If it exists, load the file first and preserve unknown frontmatter keys.
59 +4. Update YAML config and template/script content.
60 +5. Save the file in the correct scope folder.
61 +6. Report:
62 + - the saved config path
63 + - the saved content path
64 + - the slash command name in `/name` form
65 +
66 +## Output Contract
67 +
68 +After saving, explicitly state the final file path and the exact slash command invocation, for example:
69 +
70 +- `Saved config: /a0/usr/plugins/_commands/commands/explain-code.command.yaml`
71 +- `Saved content: /a0/usr/plugins/_commands/commands/explain-code.txt`
72 +- `Invoke with: /explain-code`
plugins/_commands/skills/commands-create-slash-command/template.command.txt new
+3
@@ -0,0 +1,3 @@
1 +Describe the work to perform here.
2 +
3 +{raw}
plugins/_commands/skills/commands-create-slash-command/template.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: example-command
2 +description: Briefly describe what this slash command does.
3 +argument_hint: Optional free-form text after /example-command
4 +type: text
5 +template_path: example-command.txt
plugins/_commands/tests/conftest.py new
+11
@@ -0,0 +1,11 @@
1 +# tests/conftest.py
2 +import sys
3 +from pathlib import Path
4 +
5 +# Ensure A0 framework root is first in sys.path so that
6 +# `from helpers import ...` resolves to A0's helpers/, not
7 +# the Commands plugin's local helpers/ directory.
8 +a0_root = str(Path(__file__).resolve().parents[3]) # tests/ → _commands → plugins → a0
9 +while a0_root in sys.path:
10 + sys.path.remove(a0_root)
11 +sys.path.insert(0, a0_root)
plugins/_commands/tests/test_commands_plugin.py new
+367
@@ -0,0 +1,367 @@
1 +from __future__ import annotations
2 +
3 +import threading
4 +import uuid
5 +from dataclasses import dataclass, field
6 +from pathlib import Path
7 +import sys
8 +
9 +import pytest
10 +from flask import Flask
11 +
12 +PROJECT_ROOT = Path(__file__).resolve().parents[4]
13 +if str(PROJECT_ROOT) not in sys.path:
14 + sys.path.insert(0, str(PROJECT_ROOT))
15 +
16 +from agent import AgentContext
17 +from helpers import files, projects, skills as skills_helper
18 +from initialize import initialize_agent
19 +from plugins._commands.api.commands import Commands
20 +from plugins._commands.commands import connector_commands
21 +from plugins._commands.helpers import commands as commands_helper
22 +
23 +
24 +@dataclass
25 +class ScopeFixture:
26 + prefix: str
27 + project_name: str
28 + created_paths: list[str] = field(default_factory=list)
29 +
30 +
31 +def _track_paths(scope: ScopeFixture, command: dict) -> dict:
32 + for key in ("path", "config_path", "content_path"):
33 + command_path = files.fix_dev_path(command.get(key, ""))
34 + if command_path and command_path not in scope.created_paths:
35 + scope.created_paths.append(command_path)
36 + return command
37 +
38 +
39 +def _save_command(
40 + scope: ScopeFixture,
41 + *,
42 + project_name: str = "",
43 + name: str,
44 + description: str,
45 + body: str = "",
46 + argument_hint: str = "",
47 + command_type: str = "text",
48 + include_history: bool = False,
49 + extra_frontmatter: dict | None = None,
50 +) -> dict:
51 + command = commands_helper.save_command(
52 + project_name=project_name,
53 + name=name,
54 + description=description,
55 + body=body,
56 + argument_hint=argument_hint,
57 + command_type=command_type,
58 + include_history=include_history,
59 + extra_frontmatter=extra_frontmatter or {},
60 + )
61 + return _track_paths(scope, command)
62 +
63 +
64 +@pytest.fixture
65 +def scope_fixture() -> ScopeFixture:
66 + suffix = uuid.uuid4().hex[:8]
67 + scope = ScopeFixture(
68 + prefix=f"commands-test-{suffix}",
69 + project_name=f"commands_project_{suffix}",
70 + )
71 +
72 + yield scope
73 +
74 + for path in reversed(scope.created_paths):
75 + files.delete_file(path)
76 +
77 + files.delete_dir(files.get_abs_path("usr", "projects", scope.project_name))
78 +
79 +
80 +def _new_handler() -> Commands:
81 + app = Flask("commands_plugin_tests")
82 + app.secret_key = "commands-plugin-tests"
83 + return Commands(app, threading.RLock())
84 +
85 +
86 +def test_command_config_and_template_files_round_trip(
87 + scope_fixture: ScopeFixture,
88 +) -> None:
89 + command = _save_command(
90 + scope_fixture,
91 + name=f"Explain {scope_fixture.prefix}",
92 + description="Explain a code sample clearly.",
93 + body="Explain the sample.\n\n{raw}",
94 + argument_hint="Paste code or describe the module.",
95 + command_type="text",
96 + extra_frontmatter={"category": "analysis", "audience": "team"},
97 + )
98 +
99 + config_path = Path(files.fix_dev_path(command["path"]))
100 + content_path = Path(files.fix_dev_path(command["content_path"]))
101 + assert config_path.name == f"explain-{scope_fixture.prefix}.command.yaml"
102 + assert content_path.name == f"explain-{scope_fixture.prefix}.txt"
103 +
104 + loaded = commands_helper.get_command(command["path"])
105 + assert loaded["frontmatter_extra"] == {
106 + "category": "analysis",
107 + "audience": "team",
108 + }
109 +
110 + config_yaml = files.read_file(str(config_path))
111 + assert "category: analysis" in config_yaml
112 + assert "audience: team" in config_yaml
113 + assert f"name: explain-{scope_fixture.prefix}" in config_yaml
114 + assert "type: text" in config_yaml
115 +
116 + template_text = files.read_file(str(content_path))
117 + assert "Explain the sample." in template_text
118 +
119 +
120 +def test_parse_arguments_and_render_template_support_flags() -> None:
121 + parsed = commands_helper.parse_arguments(
122 + '--git-url=https://github.com/acme/repo "quoted phrase" -v 30%'
123 + )
124 + assert parsed["flags"]["git_url"] == "https://github.com/acme/repo"
125 + assert parsed["flags"]["v"] is True
126 + assert parsed["positional"] == ["quoted phrase", "30%"]
127 +
128 + invocation = commands_helper.parse_slash_invocation(
129 + '/optimize 30% --mode fast --git-url=https://github.com/acme/repo'
130 + )
131 + rendered = commands_helper.render_text_template(
132 + "Pct: {args.positional.0}\nMode: {args.flags.mode}\nURL: {args.flags.git_url}\nRaw: {raw}",
133 + invocation,
134 + )
135 + assert rendered == (
136 + "Pct: 30%\n"
137 + "Mode: fast\n"
138 + "URL: https://github.com/acme/repo\n"
139 + "Raw: 30% --mode fast --git-url=https://github.com/acme/repo"
140 + )
141 +
142 + appended = commands_helper.render_text_template(
143 + "Summarize this request.",
144 + commands_helper.parse_slash_invocation("/summarize alpha beta"),
145 + )
146 + assert appended == "Summarize this request.\n\nArguments:\nalpha beta"
147 +
148 + invalid_invocation = commands_helper.parse_slash_invocation("/?")
149 + assert invalid_invocation["command_name"] == ""
150 +
151 +
152 +def test_list_effective_commands_project_overrides_global(
153 + scope_fixture: ScopeFixture,
154 +) -> None:
155 + shared_name = f"{scope_fixture.prefix}-shared"
156 +
157 + _save_command(
158 + scope_fixture,
159 + name=shared_name,
160 + description="global description",
161 + body="global body",
162 + command_type="text",
163 + )
164 + _save_command(
165 + scope_fixture,
166 + project_name=scope_fixture.project_name,
167 + name=shared_name,
168 + description="project description",
169 + body="project body",
170 + command_type="text",
171 + )
172 +
173 + project_commands, _ = commands_helper.list_effective_commands(
174 + scope_fixture.project_name
175 + )
176 + global_commands, _ = commands_helper.list_effective_commands("")
177 +
178 + assert {command["name"]: command for command in project_commands}[shared_name][
179 + "description"
180 + ] == "project description"
181 + assert {command["name"]: command for command in global_commands}[shared_name][
182 + "description"
183 + ] == "global description"
184 +
185 + scoped_commands, _ = commands_helper.list_scope_commands(scope_fixture.project_name)
186 + scoped_command = next(
187 + command for command in scoped_commands if command["name"] == shared_name
188 + )
189 + assert scoped_command["override_count"] == 1
190 + assert scoped_command["override_scopes"] == ["Global"]
191 +
192 +
193 +def test_models_command_always_opens_modal():
194 + result = connector_commands.run(
195 + {
196 + "invocation": {
197 + "command_name": "models",
198 + "raw_arguments": "default",
199 + },
200 + "context": {"context_id": ""},
201 + }
202 + )
203 +
204 + assert result == {
205 + "text": "",
206 + "effects": [{"type": "open_plugin_config", "plugin": "_model_config"}],
207 + }
208 +
209 +
210 +@pytest.mark.asyncio
211 +async def test_commands_api_crud_and_resolve_text_and_script(
212 + scope_fixture: ScopeFixture,
213 +) -> None:
214 + handler = _new_handler()
215 + command_name = f"{scope_fixture.prefix}-context"
216 +
217 + context = AgentContext(
218 + config=initialize_agent({}),
219 + set_current=True,
220 + )
221 + context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, scope_fixture.project_name)
222 +
223 + try:
224 + saved = await handler.process(
225 + {
226 + "action": "save",
227 + "project_name": scope_fixture.project_name,
228 + "name": command_name,
229 + "description": "context override",
230 + "command_type": "text",
231 + "body": (
232 + "Repo: {args.flags.git_url}\n"
233 + "Mode: {args.flags.mode}\n"
234 + "Raw: {raw}"
235 + ),
236 + },
237 + None,
238 + )
239 + assert isinstance(saved, dict)
240 + assert saved["ok"] is True
241 + saved_command = _track_paths(scope_fixture, saved["command"])
242 +
243 + loaded = await handler.process(
244 + {
245 + "action": "get",
246 + "project_name": scope_fixture.project_name,
247 + "path": saved_command["path"],
248 + },
249 + None,
250 + )
251 + assert isinstance(loaded, dict)
252 + assert loaded["command"]["description"] == "context override"
253 +
254 + resolved_text = await handler.process(
255 + {
256 + "action": "resolve",
257 + "project_name": scope_fixture.project_name,
258 + "path": saved_command["path"],
259 + "slash_text": f"/{command_name} --git-url=https://github.com/acme/repo --mode deep",
260 + "context_id": context.id,
261 + },
262 + None,
263 + )
264 + assert isinstance(resolved_text, dict)
265 + assert resolved_text["ok"] is True
266 + rendered_text = resolved_text["resolution"]["result"]["text"]
267 + assert "Repo: https://github.com/acme/repo" in rendered_text
268 + assert "Mode: deep" in rendered_text
269 +
270 + script_saved = await handler.process(
271 + {
272 + "action": "save",
273 + "project_name": scope_fixture.project_name,
274 + "name": f"{command_name}-script",
275 + "description": "script command",
276 + "command_type": "script",
277 + "include_history": True,
278 + "body": (
279 + "def run(payload):\n"
280 + " flags = payload['arguments'].get('flags', {})\n"
281 + " return {\n"
282 + " 'text': f\"Script mode: {flags.get('mode', 'none')}\",\n"
283 + " 'effects': [\n"
284 + " {'type': 'toast', 'level': 'success', 'message': 'Script executed'}\n"
285 + " ],\n"
286 + " }\n"
287 + ),
288 + },
289 + None,
290 + )
291 + assert isinstance(script_saved, dict)
292 + assert script_saved["ok"] is True
293 + script_command = _track_paths(scope_fixture, script_saved["command"])
294 +
295 + resolved_script = await handler.process(
296 + {
297 + "action": "resolve",
298 + "project_name": scope_fixture.project_name,
299 + "path": script_command["path"],
300 + "slash_text": f"/{command_name}-script --mode turbo",
301 + "context_id": context.id,
302 + },
303 + None,
304 + )
305 + assert isinstance(resolved_script, dict)
306 + assert resolved_script["ok"] is True
307 + assert resolved_script["resolution"]["result"]["text"] == "Script mode: turbo"
308 + assert resolved_script["resolution"]["result"]["effects"] == [
309 + {
310 + "type": "toast",
311 + "level": "success",
312 + "message": "Script executed",
313 + }
314 + ]
315 +
316 + duplicated = await handler.process(
317 + {
318 + "action": "duplicate",
319 + "project_name": scope_fixture.project_name,
320 + "path": saved_command["path"],
321 + },
322 + None,
323 + )
324 + assert isinstance(duplicated, dict)
325 + assert duplicated["ok"] is True
326 + assert duplicated["command"]["name"].startswith(f"{command_name}-copy")
327 + duplicated_command = _track_paths(scope_fixture, duplicated["command"])
328 +
329 + effective_list = await handler.process(
330 + {"action": "list_effective", "context_id": context.id},
331 + None,
332 + )
333 + assert isinstance(effective_list, dict)
334 + effective_by_name = {
335 + command["name"]: command for command in effective_list["commands"]
336 + }
337 + assert effective_by_name[command_name]["description"] == "context override"
338 + assert effective_by_name[command_name]["source_scope_key"] == "project"
339 +
340 + scope_info = await handler.process(
341 + {"action": "scope_info", "context_id": context.id},
342 + None,
343 + )
344 + assert isinstance(scope_info, dict)
345 + assert scope_info["scope"]["project_name"] == scope_fixture.project_name
346 +
347 + deleted = await handler.process(
348 + {
349 + "action": "delete",
350 + "project_name": scope_fixture.project_name,
351 + "path": duplicated_command["path"],
352 + },
353 + None,
354 + )
355 + assert isinstance(deleted, dict)
356 + assert deleted["ok"] is True
357 + finally:
358 + AgentContext.remove(context.id)
359 + AgentContext.set_current("")
360 +
361 +
362 +def test_plugin_scoped_skill_is_discoverable() -> None:
363 + skill = skills_helper.find_skill("commands-create-slash-command")
364 + assert skill is not None
365 + assert skill.skill_md_path.as_posix().endswith(
366 + "plugins/_commands/skills/commands-create-slash-command/SKILL.md"
367 + )
plugins/_commands/tests/test_legacy_migration.py new
+97
@@ -0,0 +1,97 @@
1 +from __future__ import annotations
2 +
3 +from pathlib import Path
4 +import sys
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[4]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +from helpers import plugins
11 +from plugins._commands.extensions.python.startup_migration._20_migrate_legacy_commands import (
12 + migrate_legacy_commands,
13 +)
14 +
15 +
16 +def test_migrate_legacy_commands_copies_user_data_and_disables_old_plugin(tmp_path: Path):
17 + legacy_root = tmp_path / "usr" / "plugins" / "commands"
18 + legacy_commands = legacy_root / "commands"
19 + legacy_skills = legacy_root / "skills" / "custom-skill"
20 + project_commands = (
21 + tmp_path
22 + / "usr"
23 + / "projects"
24 + / "demo"
25 + / ".a0proj"
26 + / "plugins"
27 + / "commands"
28 + / "commands"
29 + )
30 + new_commands = tmp_path / "usr" / "plugins" / "_commands" / "commands"
31 +
32 + legacy_commands.mkdir(parents=True)
33 + legacy_skills.mkdir(parents=True)
34 + project_commands.mkdir(parents=True)
35 + new_commands.mkdir(parents=True)
36 +
37 + (legacy_root / "plugin.yaml").write_text("name: commands\n", encoding="utf-8")
38 + (legacy_root / plugins.ENABLED_FILE_NAME).write_text("", encoding="utf-8")
39 + (legacy_commands / "demo.command.yaml").write_text(
40 + "name: demo\ndescription: Demo\ntype: text\ntemplate_path: demo.txt\n",
41 + encoding="utf-8",
42 + )
43 + (legacy_commands / "demo.txt").write_text("legacy demo\n", encoding="utf-8")
44 + (legacy_commands / "keep.command.yaml").write_text(
45 + "name: keep\ndescription: Keep\ntype: text\ntemplate_path: keep.txt\n",
46 + encoding="utf-8",
47 + )
48 + (new_commands / "keep.command.yaml").write_text("existing\n", encoding="utf-8")
49 + (project_commands / "project.command.yaml").write_text(
50 + "name: project\ndescription: Project\ntype: text\ntemplate_path: project.txt\n",
51 + encoding="utf-8",
52 + )
53 + (legacy_skills / "SKILL.md").write_text("---\nname: custom-skill\n---\n", encoding="utf-8")
54 +
55 + result = migrate_legacy_commands(tmp_path)
56 +
57 + assert result["copied_commands"] == 3
58 + assert result["copied_skills"] == 1
59 + assert result["disabled_roots"] == 2
60 +
61 + assert (new_commands / "demo.command.yaml").read_text(encoding="utf-8").startswith(
62 + "name: demo"
63 + )
64 + assert (new_commands / "demo.txt").read_text(encoding="utf-8") == "legacy demo\n"
65 + assert (new_commands / "keep.command.yaml").read_text(encoding="utf-8") == "existing\n"
66 + assert (
67 + tmp_path
68 + / "usr"
69 + / "projects"
70 + / "demo"
71 + / ".a0proj"
72 + / "plugins"
73 + / "_commands"
74 + / "commands"
75 + / "project.command.yaml"
76 + ).exists()
77 + assert (
78 + tmp_path
79 + / "usr"
80 + / "plugins"
81 + / "_commands"
82 + / "skills"
83 + / "custom-skill"
84 + / "SKILL.md"
85 + ).exists()
86 + assert not (legacy_root / plugins.ENABLED_FILE_NAME).exists()
87 + assert (legacy_root / plugins.DISABLED_FILE_NAME).exists()
88 + assert (
89 + tmp_path
90 + / "usr"
91 + / "projects"
92 + / "demo"
93 + / ".a0proj"
94 + / "plugins"
95 + / "commands"
96 + / plugins.DISABLED_FILE_NAME
97 + ).exists()
plugins/_commands/tests/test_plugin_command_discovery.py new
+337
@@ -0,0 +1,337 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import shutil
5 +import uuid
6 +from pathlib import Path
7 +import sys
8 +
9 +import pytest
10 +
11 +PROJECT_ROOT = Path(__file__).resolve().parents[4]
12 +if str(PROJECT_ROOT) not in sys.path:
13 + sys.path.insert(0, str(PROJECT_ROOT))
14 +
15 +from helpers import cache, files, plugins
16 +from plugins._commands.api.commands import Commands
17 +from plugins._commands.helpers import commands as commands_helper
18 +
19 +
20 +# ── Fixtures ──────────────────────────────────────────────────────────────────
21 +
22 +@pytest.fixture(autouse=True)
23 +def _clear_plugin_cache():
24 + """Ensure plugin list cache is fresh for every test."""
25 + cache.clear("*(plugins)*")
26 + yield
27 + cache.clear("*(plugins)*")
28 +
29 +
30 +@pytest.fixture()
31 +def fake_plugin():
32 + """Create a temporary plugin in usr/plugins/ with a commands/ directory."""
33 + suffix = uuid.uuid4().hex[:8]
34 + plugin_name = f"_test_cmd_disc_{suffix}"
35 + plugin_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name)
36 + commands_dir = os.path.join(plugin_dir, "commands")
37 + os.makedirs(commands_dir, exist_ok=True)
38 +
39 + # Write plugin.yaml so the plugin is discoverable
40 + files.write_file(
41 + os.path.join(plugin_dir, "plugin.yaml"),
42 + f"name: {plugin_name}\ntitle: Test\ndescription: Test\n",
43 + )
44 +
45 + yield {"name": plugin_name, "dir": plugin_dir, "commands_dir": commands_dir}
46 +
47 + # Cleanup
48 + shutil.rmtree(plugin_dir, ignore_errors=True)
49 + cache.remove(plugins.PLUGINS_LIST_CACHE_AREA, "")
50 +
51 +
52 +def _write_plugin_command(
53 + fake_plugin: dict,
54 + *,
55 + name: str,
56 + description: str,
57 + body: str = "default body",
58 +) -> str:
59 + """Write a .command.yaml + .txt into the fake plugin's commands/ dir.
60 +
61 + Returns the config file path.
62 + """
63 + slug = commands_helper.sanitize_command_name(name)
64 + cdir = fake_plugin["commands_dir"]
65 + config_path = os.path.join(cdir, f"{slug}.command.yaml")
66 + content_path = os.path.join(cdir, f"{slug}.txt")
67 +
68 + files.write_file(
69 + config_path,
70 + f"name: {slug}\ndescription: {description}\ntype: text\ntemplate_path: {slug}.txt\n",
71 + )
72 + files.write_file(content_path, body)
73 + return config_path
74 +
75 +
76 +# ── Tests ─────────────────────────────────────────────────────────────────────
77 +
78 +
79 +def test_discover_plugin_commands_finds_plugin_commands(fake_plugin: dict):
80 + """Plugin commands must be discovered without relying on real installs."""
81 + _write_plugin_command(
82 + fake_plugin,
83 + name=f"{fake_plugin['name']}-build",
84 + description="build test",
85 + )
86 +
87 + discovered = commands_helper._discover_plugin_commands()
88 + names = {c["name"] for c in discovered}
89 + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-build")
90 + assert expected in names, f"Expected {expected!r} in discovered names, got {names}"
91 +
92 + for cmd in discovered:
93 + if cmd["name"] == expected:
94 + assert cmd["source_plugin"] == fake_plugin["name"]
95 + assert cmd["scope_key"] == "plugin"
96 + assert cmd["scope_label"] == f"Plugin: {fake_plugin['name']}"
97 + assert cmd["source_scope_key"] == "plugin"
98 + assert cmd["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
99 + break
100 +
101 +
102 +def test_discover_plugin_commands_skips_own_plugin():
103 + """The commands plugin itself must NOT appear in _discover_plugin_commands."""
104 + discovered = commands_helper._discover_plugin_commands()
105 + for cmd in discovered:
106 + assert cmd.get("source_plugin") != "_commands"
107 +
108 +
109 +def test_discover_plugin_commands_skips_disabled_plugins(fake_plugin: dict):
110 + """Disabled plugins must not contribute slash commands to the picker."""
111 + _write_plugin_command(
112 + fake_plugin,
113 + name=f"{fake_plugin['name']}-disabled",
114 + description="disabled command",
115 + )
116 + files.write_file(os.path.join(fake_plugin["dir"], plugins.DISABLED_FILE_NAME), "")
117 + cache.clear("*(plugins)*")
118 +
119 + discovered = commands_helper._discover_plugin_commands()
120 + names = {command["name"] for command in discovered}
121 + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-disabled")
122 + assert expected not in names
123 +
124 +
125 +def test_discover_builtin_commands_marks_own_commands_read_only():
126 + """Bundled _commands command files are discoverable as built-ins, not plugin commands."""
127 + discovered = commands_helper._discover_builtin_commands()
128 + command = next((cmd for cmd in discovered if cmd["name"] == "new"), None)
129 +
130 + assert command is not None
131 + assert command["source_plugin"] == "_commands"
132 + assert command["scope_key"] == "builtin"
133 + assert command["scope_label"] == "Built-in"
134 +
135 + loaded = commands_helper.get_command(command["path"])
136 + assert loaded["name"] == "new"
137 + assert loaded["scope_key"] == "builtin"
138 +
139 + with pytest.raises(ValueError, match="Built-in commands are read-only"):
140 + commands_helper.save_command(
141 + existing_path=command["path"],
142 + name="new",
143 + description="updated description",
144 + body="updated body",
145 + )
146 +
147 + with pytest.raises(ValueError, match="Built-in commands are read-only"):
148 + commands_helper.delete_command(command["path"])
149 +
150 +
151 +def test_builtin_commands_use_canonical_names_only():
152 + discovered = commands_helper._discover_builtin_commands()
153 + names = {command["name"] for command in discovered}
154 +
155 + assert {"attach", "computer-use", "models", "plugins", "project"} <= names
156 + assert {
157 + "computer",
158 + "cu",
159 + "disconnect",
160 + "exit",
161 + "help",
162 + "image",
163 + "img",
164 + "keys",
165 + "model",
166 + "plugin",
167 + "projects",
168 + }.isdisjoint(names)
169 +
170 +
171 +def test_webui_effective_list_hides_webui_hidden_commands():
172 + effective, _ = commands_helper.list_effective_commands("")
173 + chats = next(command for command in effective if command["name"] == "chats")
174 + response = object.__new__(Commands)._list_effective({"context_id": ""})
175 + names = {command["name"] for command in response["commands"]}
176 +
177 + assert chats["frontmatter_extra"]["webui_hidden"] is True
178 + assert "chats" not in names
179 +
180 +
181 +def test_list_effective_includes_plugin_commands(fake_plugin: dict):
182 + """list_effective_commands must include commands from other plugins."""
183 + _write_plugin_command(
184 + fake_plugin,
185 + name=f"{fake_plugin['name']}-effective",
186 + description="effective test",
187 + )
188 +
189 + effective, _ = commands_helper.list_effective_commands("")
190 + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-effective")
191 + command = next((item for item in effective if item["name"] == expected), None)
192 + assert command is not None
193 + assert command["scope_key"] == "plugin"
194 + assert command["scope_label"] == f"Plugin: {fake_plugin['name']}"
195 +
196 +
197 +def test_plugin_commands_appear_in_effective_list(fake_plugin: dict):
198 + """Commands from a freshly-created plugin appear in effective list."""
199 + _write_plugin_command(
200 + fake_plugin,
201 + name=f"{fake_plugin['name']}-hello",
202 + description="A test command from a plugin",
203 + body="Hello from plugin",
204 + )
205 +
206 + effective, _ = commands_helper.list_effective_commands("")
207 + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-hello")
208 + command = next((item for item in effective if item["name"] == expected), None)
209 + assert command is not None
210 + assert command["source_plugin"] == fake_plugin["name"]
211 +
212 +
213 +def test_precedence_global_overrides_plugin(fake_plugin: dict):
214 + """A global command with the same name takes precedence over a plugin command."""
215 + shared_name = f"{fake_plugin['name']}-shared"
216 + slug = commands_helper.sanitize_command_name(shared_name)
217 +
218 + # 1. Plugin command (lowest precedence)
219 + _write_plugin_command(
220 + fake_plugin,
221 + name=shared_name,
222 + description="plugin version",
223 + body="plugin body",
224 + )
225 +
226 + # 2. Global command (higher precedence)
227 + try:
228 + commands_helper.save_command(
229 + name=shared_name,
230 + description="global version",
231 + body="global body",
232 + )
233 +
234 + effective, _ = commands_helper.list_effective_commands("")
235 + by_name = {c["name"]: c for c in effective}
236 + assert slug in by_name
237 + assert by_name[slug]["description"] == "global version"
238 + finally:
239 + scope_dir = commands_helper.get_scope_directory("")
240 + files.delete_file(os.path.join(scope_dir, f"{slug}.command.yaml"))
241 + files.delete_file(os.path.join(scope_dir, f"{slug}.txt"))
242 +
243 +
244 +def test_source_plugin_field_on_discovered_command(fake_plugin: dict):
245 + """Discovered commands must carry the source_plugin field."""
246 + _write_plugin_command(
247 + fake_plugin,
248 + name=f"{fake_plugin['name']}-src-test",
249 + description="source plugin test",
250 + )
251 +
252 + discovered = commands_helper._discover_plugin_commands()
253 + slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-src-test")
254 + match = next((c for c in discovered if c["name"] == slug), None)
255 + assert match is not None
256 + assert match["source_plugin"] == fake_plugin["name"]
257 + assert match["scope_key"] == "plugin"
258 + assert match["scope_label"] == f"Plugin: {fake_plugin['name']}"
259 + assert match["source_scope_key"] == "plugin"
260 + assert match["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
261 +
262 +
263 +def test_is_plugin_commands_dir_recognises_plugin_path(fake_plugin: dict):
264 + """_is_plugin_commands_dir must return True for files inside plugin commands/ dirs."""
265 + _write_plugin_command(
266 + fake_plugin,
267 + name=f"{fake_plugin['name']}-path-check",
268 + description="path test",
269 + )
270 + slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-path-check")
271 + config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml")
272 + normalized = commands_helper._normalize_client_path(config_path)
273 +
274 + assert commands_helper._is_plugin_commands_dir(normalized) is True
275 +
276 +
277 +def test_is_plugin_commands_dir_rejects_non_plugin_path(tmp_path: Path):
278 + """_is_plugin_commands_dir must return False for arbitrary paths."""
279 + non_plugin_path = tmp_path / "not-a-plugin" / "commands" / "foo.txt"
280 + assert commands_helper._is_plugin_commands_dir(str(non_plugin_path)) is False
281 +
282 +
283 +def test_get_command_can_load_plugin_command(fake_plugin: dict):
284 + """get_command must work for commands inside plugin directories."""
285 + _write_plugin_command(
286 + fake_plugin,
287 + name=f"{fake_plugin['name']}-loadable",
288 + description="loadable test",
289 + body="load me",
290 + )
291 + slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-loadable")
292 + config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml")
293 + normalized = commands_helper._normalize_client_path(config_path)
294 +
295 + command = commands_helper.get_command(normalized, project_name="demo-project")
296 + assert command["name"] == slug
297 + assert command["description"] == "loadable test"
298 + assert command["body"] == "load me"
299 + assert command["source_plugin"] == fake_plugin["name"]
300 + assert command["scope_key"] == "plugin"
301 + assert command["scope_label"] == f"Plugin: {fake_plugin['name']}"
302 + assert command["source_scope_key"] == "plugin"
303 + assert command["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
304 +
305 +
306 +def test_save_command_rejects_plugin_existing_path(fake_plugin: dict):
307 + """Editing a plugin command must fail because plugin commands are read-only."""
308 + config_path = _write_plugin_command(
309 + fake_plugin,
310 + name=f"{fake_plugin['name']}-readonly-edit",
311 + description="read-only test",
312 + body="plugin body",
313 + )
314 + normalized = commands_helper._normalize_client_path(config_path)
315 +
316 + with pytest.raises(ValueError, match="Plugin commands are read-only"):
317 + commands_helper.save_command(
318 + existing_path=normalized,
319 + name=f"{fake_plugin['name']}-readonly-edit",
320 + description="updated description",
321 + body="updated body",
322 + )
323 +
324 +
325 +def test_delete_command_rejects_plugin_command(fake_plugin: dict):
326 + """Deleting a plugin command must fail because plugin commands are read-only."""
327 + config_path = _write_plugin_command(
328 + fake_plugin,
329 + name=f"{fake_plugin['name']}-readonly-delete",
330 + description="read-only delete",
331 + )
332 + normalized = commands_helper._normalize_client_path(config_path)
333 +
334 + with pytest.raises(ValueError, match="Plugin commands are read-only"):
335 + commands_helper.delete_command(normalized)
336 +
337 + assert os.path.exists(config_path)
plugins/_commands/webui/commands-slash-store.js new
+513
@@ -0,0 +1,513 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 +import { store as chatInputStore } from "/components/chat/input/input-store.js";
5 +import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 +import {
7 + toastFrontendError,
8 + toastFrontendInfo,
9 + toastFrontendSuccess,
10 +} from "/components/notifications/notification-store.js";
11 +import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
12 +
13 +const COMMANDS_API_PATH = "/plugins/_commands/commands";
14 +
15 +function sanitizeCommandName(rawName) {
16 + return (rawName || "")
17 + .trim()
18 + .toLowerCase()
19 + .replace(/\s+/g, "-")
20 + .replace(/[^a-z0-9_-]+/g, "-")
21 + .replace(/-{2,}/g, "-")
22 + .replace(/^[-_]+|[-_]+$/g, "");
23 +}
24 +
25 +function parseSlashInput(message) {
26 + const text = String(message || "");
27 + const match = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
28 + if (!match) {
29 + return {
30 + active: false,
31 + query: "",
32 + rawArguments: "",
33 + rawMessage: text,
34 + };
35 + }
36 +
37 + return {
38 + active: true,
39 + query: (match[1] || "").trim().toLowerCase(),
40 + rawArguments: match[2] || "",
41 + rawMessage: text,
42 + };
43 +}
44 +
45 +function notifyError(message) {
46 + void toastFrontendError(message, "Commands");
47 +}
48 +
49 +function notifySuccess(message) {
50 + void toastFrontendSuccess(message, "Commands");
51 +}
52 +
53 +const HTML_ESCAPE = {
54 + "&": "&amp;",
55 + "<": "&lt;",
56 + ">": "&gt;",
57 + '"': "&quot;",
58 + "'": "&#39;",
59 +};
60 +
61 +function escapeHtml(value) {
62 + return String(value || "").replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
63 +}
64 +
65 +function notifyInfo(title, message) {
66 + const formatted = escapeHtml(message).replace(/\n/g, "<br>");
67 + void toastFrontendInfo(formatted, title || "Commands", 3, "", undefined, true);
68 +}
69 +
70 +const model = {
71 + loading: false,
72 + applying: false,
73 + commands: [],
74 + contextScope: { project_name: "" },
75 + lastContextId: "",
76 + active: false,
77 + dismissed: false,
78 + query: "",
79 + rawArguments: "",
80 + rawMessage: "",
81 + selectedIndex: 0,
82 + boundInput: null,
83 + keydownHandler: null,
84 + inputHandler: null,
85 + focusHandler: null,
86 + commandsUpdatedHandler: null,
87 +
88 + get menuVisible() {
89 + return this.active && !this.dismissed;
90 + },
91 +
92 + get filteredCommands() {
93 + const needle = (this.query || "").trim().toLowerCase();
94 + const commands = Array.isArray(this.commands) ? this.commands : [];
95 +
96 + if (!needle) return commands;
97 +
98 + return commands.filter((command) => {
99 + const haystack = `${command?.name || ""} ${command?.description || ""}`.toLowerCase();
100 + return haystack.includes(needle);
101 + });
102 + },
103 +
104 + get selectedCommand() {
105 + const commands = this.filteredCommands;
106 + if (!commands.length) return null;
107 + return commands[this.selectedIndex] || commands[0] || null;
108 + },
109 +
110 + get emptyStateLabel() {
111 + const name = sanitizeCommandName(this.query || "");
112 + return name ? `Create /${name}` : "Create slash command";
113 + },
114 +
115 + onMount() {
116 + this.ensureBindings();
117 +
118 + this.keydownHandler = (event) => this.handleKeydown(event);
119 + this.commandsUpdatedHandler = () => {
120 + this.commands = [];
121 + if (this.menuVisible) {
122 + void this.loadCommands(true);
123 + }
124 + };
125 +
126 + document.addEventListener("keydown", this.keydownHandler, true);
127 + window.addEventListener("commands:updated", this.commandsUpdatedHandler);
128 + this.handleInput();
129 + },
130 +
131 + cleanup() {
132 + this.removeBindings();
133 + if (this.keydownHandler) {
134 + document.removeEventListener("keydown", this.keydownHandler, true);
135 + }
136 + if (this.commandsUpdatedHandler) {
137 + window.removeEventListener("commands:updated", this.commandsUpdatedHandler);
138 + }
139 + this.keydownHandler = null;
140 + this.commandsUpdatedHandler = null;
141 + this.dismissed = false;
142 + this.active = false;
143 + this.query = "";
144 + this.rawArguments = "";
145 + this.rawMessage = "";
146 + this.selectedIndex = 0;
147 + this.applying = false;
148 + },
149 +
150 + ensureBindings() {
151 + const input = this.getInputElement();
152 + if (!input || input === this.boundInput) return;
153 +
154 + this.removeBindings();
155 +
156 + this.inputHandler = (event) => this.handleInput(event);
157 + this.focusHandler = () => this.handleInput();
158 + input.addEventListener("input", this.inputHandler);
159 + input.addEventListener("focus", this.focusHandler);
160 + this.boundInput = input;
161 + },
162 +
163 + removeBindings() {
164 + if (this.boundInput && this.inputHandler) {
165 + this.boundInput.removeEventListener("input", this.inputHandler);
166 + }
167 + if (this.boundInput && this.focusHandler) {
168 + this.boundInput.removeEventListener("focus", this.focusHandler);
169 + }
170 + this.boundInput = null;
171 + this.inputHandler = null;
172 + this.focusHandler = null;
173 + },
174 +
175 + getInputElement() {
176 + return document.getElementById("chat-input");
177 + },
178 +
179 + getInputMessage(event = null) {
180 + const target = event?.target || null;
181 + const targetEditor = target?.closest?.("#chat-input");
182 + if (targetEditor?.isContentEditable || target?.isContentEditable) {
183 + return (
184 + chatInputStore?._editorToMarkdown?.() ||
185 + targetEditor?.textContent ||
186 + target?.textContent ||
187 + ""
188 + );
189 + }
190 + if (typeof target?.value === "string") return target.value;
191 +
192 + const input = this.getInputElement();
193 + if (input?.isContentEditable) {
194 + return chatInputStore?._editorToMarkdown?.() ?? input.textContent ?? "";
195 + }
196 + if (typeof input?.value === "string") return input.value;
197 + return chatInputStore?.message ?? "";
198 + },
199 +
200 + getContextId() {
201 + return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
202 + },
203 +
204 + async loadCommands(force = false) {
205 + const contextId = this.getContextId();
206 +
207 + if (!force && this.commands.length && contextId === this.lastContextId) {
208 + this.ensureSelection();
209 + return;
210 + }
211 +
212 + this.loading = true;
213 + try {
214 + const response = await callJsonApi(COMMANDS_API_PATH, {
215 + action: "list_effective",
216 + context_id: contextId,
217 + });
218 + this.commands = Array.isArray(response?.commands) ? response.commands : [];
219 + this.contextScope = response?.scope || {
220 + project_name: "",
221 + };
222 + this.lastContextId = contextId;
223 + this.ensureSelection();
224 + } catch (error) {
225 + console.error("Failed to load effective commands:", error);
226 + this.commands = [];
227 + this.contextScope = { project_name: "" };
228 + } finally {
229 + this.loading = false;
230 + }
231 + },
232 +
233 + handleInput(event = null) {
234 + this.ensureBindings();
235 + this.dismissed = false;
236 +
237 + const message = this.getInputMessage(event);
238 + const parsed = parseSlashInput(message);
239 +
240 + this.active = parsed.active;
241 + this.query = parsed.query;
242 + this.rawArguments = parsed.rawArguments;
243 + this.rawMessage = parsed.rawMessage;
244 +
245 + if (!this.active) {
246 + this.selectedIndex = 0;
247 + return;
248 + }
249 +
250 + this.ensureSelection();
251 + void this.loadCommands();
252 + },
253 +
254 + handleKeydown(event) {
255 + const input = this.getInputElement();
256 + if (!this.menuVisible || !input || document.activeElement !== input) return;
257 + if (event.isComposing || event.keyCode === 229) return;
258 +
259 + if (event.key === "ArrowDown") {
260 + event.preventDefault();
261 + event.stopPropagation();
262 + this.moveSelection(1);
263 + return;
264 + }
265 +
266 + if (event.key === "ArrowUp") {
267 + event.preventDefault();
268 + event.stopPropagation();
269 + this.moveSelection(-1);
270 + return;
271 + }
272 +
273 + if (event.key === "Escape") {
274 + event.preventDefault();
275 + event.stopPropagation();
276 + this.dismissed = true;
277 + return;
278 + }
279 +
280 + if (event.key === "Enter" && this.selectedCommand) {
281 + event.preventDefault();
282 + event.stopPropagation();
283 + void this.applySelection(this.selectedCommand);
284 + }
285 + },
286 +
287 + ensureSelection() {
288 + const commands = this.filteredCommands;
289 + if (!commands.length) {
290 + this.selectedIndex = 0;
291 + return;
292 + }
293 + if (this.selectedIndex >= commands.length) {
294 + this.selectedIndex = 0;
295 + }
296 + },
297 +
298 + moveSelection(delta) {
299 + const commands = this.filteredCommands;
300 + if (!commands.length) return;
301 + const nextIndex =
302 + (this.selectedIndex + delta + commands.length) % commands.length;
303 + this.selectedIndex = nextIndex;
304 + this.scrollSelectedIntoView();
305 + },
306 +
307 + scrollSelectedIntoView() {
308 + requestAnimationFrame(() => {
309 + document
310 + .querySelector(".commands-slash-results .commands-slash-item.active")
311 + ?.scrollIntoView({ block: "nearest" });
312 + });
313 + },
314 +
315 + async applySelection(command) {
316 + if (!command || this.applying) return;
317 + const input = this.getInputElement();
318 + if (!input) return;
319 +
320 + this.applying = true;
321 + try {
322 + const contextId = this.getContextId();
323 + const fallbackSlash = this.rawMessage?.trim()
324 + ? this.rawMessage
325 + : this.rawArguments
326 + ? `/${command.name} ${this.rawArguments}`
327 + : `/${command.name}`;
328 +
329 + const response = await callJsonApi(COMMANDS_API_PATH, {
330 + action: "resolve",
331 + path: command.path,
332 + slash_text: fallbackSlash,
333 + project_name: this.contextScope?.project_name || "",
334 + context_id: contextId,
335 + });
336 +
337 + const applied = await this.applyResolution(response?.resolution, input);
338 + if (!applied?.hadToast && !applied?.hadError) {
339 + notifySuccess(`Applied /${command.name}`);
340 + }
341 + } catch (error) {
342 + console.error("Failed to apply slash command:", error);
343 + notifyError(error?.message || "Failed to apply slash command.");
344 + } finally {
345 + this.applying = false;
346 + }
347 + },
348 +
349 + async applyResolution(resolution, input) {
350 + const result = resolution?.result || {};
351 + const hasText = typeof result.text === "string";
352 + let nextText = hasText ? result.text : this.getInputMessage();
353 + const effects = Array.isArray(result.effects) ? result.effects : [];
354 + let hadToast = false;
355 + let hadError = false;
356 +
357 + for (const effect of effects) {
358 + if (!effect || typeof effect !== "object") continue;
359 + const type = String(effect.type || "").trim().toLowerCase();
360 + if (type === "replace_input") {
361 + nextText = String(effect.text || "");
362 + continue;
363 + }
364 + if (type === "append_input") {
365 + const chunk = String(effect.text || "");
366 + nextText = nextText ? `${nextText}\n${chunk}` : chunk;
367 + continue;
368 + }
369 + if (type === "toast") {
370 + hadToast = true;
371 + const level = String(effect.level || "info").toLowerCase();
372 + const message = String(effect.message || "");
373 + if (!message) continue;
374 + if (level === "error") {
375 + hadError = true;
376 + notifyError(message);
377 + } else {
378 + notifySuccess(message);
379 + }
380 + continue;
381 + }
382 + if (type === "new_chat") {
383 + await chatsStore?.newChat?.();
384 + continue;
385 + }
386 + if (type === "select_chat") {
387 + const contextId = String(effect.context_id || "").trim();
388 + if (contextId) await chatsStore?.selectChat?.(contextId);
389 + continue;
390 + }
391 + if (type === "reset_chat") {
392 + await chatsStore?.resetChat?.(String(effect.context_id || "") || null);
393 + continue;
394 + }
395 + if (type === "pause_agent") {
396 + await chatInputStore?.pauseAgent?.(Boolean(effect.paused));
397 + continue;
398 + }
399 + if (type === "nudge_agent") {
400 + await chatInputStore?.nudge?.();
401 + continue;
402 + }
403 + if (type === "open_modal") {
404 + const path = String(effect.path || "").trim();
405 + if (path) await window.openModal?.(path);
406 + continue;
407 + }
408 + if (type === "show_markdown") {
409 + hadToast = true;
410 + notifyInfo(
411 + String(effect.title || "Slash Command"),
412 + String(effect.content || ""),
413 + );
414 + continue;
415 + }
416 + if (type === "open_plugin_config") {
417 + const pluginName = String(effect.plugin || "").trim();
418 + if (pluginName) {
419 + const { store } = await import("/components/plugins/plugin-settings-store.js");
420 + await store.openConfig(
421 + pluginName,
422 + String(effect.project_name || ""),
423 + String(effect.agent_profile || ""),
424 + );
425 + }
426 + continue;
427 + }
428 + if (type === "compact_chat") {
429 + const { store } = await import("/plugins/_chat_compaction/webui/compact-store.js");
430 + await store.fetchStats();
431 + continue;
432 + }
433 + if (type === "attach_files") {
434 + await this.openAttachmentPicker(effect);
435 + continue;
436 + }
437 + if (type === "copy_transcript") {
438 + await this.copyTranscript();
439 + hadToast = true;
440 + continue;
441 + }
442 + if (type === "clear_transcript") {
443 + const history = document.getElementById("chat-history");
444 + if (history) history.innerHTML = "";
445 + continue;
446 + }
447 + }
448 +
449 + if (typeof input.value === "string") input.value = nextText;
450 + chatInputStore.message = nextText;
451 + input.dispatchEvent(new Event("input", { bubbles: true }));
452 + chatInputStore.adjustTextareaHeight();
453 + input.focus();
454 + if (typeof input.setSelectionRange === "function") {
455 + input.setSelectionRange(nextText.length, nextText.length);
456 + } else {
457 + chatInputStore?._setEditorCaret?.(nextText.length);
458 + }
459 +
460 + this.active = false;
461 + this.dismissed = false;
462 + this.query = "";
463 + this.rawArguments = "";
464 + this.rawMessage = nextText;
465 + this.selectedIndex = 0;
466 + return { hadToast, hadError };
467 + },
468 +
469 + openAttachmentPicker(effect = {}) {
470 + return new Promise((resolve) => {
471 + const picker = document.createElement("input");
472 + let settled = false;
473 + const done = () => {
474 + if (settled) return;
475 + settled = true;
476 + picker.remove();
477 + resolve();
478 + };
479 + picker.type = "file";
480 + picker.multiple = true;
481 + picker.accept = String(effect.accept || "*");
482 + picker.style.display = "none";
483 + picker.addEventListener("change", () => {
484 + attachmentsStore?.handleFiles?.(picker.files || []);
485 + done();
486 + }, { once: true });
487 + window.addEventListener("focus", () => setTimeout(done, 500), { once: true });
488 + document.body.appendChild(picker);
489 + picker.click();
490 + });
491 + },
492 +
493 + async copyTranscript() {
494 + const text = document.getElementById("chat-history")?.innerText?.trim() || "";
495 + if (!text) {
496 + notifyError("No visible transcript to copy.");
497 + return;
498 + }
499 + await navigator.clipboard.writeText(text);
500 + notifySuccess("Transcript copied.");
501 + },
502 +
503 + openCreateCommand() {
504 + commandsManagerStore.openManager({
505 + projectName: this.contextScope?.project_name || "",
506 + prefillName: sanitizeCommandName(this.query || ""),
507 + openEditor: true,
508 + });
509 + this.dismissed = true;
510 + },
511 +};
512 +
513 +export const store = createStore("commandsSlash", model);
plugins/_commands/webui/commands-store.js new
+417
@@ -0,0 +1,417 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import {
4 + toastFrontendError,
5 + toastFrontendSuccess,
6 +} from "/components/notifications/notification-store.js";
7 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
9 +
10 +const COMMANDS_API_PATH = "/plugins/_commands/commands";
11 +const MAIN_MODAL_PATH = "/plugins/_commands/webui/main.html";
12 +const EDITOR_MODAL_PATH = "/plugins/_commands/webui/editor.html";
13 +
14 +function createEmptyEditor() {
15 + return {
16 + mode: "create",
17 + existingPath: "",
18 + path: "",
19 + name: "",
20 + description: "",
21 + argumentHint: "",
22 + commandType: "text",
23 + includeHistory: false,
24 + body: "",
25 + extraFrontmatter: {},
26 + };
27 +}
28 +
29 +function safeStringify(value) {
30 + try {
31 + return JSON.stringify(value ?? {});
32 + } catch {
33 + return "";
34 + }
35 +}
36 +
37 +function sanitizeCommandName(rawName) {
38 + return (rawName || "")
39 + .trim()
40 + .toLowerCase()
41 + .replace(/\s+/g, "-")
42 + .replace(/[^a-z0-9_-]+/g, "-")
43 + .replace(/-{2,}/g, "-")
44 + .replace(/^[-_]+|[-_]+$/g, "");
45 +}
46 +
47 +function buildDefaultBody(commandType = "text") {
48 + if (commandType === "script") {
49 + return [
50 + "def run(payload):",
51 + " args = payload.get('arguments', {})",
52 + " flags = args.get('flags', {})",
53 + " positional = args.get('positional', [])",
54 + " return {",
55 + " 'text': f\"Script command received args: {positional} flags: {flags}\",",
56 + " 'effects': [],",
57 + " }",
58 + "",
59 + ].join("\n");
60 + }
61 + return "Describe the work to perform here.\n\n{raw}";
62 +}
63 +
64 +function notifyError(message) {
65 + void toastFrontendError(message, "Commands");
66 +}
67 +
68 +function notifySuccess(message) {
69 + void toastFrontendSuccess(message, "Commands");
70 +}
71 +
72 +function emitCommandsUpdated() {
73 + window.dispatchEvent(new CustomEvent("commands:updated"));
74 +}
75 +
76 +const model = {
77 + loading: false,
78 + saving: false,
79 + projects: [],
80 + projectName: "",
81 + scope: null,
82 + contextScope: { project_name: "" },
83 + commands: [],
84 + pendingScope: null,
85 + pendingCreate: null,
86 + editor: createEmptyEditor(),
87 + editorSnapshot: "",
88 +
89 + get selectedScopeLabel() {
90 + return this.scope?.scope_label || "Global";
91 + },
92 +
93 + get selectedScopeDirectory() {
94 + return this.scope?.directory_path || "";
95 + },
96 +
97 + get hasCommands() {
98 + return (this.commands || []).length > 0;
99 + },
100 +
101 + get editorTitle() {
102 + return this.editor.mode === "edit" ? "Edit Slash Command" : "Create Slash Command";
103 + },
104 +
105 + get editorDirty() {
106 + return this._serializeEditor() !== this.editorSnapshot;
107 + },
108 +
109 + get editorBodyLabel() {
110 + return this.editor.commandType === "script" ? "Python hook" : "Text template";
111 + },
112 +
113 + openManager(options = {}) {
114 + const hasExplicitScope = Object.prototype.hasOwnProperty.call(options, "projectName");
115 +
116 + this.pendingScope = hasExplicitScope
117 + ? {
118 + projectName: options.projectName || "",
119 + }
120 + : null;
121 +
122 + this.pendingCreate =
123 + options.openEditor || options.prefillName
124 + ? {
125 + name: options.prefillName || "",
126 + }
127 + : null;
128 +
129 + return window.openModal?.(MAIN_MODAL_PATH);
130 + },
131 +
132 + async onOpen() {
133 + await this.loadProjects();
134 +
135 + try {
136 + await this.resolveInitialScope();
137 + await this.loadCommands();
138 + } catch (error) {
139 + console.error("Failed to initialize commands manager:", error);
140 + this.scope = null;
141 + this.commands = [];
142 + notifyError(error?.message || "Failed to open the commands manager.");
143 + }
144 +
145 + if (this.pendingCreate) {
146 + const pendingCreate = { ...this.pendingCreate };
147 + this.pendingCreate = null;
148 + await this.openCreateCommand({ name: pendingCreate.name });
149 + }
150 + },
151 +
152 + cleanup() {
153 + this.loading = false;
154 + this.saving = false;
155 + this.projects = [];
156 + this.projectName = "";
157 + this.scope = null;
158 + this.contextScope = { project_name: "" };
159 + this.commands = [];
160 + this.pendingScope = null;
161 + this.pendingCreate = null;
162 + this.resetEditor();
163 + },
164 +
165 + async loadProjects() {
166 + try {
167 + const response = await callJsonApi("projects", { action: "list_options" });
168 + this.projects = Array.isArray(response?.data) ? response.data : [];
169 + } catch {
170 + this.projects = [];
171 + }
172 + },
173 +
174 + normalizeProject(projectName) {
175 + if (!projectName) return "";
176 + return (this.projects || []).some((project) => project?.key === projectName)
177 + ? projectName
178 + : "";
179 + },
180 +
181 + async resolveInitialScope() {
182 + const contextId =
183 + chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
184 + const scopeInfo = await callJsonApi(COMMANDS_API_PATH, {
185 + action: "scope_info",
186 + context_id: contextId,
187 + });
188 +
189 + this.contextScope = scopeInfo?.context_scope || {
190 + project_name: "",
191 + };
192 +
193 + const preferredScope = this.pendingScope || scopeInfo?.scope || {};
194 + this.projectName = this.normalizeProject(preferredScope.project_name || "");
195 + this.pendingScope = null;
196 + },
197 +
198 + async loadCommands() {
199 + this.loading = true;
200 +
201 + try {
202 + const response = await callJsonApi(COMMANDS_API_PATH, {
203 + action: "list_scope",
204 + project_name: this.projectName || "",
205 + });
206 +
207 + this.commands = Array.isArray(response?.commands) ? response.commands : [];
208 + this.scope = response?.scope || null;
209 + } catch (error) {
210 + console.error("Failed to load commands:", error);
211 + this.commands = [];
212 + this.scope = null;
213 + notifyError(error?.message || "Failed to load commands.");
214 + } finally {
215 + this.loading = false;
216 + }
217 + },
218 +
219 + async refresh() {
220 + await this.loadCommands();
221 + },
222 +
223 + async onScopeChanged() {
224 + this.projectName = this.normalizeProject(this.projectName);
225 + await this.loadCommands();
226 + },
227 +
228 + overrideBadgeLabel(command) {
229 + const count = Number(command?.override_count || 0);
230 + if (!count) return "";
231 + if (count === 1) {
232 + return `Overrides ${command.override_scopes[0]}`;
233 + }
234 + return `Overrides ${count} lower scopes`;
235 + },
236 +
237 + async browseScopeFolder() {
238 + try {
239 + const response = await callJsonApi(COMMANDS_API_PATH, {
240 + action: "scope_info",
241 + project_name: this.projectName || "",
242 + ensure_directory: true,
243 + });
244 + if (response?.scope?.directory_path) {
245 + await fileBrowserStore.open(response.scope.directory_path);
246 + }
247 + } catch (error) {
248 + console.error("Failed to open scope folder:", error);
249 + notifyError(error?.message || "Failed to open scope folder.");
250 + }
251 + },
252 +
253 + async openCreateCommand(options = {}) {
254 + if (Object.prototype.hasOwnProperty.call(options, "projectName")) {
255 + this.projectName = this.normalizeProject(options.projectName || "");
256 + await this.loadCommands();
257 + }
258 +
259 + const suggestedName = sanitizeCommandName(options.name || "");
260 + this.editor = {
261 + ...createEmptyEditor(),
262 + mode: "create",
263 + name: suggestedName,
264 + commandType: "text",
265 + body: buildDefaultBody("text"),
266 + };
267 + this.editorSnapshot = this._serializeEditor();
268 + await this.openEditorModal();
269 + },
270 +
271 + async openEditCommand(command) {
272 + if (!command?.path) return;
273 +
274 + try {
275 + const response = await callJsonApi(COMMANDS_API_PATH, {
276 + action: "get",
277 + path: command.path,
278 + project_name: this.projectName || "",
279 + });
280 + const loaded = response?.command || command;
281 + this.editor = {
282 + mode: "edit",
283 + existingPath: loaded.path || "",
284 + path: loaded.path || "",
285 + name: loaded.name || "",
286 + description: loaded.description || "",
287 + argumentHint: loaded.argument_hint || "",
288 + commandType: loaded.command_type || "text",
289 + includeHistory: Boolean(loaded.include_history),
290 + body: loaded.body || "",
291 + extraFrontmatter: loaded.frontmatter_extra || {},
292 + };
293 + this.editorSnapshot = this._serializeEditor();
294 + await this.openEditorModal();
295 + } catch (error) {
296 + console.error("Failed to load command:", error);
297 + notifyError(error?.message || "Failed to load command.");
298 + }
299 + },
300 +
301 + async duplicateCommand(command) {
302 + if (!command?.path) return;
303 +
304 + try {
305 + const response = await callJsonApi(COMMANDS_API_PATH, {
306 + action: "duplicate",
307 + path: command.path,
308 + project_name: this.projectName || "",
309 + });
310 + await this.loadCommands();
311 + emitCommandsUpdated();
312 + notifySuccess(`Duplicated /${response?.command?.name || command.name}`);
313 + if (response?.command) {
314 + await this.openEditCommand(response.command);
315 + }
316 + } catch (error) {
317 + console.error("Failed to duplicate command:", error);
318 + notifyError(error?.message || "Failed to duplicate command.");
319 + }
320 + },
321 +
322 + async deleteCommand(command) {
323 + if (!command?.path) return;
324 +
325 + try {
326 + await callJsonApi(COMMANDS_API_PATH, {
327 + action: "delete",
328 + path: command.path,
329 + project_name: this.projectName || "",
330 + });
331 + await this.loadCommands();
332 + emitCommandsUpdated();
333 + notifySuccess(`Deleted /${command.name}`);
334 + } catch (error) {
335 + console.error("Failed to delete command:", error);
336 + notifyError(error?.message || "Failed to delete command.");
337 + }
338 + },
339 +
340 + async openEditorModal() {
341 + await window.openModal?.(EDITOR_MODAL_PATH, () => this.confirmCloseEditor());
342 + this.resetEditor();
343 + },
344 +
345 + confirmCloseEditor() {
346 + if (!this.editorDirty) return true;
347 + return window.confirm("Discard unsaved slash command changes?");
348 + },
349 +
350 + async closeEditor() {
351 + await window.closeModal?.(EDITOR_MODAL_PATH);
352 + },
353 +
354 + setEditorType(nextType) {
355 + const normalizedType = nextType === "script" ? "script" : "text";
356 + if (this.editor.commandType === normalizedType) return;
357 + this.editor.commandType = normalizedType;
358 + this.editor.includeHistory =
359 + normalizedType === "script" ? this.editor.includeHistory : false;
360 + this.editor.body = buildDefaultBody(normalizedType);
361 + },
362 +
363 + async saveEditor() {
364 + this.saving = true;
365 +
366 + try {
367 + const response = await callJsonApi(COMMANDS_API_PATH, {
368 + action: "save",
369 + project_name: this.projectName || "",
370 + existing_path: this.editor.existingPath || "",
371 + name: this.editor.name || "",
372 + description: this.editor.description || "",
373 + argument_hint: this.editor.argumentHint || "",
374 + command_type: this.editor.commandType || "text",
375 + include_history:
376 + this.editor.commandType === "script" ? Boolean(this.editor.includeHistory) : false,
377 + body: this.editor.body || "",
378 + extra_frontmatter: this.editor.extraFrontmatter || {},
379 + });
380 +
381 + this.editor.path = response?.command?.path || "";
382 + this.editor.existingPath = response?.command?.path || "";
383 + this.editorSnapshot = this._serializeEditor();
384 + await this.loadCommands();
385 + emitCommandsUpdated();
386 + notifySuccess(
387 + `${this.editor.mode === "edit" ? "Updated" : "Saved"} /${response?.command?.name || this.editor.name}`,
388 + );
389 + await window.closeModal?.(EDITOR_MODAL_PATH);
390 + } catch (error) {
391 + console.error("Failed to save command:", error);
392 + notifyError(error?.message || "Failed to save command.");
393 + } finally {
394 + this.saving = false;
395 + }
396 + },
397 +
398 + resetEditor() {
399 + this.editor = createEmptyEditor();
400 + this.editorSnapshot = this._serializeEditor();
401 + },
402 +
403 + _serializeEditor() {
404 + return safeStringify({
405 + existingPath: this.editor.existingPath || "",
406 + name: this.editor.name || "",
407 + description: this.editor.description || "",
408 + argumentHint: this.editor.argumentHint || "",
409 + commandType: this.editor.commandType || "text",
410 + includeHistory: Boolean(this.editor.includeHistory),
411 + body: this.editor.body || "",
412 + extraFrontmatter: this.editor.extraFrontmatter || {},
413 + });
414 + },
415 +};
416 +
417 +export const store = createStore("commandsManager", model);
plugins/_commands/webui/editor.html new
+240
@@ -0,0 +1,240 @@
1 +<html>
2 +<head>
3 + <title>Slash Command</title>
4 + <script type="module">
5 + import { store } from "/plugins/_commands/webui/commands-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.commandsManager">
11 + <div class="commands-editor"
12 + x-create="$el.closest('.modal')?.querySelector('.modal-title') && ($el.closest('.modal').querySelector('.modal-title').textContent = $store.commandsManager.editorTitle)">
13 + <div class="commands-editor-header">
14 + <div class="commands-editor-copy">
15 + <div class="commands-editor-title" x-text="$store.commandsManager.editorTitle"></div>
16 + <div class="commands-editor-subtitle">
17 + Saving writes a <code>.command.yaml</code> config plus a template file in <span x-text="$store.commandsManager.selectedScopeLabel"></span>.
18 + </div>
19 + </div>
20 + </div>
21 +
22 + <div class="commands-editor-grid">
23 + <label class="commands-field">
24 + <span>Command name</span>
25 + <input type="text"
26 + x-model="$store.commandsManager.editor.name"
27 + placeholder="explain-code"
28 + autocomplete="off">
29 + </label>
30 +
31 + <label class="commands-field">
32 + <span>Description</span>
33 + <input type="text"
34 + x-model="$store.commandsManager.editor.description"
35 + placeholder="Explain code clearly with examples.">
36 + </label>
37 + </div>
38 +
39 + <label class="commands-field">
40 + <span>Argument hint or example</span>
41 + <input type="text"
42 + x-model="$store.commandsManager.editor.argumentHint"
43 + placeholder="Optional free-form text after /command">
44 + </label>
45 +
46 + <div class="commands-editor-grid">
47 + <label class="commands-field">
48 + <span>Command type</span>
49 + <select x-model="$store.commandsManager.editor.commandType"
50 + @change="$store.commandsManager.setEditorType($store.commandsManager.editor.commandType)">
51 + <option value="text">Text template (.txt)</option>
52 + <option value="script">Python hook (.py)</option>
53 + </select>
54 + </label>
55 +
56 + <template x-if="$store.commandsManager.editor.commandType === 'script'">
57 + <label class="commands-field commands-field-checkbox">
58 + <span>Script context</span>
59 + <div class="commands-checkbox-inline">
60 + <input type="checkbox" x-model="$store.commandsManager.editor.includeHistory">
61 + <span>Include chat history payload</span>
62 + </div>
63 + </label>
64 + </template>
65 + </div>
66 +
67 + <label class="commands-field commands-field-body">
68 + <span x-text="$store.commandsManager.editorBodyLabel"></span>
69 + <textarea x-model="$store.commandsManager.editor.body"
70 + rows="14"
71 + :placeholder="$store.commandsManager.editor.commandType === 'script'
72 + ? 'Implement run(payload) and return { text, effects }.'
73 + : 'Write reusable text. Use {raw}, {args.positional.0}, {args.flags.some_flag}.'"></textarea>
74 + </label>
75 +
76 + <template x-if="$store.commandsManager.editor.commandType === 'text'">
77 + <div class="commands-editor-help">
78 + <span class="commands-help-chip">{raw}</span>
79 + <span class="commands-help-chip">{args.positional.0}</span>
80 + <span class="commands-help-chip">{args.flags.git_url}</span>
81 + <span class="commands-help-copy">
82 + Trailing command input is parsed once and exposed to template placeholders and python payloads.
83 + </span>
84 + </div>
85 + </template>
86 +
87 + <template x-if="$store.commandsManager.editor.commandType === 'script'">
88 + <div class="commands-editor-help">
89 + <span class="commands-help-chip">run(payload)</span>
90 + <span class="commands-help-chip">payload.arguments</span>
91 + <span class="commands-help-chip">result.effects</span>
92 + <span class="commands-help-copy">
93 + Return a string or <code>{ text, effects }</code>. Effects support <code>replace_input</code>, <code>append_input</code>, and <code>toast</code>.
94 + </span>
95 + </div>
96 + </template>
97 +
98 + <template x-if="$store.commandsManager.editor.path">
99 + <div class="commands-editor-path">
100 + <span class="material-symbols-outlined">description</span>
101 + <span x-text="$store.commandsManager.editor.path"></span>
102 + </div>
103 + </template>
104 + </div>
105 + </template>
106 + </div>
107 +
108 + <div class="modal-footer" data-modal-footer>
109 + <button class="btn btn-ok"
110 + @click="$store.commandsManager.saveEditor()"
111 + :disabled="$store.commandsManager.saving">
112 + Save
113 + </button>
114 + <button class="btn btn-cancel" @click="$store.commandsManager.closeEditor()">
115 + Cancel
116 + </button>
117 + </div>
118 +
119 + <style>
120 + .commands-editor {
121 + display: flex;
122 + flex-direction: column;
123 + gap: 1rem;
124 + padding: 1rem 1.1rem 1.2rem;
125 + }
126 +
127 + .commands-editor-header {
128 + display: flex;
129 + justify-content: space-between;
130 + gap: 1rem;
131 + align-items: flex-start;
132 + }
133 +
134 + .commands-editor-copy {
135 + min-width: 0;
136 + }
137 +
138 + .commands-editor-title {
139 + font-size: 1.02rem;
140 + font-weight: 600;
141 + }
142 +
143 + .commands-editor-subtitle {
144 + margin-top: 0.25rem;
145 + color: var(--color-text-secondary);
146 + font-size: 0.88rem;
147 + line-height: 1.45;
148 + }
149 +
150 + .commands-editor-grid {
151 + display: grid;
152 + grid-template-columns: repeat(2, minmax(0, 1fr));
153 + gap: 0.85rem;
154 + }
155 +
156 + .commands-field {
157 + display: flex;
158 + flex-direction: column;
159 + gap: 0.4rem;
160 + }
161 +
162 + .commands-field span {
163 + font-size: 0.82rem;
164 + font-weight: 600;
165 + color: var(--color-text-secondary);
166 + }
167 +
168 + .commands-field textarea,
169 + .commands-field input {
170 + width: 100%;
171 + }
172 +
173 + .commands-field select {
174 + width: 100%;
175 + }
176 +
177 + .commands-field-checkbox {
178 + justify-content: flex-end;
179 + }
180 +
181 + .commands-checkbox-inline {
182 + display: inline-flex;
183 + align-items: center;
184 + gap: 0.45rem;
185 + color: var(--color-text-primary);
186 + font-size: 0.86rem;
187 + font-weight: 500;
188 + }
189 +
190 + .commands-checkbox-inline input {
191 + width: auto;
192 + }
193 +
194 + .commands-field-body textarea {
195 + min-height: 18rem;
196 + resize: vertical;
197 + font-family: "Roboto Mono", monospace;
198 + }
199 +
200 + .commands-editor-help {
201 + display: flex;
202 + flex-wrap: wrap;
203 + gap: 0.5rem;
204 + align-items: center;
205 + }
206 +
207 + .commands-help-chip {
208 + display: inline-flex;
209 + align-items: center;
210 + padding: 0.22rem 0.55rem;
211 + border-radius: 999px;
212 + background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
213 + border: 1px solid color-mix(in srgb, var(--color-highlight) 24%, transparent);
214 + font-family: "Roboto Mono", monospace;
215 + font-size: 0.8rem;
216 + }
217 +
218 + .commands-help-copy {
219 + color: var(--color-text-secondary);
220 + font-size: 0.85rem;
221 + }
222 +
223 + .commands-editor-path {
224 + display: inline-flex;
225 + align-items: center;
226 + gap: 0.45rem;
227 + color: var(--color-text-secondary);
228 + font-family: "Roboto Mono", monospace;
229 + font-size: 0.78rem;
230 + word-break: break-all;
231 + }
232 +
233 + @media (max-width: 760px) {
234 + .commands-editor-grid {
235 + grid-template-columns: 1fr;
236 + }
237 + }
238 + </style>
239 +</body>
240 +</html>
plugins/_commands/webui/main.html new
+395
@@ -0,0 +1,395 @@
1 +<html>
2 +<head>
3 + <title>Commands</title>
4 + <script type="module">
5 + import { store } from "/plugins/_commands/webui/commands-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.commandsManager">
11 + <div class="commands-manager" x-create="$store.commandsManager.onOpen()" x-destroy="$store.commandsManager.cleanup()">
12 + <div class="commands-toolbar">
13 + <div class="commands-toolbar-copy">
14 + <div class="commands-title">Slash Commands</div>
15 + <div class="commands-subtitle">
16 + YAML-configured commands for the inline chat composer.
17 + </div>
18 + </div>
19 +
20 + <div class="commands-toolbar-controls">
21 + <label class="commands-select">
22 + <span>Project</span>
23 + <select x-model="$store.commandsManager.projectName" @change="$store.commandsManager.onScopeChanged()">
24 + <option value="">Global</option>
25 + <template x-for="project in $store.commandsManager.projects" :key="project.key">
26 + <option :value="project.key" x-text="project.label"></option>
27 + </template>
28 + </select>
29 + </label>
30 +
31 + <div class="commands-toolbar-actions">
32 + <button type="button" class="button secondary" @click="$store.commandsManager.refresh()">
33 + <span class="material-symbols-outlined">refresh</span>
34 + <span>Refresh</span>
35 + </button>
36 + <button type="button" class="button secondary" @click="$store.commandsManager.browseScopeFolder()">
37 + <span class="material-symbols-outlined">folder_open</span>
38 + <span>Browse Scope Folder</span>
39 + </button>
40 + <button type="button" class="button primary" @click="$store.commandsManager.openCreateCommand()">
41 + <span class="material-symbols-outlined">add</span>
42 + <span>Create Command</span>
43 + </button>
44 + </div>
45 + </div>
46 + </div>
47 +
48 + <div class="commands-scope-banner" x-show="$store.commandsManager.scope">
49 + <div class="commands-scope-banner-label">
50 + <span class="material-symbols-outlined">target</span>
51 + <span x-text="$store.commandsManager.selectedScopeLabel"></span>
52 + </div>
53 + <div class="commands-scope-banner-path" x-text="$store.commandsManager.selectedScopeDirectory"></div>
54 + </div>
55 +
56 + <div class="commands-loading" x-show="$store.commandsManager.loading">
57 + <span class="material-symbols-outlined spinning">progress_activity</span>
58 + <span>Loading commands...</span>
59 + </div>
60 +
61 + <div class="commands-list" x-show="!$store.commandsManager.loading">
62 + <template x-if="$store.commandsManager.hasCommands">
63 + <div class="commands-grid">
64 + <template x-for="command in $store.commandsManager.commands" :key="command.path">
65 + <article class="commands-card">
66 + <div class="commands-card-header">
67 + <div class="commands-card-copy">
68 + <div class="commands-card-title">
69 + <span class="commands-slash">/</span><span x-text="command.name"></span>
70 + </div>
71 + <div class="commands-card-description" x-text="command.description"></div>
72 + </div>
73 +
74 + <div class="commands-card-actions">
75 + <button type="button" class="button icon" title="Edit command" @click="$store.commandsManager.openEditCommand(command)">
76 + <span class="material-symbols-outlined">edit</span>
77 + </button>
78 + <button type="button" class="button icon" title="Duplicate command" @click="$store.commandsManager.duplicateCommand(command)">
79 + <span class="material-symbols-outlined">content_copy</span>
80 + </button>
81 + <button type="button" class="button icon danger" title="Delete command" @click.stop="$confirmClick($event, () => $store.commandsManager.deleteCommand(command))">
82 + <span class="material-symbols-outlined">delete</span>
83 + </button>
84 + </div>
85 + </div>
86 +
87 + <div class="commands-card-badges">
88 + <span class="commands-badge scope" x-text="command.scope_label"></span>
89 + <span class="commands-badge type" x-text="command.command_type === 'script' ? 'Python Hook' : 'Text Template'"></span>
90 + <template x-if="command.override_count">
91 + <span class="commands-badge override" x-text="$store.commandsManager.overrideBadgeLabel(command)"></span>
92 + </template>
93 + </div>
94 +
95 + <template x-if="command.argument_hint">
96 + <div class="commands-card-hint">
97 + <span class="material-symbols-outlined">subdirectory_arrow_right</span>
98 + <span x-text="command.argument_hint"></span>
99 + </div>
100 + </template>
101 +
102 + <div class="commands-card-path" x-text="command.path"></div>
103 + </article>
104 + </template>
105 + </div>
106 + </template>
107 +
108 + <template x-if="!$store.commandsManager.hasCommands">
109 + <div class="commands-empty-state">
110 + <div class="commands-empty-title">No commands in this scope yet.</div>
111 + <div class="commands-empty-copy">
112 + Create YAML-configured slash commands here and they will appear from <code>/</code> in chat.
113 + </div>
114 + <button type="button" class="button primary" @click="$store.commandsManager.openCreateCommand()">
115 + <span class="material-symbols-outlined">add</span>
116 + <span>Create Slash Command</span>
117 + </button>
118 + </div>
119 + </template>
120 + </div>
121 + </div>
122 + </template>
123 + </div>
124 +
125 + <style>
126 + .commands-manager {
127 + display: flex;
128 + flex-direction: column;
129 + gap: 1rem;
130 + padding: 1rem;
131 + }
132 +
133 + .commands-toolbar {
134 + display: flex;
135 + flex-direction: column;
136 + gap: 1rem;
137 + padding: 1rem;
138 + border: 1px solid var(--color-border);
139 + border-radius: 12px;
140 + background: color-mix(in srgb, var(--color-panel) 88%, transparent);
141 + }
142 +
143 + .commands-toolbar-copy {
144 + display: flex;
145 + flex-direction: column;
146 + gap: 0.25rem;
147 + }
148 +
149 + .commands-title {
150 + font-size: 1.1rem;
151 + font-weight: 600;
152 + }
153 +
154 + .commands-subtitle {
155 + color: var(--color-text-secondary);
156 + font-size: 0.92rem;
157 + }
158 +
159 + .commands-toolbar-controls {
160 + display: flex;
161 + flex-wrap: wrap;
162 + gap: 0.75rem;
163 + align-items: end;
164 + }
165 +
166 + .commands-select {
167 + display: flex;
168 + flex-direction: column;
169 + gap: 0.35rem;
170 + min-width: 14rem;
171 + flex: 1 1 14rem;
172 + }
173 +
174 + .commands-select span {
175 + color: var(--color-text-secondary);
176 + font-size: 0.82rem;
177 + font-weight: 600;
178 + }
179 +
180 + .commands-toolbar-actions {
181 + display: flex;
182 + flex-wrap: wrap;
183 + gap: 0.5rem;
184 + margin-left: auto;
185 + }
186 +
187 + .commands-scope-banner {
188 + display: flex;
189 + flex-wrap: wrap;
190 + gap: 0.75rem;
191 + align-items: center;
192 + padding: 0.85rem 1rem;
193 + border: 1px solid var(--color-border);
194 + border-radius: 12px;
195 + background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
196 + }
197 +
198 + .commands-scope-banner-label {
199 + display: inline-flex;
200 + align-items: center;
201 + gap: 0.45rem;
202 + font-weight: 600;
203 + }
204 +
205 + .commands-scope-banner-path {
206 + color: var(--color-text-secondary);
207 + font-family: "Roboto Mono", monospace;
208 + font-size: 0.82rem;
209 + word-break: break-all;
210 + }
211 +
212 + .commands-loading {
213 + display: flex;
214 + align-items: center;
215 + gap: 0.5rem;
216 + padding: 0.9rem 1rem;
217 + border-radius: 10px;
218 + }
219 +
220 + .commands-loading {
221 + color: var(--color-text-secondary);
222 + border: 1px dashed var(--color-border);
223 + }
224 +
225 + .commands-grid {
226 + display: grid;
227 + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
228 + gap: 0.9rem;
229 + }
230 +
231 + .commands-card {
232 + display: flex;
233 + flex-direction: column;
234 + gap: 0.75rem;
235 + padding: 1rem;
236 + border: 1px solid var(--color-border);
237 + border-radius: 12px;
238 + background: color-mix(in srgb, var(--color-panel) 90%, transparent);
239 + }
240 +
241 + .commands-card-header {
242 + display: flex;
243 + gap: 0.75rem;
244 + justify-content: space-between;
245 + align-items: flex-start;
246 + }
247 +
248 + .commands-card-copy {
249 + min-width: 0;
250 + }
251 +
252 + .commands-card-title {
253 + font-size: 1rem;
254 + font-weight: 600;
255 + word-break: break-word;
256 + }
257 +
258 + .commands-slash {
259 + color: var(--color-highlight);
260 + }
261 +
262 + .commands-card-description {
263 + margin-top: 0.25rem;
264 + color: var(--color-text-secondary);
265 + font-size: 0.9rem;
266 + line-height: 1.45;
267 + }
268 +
269 + .commands-card-actions {
270 + display: inline-flex;
271 + gap: 0.35rem;
272 + flex-shrink: 0;
273 + }
274 +
275 + .commands-card-badges {
276 + display: flex;
277 + flex-wrap: wrap;
278 + gap: 0.45rem;
279 + }
280 +
281 + .commands-badge {
282 + display: inline-flex;
283 + align-items: center;
284 + padding: 0.22rem 0.5rem;
285 + border-radius: 999px;
286 + font-size: 0.74rem;
287 + font-weight: 600;
288 + border: 1px solid transparent;
289 + }
290 +
291 + .commands-badge.scope {
292 + background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
293 + border-color: color-mix(in srgb, var(--color-highlight) 24%, transparent);
294 + }
295 +
296 + .commands-badge.override {
297 + background: color-mix(in srgb, #f39c12 12%, transparent);
298 + border-color: color-mix(in srgb, #f39c12 28%, transparent);
299 + }
300 +
301 + .commands-badge.type {
302 + background: color-mix(in srgb, #5b8def 12%, transparent);
303 + border-color: color-mix(in srgb, #5b8def 30%, transparent);
304 + }
305 +
306 + .commands-card-hint {
307 + display: inline-flex;
308 + align-items: flex-start;
309 + gap: 0.35rem;
310 + color: var(--color-text-secondary);
311 + font-size: 0.86rem;
312 + }
313 +
314 + .commands-card-hint .material-symbols-outlined {
315 + font-size: 1rem;
316 + margin-top: 0.05rem;
317 + }
318 +
319 + .commands-card-path {
320 + color: var(--color-text-secondary);
321 + font-family: "Roboto Mono", monospace;
322 + font-size: 0.78rem;
323 + word-break: break-all;
324 + }
325 +
326 + .commands-empty-state {
327 + display: flex;
328 + flex-direction: column;
329 + align-items: flex-start;
330 + gap: 0.75rem;
331 + padding: 1.4rem;
332 + border: 1px dashed var(--color-border);
333 + border-radius: 12px;
334 + background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
335 + }
336 +
337 + .commands-empty-title {
338 + font-size: 1rem;
339 + font-weight: 600;
340 + }
341 +
342 + .commands-empty-copy {
343 + color: var(--color-text-secondary);
344 + max-width: 42rem;
345 + }
346 +
347 + .button.primary,
348 + .button.secondary,
349 + .button.icon {
350 + display: inline-flex;
351 + align-items: center;
352 + gap: 0.35rem;
353 + }
354 +
355 + .button.icon {
356 + padding: 0.45rem;
357 + min-width: 2.25rem;
358 + justify-content: center;
359 + }
360 +
361 + .button.icon.danger {
362 + color: var(--color-accent);
363 + }
364 +
365 + .spinning {
366 + animation: commands-spin 1s linear infinite;
367 + }
368 +
369 + @keyframes commands-spin {
370 + from { transform: rotate(0deg); }
371 + to { transform: rotate(360deg); }
372 + }
373 +
374 + @media (max-width: 760px) {
375 + .commands-toolbar-actions {
376 + margin-left: 0;
377 + }
378 +
379 + .commands-select {
380 + min-width: 0;
381 + flex-basis: 100%;
382 + }
383 +
384 + .commands-toolbar-actions {
385 + width: 100%;
386 + }
387 +
388 + .commands-toolbar-actions .button {
389 + flex: 1 1 100%;
390 + justify-content: center;
391 + }
392 + }
393 + </style>
394 +</body>
395 +</html>
plugins/_commands/webui/thumbnail.png
Binary files /dev/null and b/plugins/_commands/webui/thumbnail.png differ