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
+ "&": "&",
55
+ "<": "<",
56
+ ">": ">",
57
+ '"': """,
58
+ "'": "'",
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