From 9c9a4e00ca8483295ae44309dd1c571671a951c0 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:00:30 +0200 Subject: [PATCH] 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. --- plugins/AGENTS.md | 1 + plugins/_commands/AGENTS.md | 44 + plugins/_commands/LICENSE | 21 + plugins/_commands/README.md | 146 +++ plugins/_commands/__init__.py | 1 + plugins/_commands/api/commands.py | 165 +++ .../_commands/commands/attach.command.yaml | 5 + .../_commands/commands/browser.command.yaml | 5 + plugins/_commands/commands/chat.command.yaml | 5 + plugins/_commands/commands/chats.command.yaml | 6 + plugins/_commands/commands/clear.command.yaml | 4 + .../_commands/commands/compact.command.yaml | 4 + .../commands/computer-use.command.yaml | 5 + .../_commands/commands/connector_commands.py | 305 +++++ plugins/_commands/commands/copy.command.yaml | 4 + .../_commands/commands/models.command.yaml | 4 + plugins/_commands/commands/new.command.yaml | 4 + plugins/_commands/commands/nudge.command.yaml | 4 + plugins/_commands/commands/pause.command.yaml | 4 + .../_commands/commands/plugins.command.yaml | 4 + .../_commands/commands/presets.command.yaml | 4 + .../_commands/commands/profile.command.yaml | 5 + .../_commands/commands/project.command.yaml | 5 + plugins/_commands/commands/queue.command.yaml | 5 + plugins/_commands/commands/quit.command.yaml | 4 + .../_commands/commands/resume.command.yaml | 4 + plugins/_commands/commands/send.command.yaml | 4 + .../_commands/commands/status.command.yaml | 4 + .../_20_migrate_legacy_commands.py | 124 ++ .../chat-input-box-start/commands-menu.html | 192 +++ .../commands-entry.html | 9 + plugins/_commands/helpers/__init__.py | 1 + plugins/_commands/helpers/commands.py | 1150 +++++++++++++++++ plugins/_commands/plugin.yaml | 8 + .../commands-create-slash-command/SKILL.md | 72 ++ .../template.command.txt | 3 + .../template.command.yaml | 5 + plugins/_commands/tests/conftest.py | 11 + .../_commands/tests/test_commands_plugin.py | 367 ++++++ .../_commands/tests/test_legacy_migration.py | 97 ++ .../tests/test_plugin_command_discovery.py | 337 +++++ .../_commands/webui/commands-slash-store.js | 513 ++++++++ plugins/_commands/webui/commands-store.js | 417 ++++++ plugins/_commands/webui/editor.html | 240 ++++ plugins/_commands/webui/main.html | 395 ++++++ plugins/_commands/webui/thumbnail.png | Bin 0 -> 5637 bytes 46 files changed, 4717 insertions(+) create mode 100644 plugins/_commands/AGENTS.md create mode 100644 plugins/_commands/LICENSE create mode 100644 plugins/_commands/README.md create mode 100644 plugins/_commands/__init__.py create mode 100644 plugins/_commands/api/commands.py create mode 100644 plugins/_commands/commands/attach.command.yaml create mode 100644 plugins/_commands/commands/browser.command.yaml create mode 100644 plugins/_commands/commands/chat.command.yaml create mode 100644 plugins/_commands/commands/chats.command.yaml create mode 100644 plugins/_commands/commands/clear.command.yaml create mode 100644 plugins/_commands/commands/compact.command.yaml create mode 100644 plugins/_commands/commands/computer-use.command.yaml create mode 100644 plugins/_commands/commands/connector_commands.py create mode 100644 plugins/_commands/commands/copy.command.yaml create mode 100644 plugins/_commands/commands/models.command.yaml create mode 100644 plugins/_commands/commands/new.command.yaml create mode 100644 plugins/_commands/commands/nudge.command.yaml create mode 100644 plugins/_commands/commands/pause.command.yaml create mode 100644 plugins/_commands/commands/plugins.command.yaml create mode 100644 plugins/_commands/commands/presets.command.yaml create mode 100644 plugins/_commands/commands/profile.command.yaml create mode 100644 plugins/_commands/commands/project.command.yaml create mode 100644 plugins/_commands/commands/queue.command.yaml create mode 100644 plugins/_commands/commands/quit.command.yaml create mode 100644 plugins/_commands/commands/resume.command.yaml create mode 100644 plugins/_commands/commands/send.command.yaml create mode 100644 plugins/_commands/commands/status.command.yaml create mode 100644 plugins/_commands/extensions/python/startup_migration/_20_migrate_legacy_commands.py create mode 100644 plugins/_commands/extensions/webui/chat-input-box-start/commands-menu.html create mode 100644 plugins/_commands/extensions/webui/sidebar-quick-actions-main-start/commands-entry.html create mode 100644 plugins/_commands/helpers/__init__.py create mode 100644 plugins/_commands/helpers/commands.py create mode 100644 plugins/_commands/plugin.yaml create mode 100644 plugins/_commands/skills/commands-create-slash-command/SKILL.md create mode 100644 plugins/_commands/skills/commands-create-slash-command/template.command.txt create mode 100644 plugins/_commands/skills/commands-create-slash-command/template.command.yaml create mode 100644 plugins/_commands/tests/conftest.py create mode 100644 plugins/_commands/tests/test_commands_plugin.py create mode 100644 plugins/_commands/tests/test_legacy_migration.py create mode 100644 plugins/_commands/tests/test_plugin_command_discovery.py create mode 100644 plugins/_commands/webui/commands-slash-store.js create mode 100644 plugins/_commands/webui/commands-store.js create mode 100644 plugins/_commands/webui/editor.html create mode 100644 plugins/_commands/webui/main.html create mode 100644 plugins/_commands/webui/thumbnail.png diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index c5f604708..c913b12f1 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -70,6 +70,7 @@ Direct child DOX files: | [_browser/AGENTS.md](_browser/AGENTS.md) | Playwright browser tool, helpers, viewer, and browser panel UI. | | [_chat_branching/AGENTS.md](_chat_branching/AGENTS.md) | Chat branching from an existing message. | | [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. | +| [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. | | [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. | | [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. | | [_discovery/AGENTS.md](_discovery/AGENTS.md) | Welcome-screen plugin discovery cards and promotions. | diff --git a/plugins/_commands/AGENTS.md b/plugins/_commands/AGENTS.md new file mode 100644 index 000000000..92b2a89e7 --- /dev/null +++ b/plugins/_commands/AGENTS.md @@ -0,0 +1,44 @@ +# Commands Plugin DOX + +## Purpose + +- Own the built-in slash command manager and chat composer slash picker. +- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes. + +## Ownership + +- `plugin.yaml` owns the built-in `_commands` plugin metadata. +- `helpers/commands.py` owns command name sanitization, argument parsing, scope resolution, file persistence, plugin command discovery, and command invocation resolution. +- `api/commands.py` owns the Commands API actions used by the WebUI. +- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset. +- `commands/` owns bundled read-only slash command definitions shipped by `_commands`. +- `extensions/` owns the chat composer picker and sidebar quick-action entry. +- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace. +- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands. +- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery. + +## Local Contracts + +- The plugin identity is `_commands`; user-created command files live under `usr/plugins/_commands/commands/` or `usr/projects//.a0proj/plugins/_commands/commands/`. +- Each command is one `.command.yaml` config plus one same-directory `.txt` text template or `.py` script hook. +- Project commands override global commands, global commands override bundled `_commands/commands/` defaults, and bundled defaults override other plugin-distributed commands with the same name. +- Bundled `_commands/commands/` definitions and commands contributed by other plugins are read-only from this manager. +- Bundled command files use canonical command names only; do not ship alias-only built-ins such as `/img` for `/attach`. +- Command configs may set `webui_hidden: true` to stay resolvable but be omitted from the chat composer picker. +- Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself. +- 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. +- 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. + +## Work Guidance + +- Keep the command storage and route namespace aligned with `_commands`. +- Preserve unknown command config keys when editing commands. +- Keep WebUI paths pointed at `/plugins/_commands/...`. + +## Verification + +- Run `conda run -n a0 pytest plugins/_commands/tests` after backend or command contract changes. + +## Child DOX Index + +No child DOX files. diff --git a/plugins/_commands/LICENSE b/plugins/_commands/LICENSE new file mode 100644 index 000000000..e4e068e76 --- /dev/null +++ b/plugins/_commands/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Commands plugin contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/_commands/README.md b/plugins/_commands/README.md new file mode 100644 index 000000000..6758987da --- /dev/null +++ b/plugins/_commands/README.md @@ -0,0 +1,146 @@ +# Commands + +YAML-configured slash commands for Agent Zero. + +This plugin lets you define reusable `/commands` as `.command.yaml` files with either: + +- a `.txt` template body +- a `.py` script hook + +Commands are managed from the plugin modal and can be inserted directly from the chat composer when the first token starts with `/`. + +## Features + +- `.command.yaml` config files with command metadata +- Text template commands with `{}` placeholders and parsed args +- Python hook commands with parsed args and optional chat history payload +- Unified parser for positional args, free-form tail, and flags +- Scope-aware command resolution across project and global scopes +- Built-in A0 CLI connector command pack for common session, queue, model, project, browser, and connector status commands +- Slash picker in the chat composer with keyboard navigation and create-on-empty flow + +## Command File Model + +Each command is defined by one config file plus one content file in the same scope directory. +Set `webui_hidden: true` to keep a command resolvable while omitting it from the chat composer picker. + +Example text command: + +`scan.command.yaml` + +```yaml +name: scan +description: Scan a Git repository. +argument_hint: /scan --git-url https://github.com/org/repo +type: text +template_path: scan.txt +``` + +`scan.txt` + +```txt +Please scan repository: {args.flags.git_url} + +Raw input: +{raw} +``` + +Example python hook command: + +`optimize.command.yaml` + +```yaml +name: optimize +description: Optimize the current request. +argument_hint: /optimize 30% +type: script +script_path: optimize.py +include_history: true +``` + +`optimize.py` + +```python +def run(payload): + args = payload["arguments"] + pct = args["positional"][0] if args["positional"] else "10%" + return { + "text": f"Optimize this response by {pct}.", + "effects": [], + } +``` + +## Argument Parsing + +The parser supports: + +- Positional input: `/scan https://github.com/org/repo` +- Long flags: `/scan --git-url https://github.com/org/repo` +- Long flags with equals: `/scan --git-url=https://github.com/org/repo` +- Short flags and bundles: `/scan -v -q` or `/scan -vq` + +Parsed data is available to: + +- Text templates via `{}` placeholders: + - `{raw}` + - `{args.positional.0}` + - `{args.flags.git_url}` +- Python scripts via `payload["arguments"]` + +## Script Hook Contract + +Python hook file must expose: + +```python +def run(payload): ... +``` + +It can return: + +- `str` (used as replacement text) +- `dict` with: + - `text: str` (replacement text) + - `effects: list[dict]` + +Supported frontend effects: + +- `{"type": "replace_input", "text": "..."}` +- `{"type": "append_input", "text": "..."}` +- `{"type": "toast", "level": "info|error|success", "message": "..."}` +- Built-in UI effects for existing WebUI actions such as chat switching, modals, attachments, compaction, queue actions, transcript copy, and toast output + +## Scope Resolution + +Commands are discovered from these scope folders: + +- Project: `usr/projects//.a0proj/plugins/_commands/commands/` +- Global fallback: `usr/plugins/_commands/commands/` +- Built-in defaults: `plugins/_commands/commands/` +- Other enabled plugins: `plugins//commands/` or `usr/plugins//commands/` + +Precedence in the chat picker: + +1. Project +2. Global +3. Built-in `_commands` +4. Other plugin-distributed commands + +## Legacy Community Plugin Migration + +When the built-in `_commands` plugin starts, it migrates files from the older community `commands` plugin namespace: + +- Copies `usr/plugins/commands/commands/` into `usr/plugins/_commands/commands/` +- Copies `usr/plugins/commands/skills/` into `usr/plugins/_commands/skills/` +- Copies project and agent scoped `plugins/commands/commands/` folders to matching `plugins/_commands/commands/` folders +- Skips existing destination files +- Disables the legacy `commands` plugin roots so the WebUI does not load two slash-command popovers + +## UI Surfaces + +- Plugin modal: open the Commands manager from the Plugins dialog +- Sidebar quick action: terminal icon next to the Plugins button +- Chat composer: type `/` at the start of the inline input to browse commands + +## Agent Skill + +The plugin ships with `commands-create-slash-command`, a plugin-scoped skill that helps Agent Zero create or update command files. diff --git a/plugins/_commands/__init__.py b/plugins/_commands/__init__.py new file mode 100644 index 000000000..87a5785df --- /dev/null +++ b/plugins/_commands/__init__.py @@ -0,0 +1 @@ +"""Commands plugin package.""" diff --git a/plugins/_commands/api/commands.py b/plugins/_commands/api/commands.py new file mode 100644 index 000000000..9faf00315 --- /dev/null +++ b/plugins/_commands/api/commands.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from helpers.api import ApiHandler, Request, Response + +from plugins._commands.helpers import commands as commands_helper + + +class Commands(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + action = str(input.get("action", "") or "").strip() + + if action == "list_effective": + return self._list_effective(input) + if action == "list_scope": + return self._list_scope(input) + if action == "get": + return self._get(input) + if action == "save": + return self._save(input) + if action == "delete": + return self._delete(input) + if action == "duplicate": + return self._duplicate(input) + if action == "scope_info": + return self._scope_info(input) + if action == "resolve": + return await self._resolve(input) + + return Response(status=400, response=f"Unknown action: {action}") + + def _list_effective(self, input: dict) -> dict | Response: + context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or "")) + commands, scope = commands_helper.list_effective_commands( + project_name=context_scope["project_name"], + ) + commands = [ + command + for command in commands + if not command.get("frontmatter_extra", {}).get("webui_hidden") + ] + return { + "ok": True, + "commands": commands, + "scope": scope, + } + + def _list_scope(self, input: dict) -> dict | Response: + commands, scope = commands_helper.list_scope_commands( + project_name=str(input.get("project_name", "") or ""), + ) + return { + "ok": True, + "commands": commands, + "scope": scope, + } + + def _get(self, input: dict) -> dict | Response: + path = str(input.get("path", "") or "") + if not path: + return Response(status=400, response="Missing path") + + try: + command = commands_helper.get_command( + path, + project_name=str(input.get("project_name", "") or ""), + ) + except FileNotFoundError: + return Response(status=404, response="Command not found") + except ValueError as error: + return Response(status=400, response=str(error)) + + return {"ok": True, "command": command} + + def _save(self, input: dict) -> dict | Response: + try: + command = commands_helper.save_command( + project_name=str(input.get("project_name", "") or ""), + existing_path=str(input.get("existing_path", "") or ""), + name=str(input.get("name", "") or ""), + description=str(input.get("description", "") or ""), + argument_hint=str(input.get("argument_hint", "") or ""), + command_type=str(input.get("command_type", "text") or "text"), + body=str(input.get("body", "") or ""), + include_history=bool(input.get("include_history", False)), + extra_frontmatter=input.get("extra_frontmatter", {}) or {}, + ) + except FileExistsError as error: + return Response(status=409, response=str(error)) + except ValueError as error: + return Response(status=400, response=str(error)) + + return {"ok": True, "command": command} + + def _delete(self, input: dict) -> dict | Response: + path = str(input.get("path", "") or "") + if not path: + return Response(status=400, response="Missing path") + + try: + commands_helper.delete_command( + path, + project_name=str(input.get("project_name", "") or ""), + ) + except FileNotFoundError: + return Response(status=404, response="Command not found") + except ValueError as error: + return Response(status=400, response=str(error)) + + return {"ok": True} + + def _duplicate(self, input: dict) -> dict | Response: + path = str(input.get("path", "") or "") + if not path: + return Response(status=400, response="Missing path") + + try: + command = commands_helper.duplicate_command( + path, + project_name=str(input.get("project_name", "") or ""), + ) + except FileNotFoundError: + return Response(status=404, response="Command not found") + except ValueError as error: + return Response(status=400, response=str(error)) + + return {"ok": True, "command": command} + + def _scope_info(self, input: dict) -> dict | Response: + explicit_project = str(input.get("project_name", "") or "") + context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or "")) + + project_name = explicit_project if "project_name" in input else context_scope["project_name"] + + scope = commands_helper.get_scope_payload( + project_name=project_name, + ensure_directory=bool(input.get("ensure_directory", False)), + ) + return { + "ok": True, + "scope": commands_helper.strip_private_scope(scope), + "context_scope": context_scope, + } + + async def _resolve(self, input: dict) -> dict | Response: + path = str(input.get("path", "") or "") + if not path: + return Response(status=400, response="Missing path") + + slash_text = str(input.get("slash_text", "") or "") + if not slash_text: + return Response(status=400, response="Missing slash_text") + + try: + resolution = await commands_helper.resolve_command_invocation( + path=path, + slash_text=slash_text, + project_name=str(input.get("project_name", "") or ""), + context_id=str(input.get("context_id", "") or ""), + ) + except FileNotFoundError: + return Response(status=404, response="Command not found") + except ValueError as error: + return Response(status=400, response=str(error)) + + return {"ok": True, "resolution": resolution} diff --git a/plugins/_commands/commands/attach.command.yaml b/plugins/_commands/commands/attach.command.yaml new file mode 100644 index 000000000..081ca8426 --- /dev/null +++ b/plugins/_commands/commands/attach.command.yaml @@ -0,0 +1,5 @@ +name: attach +description: Attach local image file(s) to the next message. +argument_hint: Choose local file(s) from the WebUI picker. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/browser.command.yaml b/plugins/_commands/commands/browser.command.yaml new file mode 100644 index 000000000..09755852a --- /dev/null +++ b/plugins/_commands/commands/browser.command.yaml @@ -0,0 +1,5 @@ +name: browser +description: Choose Browser host/container mode and manage host-browser control. +argument_hint: "[host|container|status]" +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/chat.command.yaml b/plugins/_commands/commands/chat.command.yaml new file mode 100644 index 000000000..f65ef20bc --- /dev/null +++ b/plugins/_commands/commands/chat.command.yaml @@ -0,0 +1,5 @@ +name: chat +description: Switch to a chat context by id. +argument_hint: "" +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/chats.command.yaml b/plugins/_commands/commands/chats.command.yaml new file mode 100644 index 000000000..85b18b9a9 --- /dev/null +++ b/plugins/_commands/commands/chats.command.yaml @@ -0,0 +1,6 @@ +name: chats +description: List previous chats (default sorted by last updated). Use --project to filter by active project. +argument_hint: "[--project|--all-projects] [--sort=updated|created|name]" +type: script +script_path: connector_commands.py +webui_hidden: true diff --git a/plugins/_commands/commands/clear.command.yaml b/plugins/_commands/commands/clear.command.yaml new file mode 100644 index 000000000..0b52d1464 --- /dev/null +++ b/plugins/_commands/commands/clear.command.yaml @@ -0,0 +1,4 @@ +name: clear +description: Clear the visible chat log. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/compact.command.yaml b/plugins/_commands/commands/compact.command.yaml new file mode 100644 index 000000000..afd41eb92 --- /dev/null +++ b/plugins/_commands/commands/compact.command.yaml @@ -0,0 +1,4 @@ +name: compact +description: Open the connector-backed compaction confirmation flow. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/computer-use.command.yaml b/plugins/_commands/commands/computer-use.command.yaml new file mode 100644 index 000000000..f0b4e68ce --- /dev/null +++ b/plugins/_commands/commands/computer-use.command.yaml @@ -0,0 +1,5 @@ +name: computer-use +description: Turn local Computer Use on or off. +argument_hint: "[on|off|status]" +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/connector_commands.py b/plugins/_commands/commands/connector_commands.py new file mode 100644 index 000000000..1f2073e98 --- /dev/null +++ b/plugins/_commands/commands/connector_commands.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +from typing import Any + +from agent import AgentContext +from helpers import message_queue as mq +from helpers import plugins, projects +from helpers.integration_commands import try_handle_command +from helpers.state_monitor_integration import mark_dirty_for_context + +CLI_ONLY = { + "quit": "Quit is an A0 CLI shell command. Close this browser tab or stop the WebUI session when you are done.", +} + + +def run(payload: dict[str, Any]) -> dict[str, Any]: + invocation = payload.get("invocation") or {} + raw_name = str(invocation.get("command_name") or "").strip().lower() + command = raw_name + raw_args = str(invocation.get("raw_arguments") or "").strip() + arguments = invocation.get("arguments") if isinstance(invocation.get("arguments"), dict) else {} + context_id = str((payload.get("context") or {}).get("context_id") or "").strip() + context = _context(context_id) + + if command == "new": + return _effects(_toast("Created a new chat."), {"type": "new_chat"}) + if command == "chat": + return _handle_chat(arguments) + if command == "chats": + return _show_markdown("Chats", _chat_list(context, arguments)) + if command == "clear": + return _effects(_toast("Visible transcript cleared."), {"type": "clear_transcript"}) + if command == "project": + return _handle_project(context, raw_args) + if command == "profile": + return _handle_profile(context, raw_args) + if command == "plugins": + return _effects({"type": "open_modal", "path": "/components/plugins/list/plugin-list.html"}) + if command == "compact": + return _effects({"type": "compact_chat"}) + if command == "pause": + return _effects(_toast("Pause requested."), {"type": "pause_agent", "paused": True}) + if command == "resume": + return _effects(_toast("Resume requested."), {"type": "pause_agent", "paused": False}) + if command == "nudge": + return _effects(_toast("Nudge sent."), {"type": "nudge_agent"}) + if command == "send": + return _handle_queue(context, ["send"]) + if command == "queue": + return _handle_queue(context, list(arguments.get("tokens") or [])) + if command == "presets": + return _effects({"type": "open_modal", "path": "/plugins/_model_config/webui/main.html"}) + if command == "models": + return _handle_models(context, raw_args) + if command == "browser": + return _handle_browser(context, raw_args) + if command == "attach": + return _effects({"type": "attach_files"}) + if command == "computer-use": + return _show_markdown("Computer Use", _computer_use_status(context_id, raw_args)) + if command == "copy": + return _effects({"type": "copy_transcript"}) + if command == "status": + return _show_markdown("Status", _status(context)) + if command in CLI_ONLY: + return _effects(_toast(CLI_ONLY[command], level="info")) + + return _effects(_toast(f"Unknown command: /{raw_name or command}", level="error")) + + +def _context(context_id: str) -> AgentContext | None: + if context_id: + return AgentContext.get(context_id) + return AgentContext.current() or AgentContext.first() + + +def _require_context(context: AgentContext | None) -> str | None: + if context: + return None + return "Open or create a chat context first." + + +def _effects(*effects: dict[str, Any]) -> dict[str, Any]: + return {"text": "", "effects": [effect for effect in effects if effect]} + + +def _toast(message: str, *, level: str = "success") -> dict[str, Any]: + return {"type": "toast", "message": message, "level": level} + + +def _show_markdown(title: str, content: str) -> dict[str, Any]: + return _effects({"type": "show_markdown", "title": title, "content": content}) + + +def _handle_chat(arguments: dict[str, Any]) -> dict[str, Any]: + selector = str((arguments.get("positional") or [""])[0] or "").strip() + if not selector: + return _effects(_toast("Usage: /chat ", level="error")) + if not AgentContext.get(selector): + return _effects(_toast(f"Chat context '{selector}' was not found.", level="error")) + return _effects(_toast(f"Switched to {selector}."), {"type": "select_chat", "context_id": selector}) + + +def _handle_project(context: AgentContext | None, raw_args: str) -> dict[str, Any]: + if not raw_args: + return _effects({"type": "open_modal", "path": "/components/projects/project-list.html"}) + error = _require_context(context) + if error: + return _effects(_toast(error, level="error")) + return _show_markdown("Project", try_handle_command(context, f"/project {raw_args}") or "") + + +def _handle_profile(context: AgentContext | None, raw_args: str) -> dict[str, Any]: + if not raw_args: + return _effects({"type": "open_modal", "path": "/components/settings/settings.html"}) + error = _require_context(context) + if error: + return _effects(_toast(error, level="error")) + return _show_markdown("Agent Profile", try_handle_command(context, f"/agent {raw_args}") or "") + + +def _handle_models(context: AgentContext | None, raw_args: str) -> dict[str, Any]: + return _effects({"type": "open_plugin_config", "plugin": "_model_config"}) + + +def _handle_browser(context: AgentContext | None, raw_args: str) -> dict[str, Any]: + args = raw_args.strip().lower().replace("-", "_").split() + action = args[0] if args else "" + if not action: + return _effects({"type": "open_modal", "path": "/plugins/_browser/webui/main.html"}) + if action in {"status", "state"}: + return _show_markdown("Browser", _browser_status(context)) + if action not in {"host", "container", "docker"}: + return _effects(_toast("Usage: /browser [host|container|status]", level="error")) + + project_name = projects.get_context_project_name(context) if context else "" + settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {} + settings["runtime_backend"] = "host_required" if action == "host" else "container" + plugins.save_plugin_config("_browser", project_name or "", "", settings) + if context: + mark_dirty_for_context(context.id, reason="plugins._commands.browser_runtime") + label = "Host browser through A0 CLI" if settings["runtime_backend"] == "host_required" else "Internal Docker browser" + return _effects(_toast(f"Browser runtime set to {label}.")) + + +def _browser_status(context: AgentContext | None) -> str: + project_name = projects.get_context_project_name(context) if context else "" + settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {} + runtime = str(settings.get("runtime_backend") or "container") + label = "Host browser through A0 CLI" if runtime == "host_required" else "Internal Docker browser" + return f"Browser runtime: {label}\n\nUse `/browser host` or `/browser container` to switch." + + +def _handle_queue(context: AgentContext | None, tokens: list[str]) -> dict[str, Any]: + error = _require_context(context) + if error: + return _effects(_toast(error, level="error")) + + queue = mq.get_queue(context) + if not tokens: + return _show_markdown("Queue", _queue_summary(queue)) + + action = str(tokens[0] or "").lower() + if action in {"send", "all", "flush"}: + if not queue: + return _effects(_toast("No queued messages.")) + sent_count = mq.send_all_aggregated(context) + mark_dirty_for_context(context.id, reason="plugins._commands.queue_send") + noun = "message" if sent_count == 1 else "messages" + return _effects(_toast(f"Sent {sent_count} queued {noun}.")) + + if action in {"clear", "delete"} and len(tokens) == 1: + mq.remove(context) + mark_dirty_for_context(context.id, reason="plugins._commands.queue_clear") + return _effects(_toast("Queue cleared.")) + + if action in {"remove", "rm", "delete"}: + if len(tokens) < 2: + return _effects(_toast("Usage: /queue remove ", level="error")) + item_id = _queue_selector_to_id(queue, str(tokens[1])) + if not item_id: + return _effects(_toast(f"No queued message matches '{tokens[1]}'.", level="error")) + mq.remove(context, item_id) + mark_dirty_for_context(context.id, reason="plugins._commands.queue_remove") + return _effects(_toast("Queued message removed.")) + + return _effects(_toast("Usage: /queue [send|clear|remove ]", level="error")) + + +def _queue_summary(queue: list[dict[str, Any]]) -> str: + if not queue: + return "No queued messages." + lines = [f"Queued messages ({len(queue)}):"] + for index, item in enumerate(queue, start=1): + text = str(item.get("text") or "").strip() or "(attachment only)" + if len(text) > 100: + text = text[:97].rstrip() + "..." + attachments = item.get("attachments") or [] + suffix = f" [{len(attachments)} files]" if attachments else "" + lines.append(f"{index}. {text}{suffix}") + return "\n".join(lines) + + +def _queue_selector_to_id(queue: list[dict[str, Any]], selector: str) -> str: + value = selector.strip() + if value.isdigit(): + index = int(value) - 1 + if 0 <= index < len(queue): + return str(queue[index].get("id") or "") + return "" + return value + + +def _chat_list(context: AgentContext | None, arguments: dict[str, Any]) -> str: + items = list(AgentContext.all()) + flags = arguments.get("flags") or {} + active_project_only = bool(flags.get("project") or flags.get("active_project") or flags.get("p")) + sort_by = str(flags.get("sort") or "").lower() + positional = [str(item).lower() for item in (arguments.get("positional") or [])] + if not sort_by: + sort_by = next((item for item in positional if item in {"updated", "created", "name"}), "updated") + if sort_by not in {"updated", "created", "name"}: + return "Usage: /chats [--project|--all-projects] [--sort=updated|created|name]" + + if active_project_only and context: + project_name = projects.get_context_project_name(context) or "" + items = [item for item in items if (projects.get_context_project_name(item) or "") == project_name] + + def sort_key(item: AgentContext) -> Any: + output = item.output() + if sort_by == "name": + return (item.name or item.id).casefold() + if sort_by == "created": + return str(output.get("created_at") or "") + return str(output.get("last_message") or output.get("created_at") or "") + + items = sorted(items, key=sort_key, reverse=sort_by != "name") + if not items: + return "No chats found." + + lines = ["| Chat | Context | State |", "| --- | --- | --- |"] + for item in items[:30]: + marker = "current" if context and item.id == context.id else ("running" if item.is_running() else "idle") + lines.append(f"| {_escape_cell(item.name or item.id)} | `{item.id}` | {marker} |") + if len(items) > 30: + lines.append(f"\nShowing 30 of {len(items)} chats.") + return "\n".join(lines) + + +def _status(context: AgentContext | None) -> str: + error = _require_context(context) + if error: + return error + project_name = projects.get_context_project_name(context) or "none" + profile = getattr(context.agent0.config, "profile", "default") if context.agent0 else "default" + running = "running" if context.is_running() else "idle" + if getattr(context, "paused", False): + running = "paused" + return "\n".join( + [ + f"Context: `{context.id}`", + f"State: {running}", + f"Project: {project_name}", + f"Agent profile: {profile}", + f"Queued messages: {len(mq.get_queue(context))}", + ] + ) + + +def _computer_use_status(context_id: str, raw_args: str) -> str: + from plugins._a0_connector.helpers import ws_runtime + + action = "-".join(part.strip().lower().replace("_", "-") for part in raw_args.split()) or "status" + sids = ws_runtime.remote_tool_sids_for_context(context_id) if context_id else sorted(ws_runtime.connected_sids()) + if not sids: + return ( + "No A0 CLI is connected to this WebUI session.\n\n" + "Computer Use requires the CLI because the desktop permission prompt and native backend live on the CLI host. " + "Start A0 CLI, connect it to this Agent Zero instance, then run `/computer-use on` in the CLI." + ) + + if action in {"on", "off", "enable", "disable", "enabled", "disabled", "true", "false", "yes", "no", "1", "0"}: + return ( + "Computer Use must be armed from the connected A0 CLI because it controls local desktop permissions.\n\n" + "Run `/computer-use on` or `/computer-use off` in the CLI terminal." + ) + + lines = ["Connected A0 CLI sessions:"] + for sid in sids: + metadata = ws_runtime.computer_use_metadata_for_sid(sid) or {} + if not metadata: + lines.append(f"- `{sid}`: connected, but not advertising Computer Use metadata.") + continue + state = "enabled" if metadata.get("enabled") else "disabled" + supported = "supported" if metadata.get("supported") else "unsupported" + status = str(metadata.get("status") or "unknown") + detail = str(metadata.get("last_error") or metadata.get("support_reason") or "").strip() + suffix = f" ({detail})" if detail else "" + lines.append(f"- `{sid}`: {state}, {supported}, status: {status}{suffix}") + lines.append("\nUse `/computer-use on|off|status` in the CLI to change local Computer Use.") + return "\n".join(lines) + + +def _escape_cell(value: str) -> str: + return str(value).replace("|", "\\|") diff --git a/plugins/_commands/commands/copy.command.yaml b/plugins/_commands/commands/copy.command.yaml new file mode 100644 index 000000000..dca727448 --- /dev/null +++ b/plugins/_commands/commands/copy.command.yaml @@ -0,0 +1,4 @@ +name: copy +description: Copy the currently visible transcript text to the clipboard. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/models.command.yaml b/plugins/_commands/commands/models.command.yaml new file mode 100644 index 000000000..4efdcd59a --- /dev/null +++ b/plugins/_commands/commands/models.command.yaml @@ -0,0 +1,4 @@ +name: models +description: Open Main/Utility model runtime editor. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/new.command.yaml b/plugins/_commands/commands/new.command.yaml new file mode 100644 index 000000000..0f8cf5610 --- /dev/null +++ b/plugins/_commands/commands/new.command.yaml @@ -0,0 +1,4 @@ +name: new +description: Create a brand-new empty chat context. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/nudge.command.yaml b/plugins/_commands/commands/nudge.command.yaml new file mode 100644 index 000000000..753aa1326 --- /dev/null +++ b/plugins/_commands/commands/nudge.command.yaml @@ -0,0 +1,4 @@ +name: nudge +description: Nudge the current agent run. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/pause.command.yaml b/plugins/_commands/commands/pause.command.yaml new file mode 100644 index 000000000..7c47ccb3c --- /dev/null +++ b/plugins/_commands/commands/pause.command.yaml @@ -0,0 +1,4 @@ +name: pause +description: Pause the active agent run. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/plugins.command.yaml b/plugins/_commands/commands/plugins.command.yaml new file mode 100644 index 000000000..aa4f730b4 --- /dev/null +++ b/plugins/_commands/commands/plugins.command.yaml @@ -0,0 +1,4 @@ +name: plugins +description: Open the installed-only Agent Zero plugin toggle view. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/presets.command.yaml b/plugins/_commands/commands/presets.command.yaml new file mode 100644 index 000000000..fb9184e3f --- /dev/null +++ b/plugins/_commands/commands/presets.command.yaml @@ -0,0 +1,4 @@ +name: presets +description: Open preset picker with Main/Utility model details. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/profile.command.yaml b/plugins/_commands/commands/profile.command.yaml new file mode 100644 index 000000000..082585831 --- /dev/null +++ b/plugins/_commands/commands/profile.command.yaml @@ -0,0 +1,5 @@ +name: profile +description: Pick or set the active Agent Zero Core profile. +argument_hint: "[profile]" +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/project.command.yaml b/plugins/_commands/commands/project.command.yaml new file mode 100644 index 000000000..9d86c7785 --- /dev/null +++ b/plugins/_commands/commands/project.command.yaml @@ -0,0 +1,5 @@ +name: project +description: Open the project menu, or switch directly with /project . +argument_hint: "[name]" +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/queue.command.yaml b/plugins/_commands/commands/queue.command.yaml new file mode 100644 index 000000000..4e87774c4 --- /dev/null +++ b/plugins/_commands/commands/queue.command.yaml @@ -0,0 +1,5 @@ +name: queue +description: Show, send, clear, or remove queued messages. +argument_hint: "[send|clear|remove ]" +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/quit.command.yaml b/plugins/_commands/commands/quit.command.yaml new file mode 100644 index 000000000..e7f6c7373 --- /dev/null +++ b/plugins/_commands/commands/quit.command.yaml @@ -0,0 +1,4 @@ +name: quit +description: Disconnect and exit the CLI. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/resume.command.yaml b/plugins/_commands/commands/resume.command.yaml new file mode 100644 index 000000000..76c1587a6 --- /dev/null +++ b/plugins/_commands/commands/resume.command.yaml @@ -0,0 +1,4 @@ +name: resume +description: Resume a paused agent run. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/send.command.yaml b/plugins/_commands/commands/send.command.yaml new file mode 100644 index 000000000..12d69252e --- /dev/null +++ b/plugins/_commands/commands/send.command.yaml @@ -0,0 +1,4 @@ +name: send +description: Send all queued messages now. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/commands/status.command.yaml b/plugins/_commands/commands/status.command.yaml new file mode 100644 index 000000000..c3e240325 --- /dev/null +++ b/plugins/_commands/commands/status.command.yaml @@ -0,0 +1,4 @@ +name: status +description: Show this chat's project, model, agent, and queue state. +type: script +script_path: connector_commands.py diff --git a/plugins/_commands/extensions/python/startup_migration/_20_migrate_legacy_commands.py b/plugins/_commands/extensions/python/startup_migration/_20_migrate_legacy_commands.py new file mode 100644 index 000000000..0216cfdeb --- /dev/null +++ b/plugins/_commands/extensions/python/startup_migration/_20_migrate_legacy_commands.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from helpers import cache, files, plugins +from helpers.extension import Extension +from helpers.print_style import PrintStyle + + +LEGACY_PLUGIN_NAME = "commands" +PLUGIN_NAME = "_commands" +COMMANDS_DIR = "commands" +SKILLS_DIR = "skills" + + +class LegacyCommandsMigration(Extension): + def execute(self, **kwargs): + result = migrate_legacy_commands() + if result["copied_commands"] or result["copied_skills"] or result["disabled_roots"]: + PrintStyle.info("Migrated legacy commands plugin data:", result) + + +def migrate_legacy_commands(base_dir: str | Path | None = None) -> dict[str, Any]: + root = Path(base_dir or files.get_abs_path("")).resolve() + result: dict[str, Any] = { + "copied_commands": 0, + "copied_skills": 0, + "disabled_roots": 0, + } + + legacy_plugin_dir = root / "usr" / "plugins" / LEGACY_PLUGIN_NAME + if not legacy_plugin_dir.exists() and not _legacy_scoped_plugin_dirs(root): + return result + + for legacy_commands_dir in _legacy_command_dirs(root): + target = _replace_plugin_segment(legacy_commands_dir, PLUGIN_NAME) + result["copied_commands"] += _copy_tree_files(legacy_commands_dir, target) + + result["copied_skills"] += _copy_tree_files( + legacy_plugin_dir / SKILLS_DIR, + root / "usr" / "plugins" / PLUGIN_NAME / SKILLS_DIR, + ) + + for legacy_plugin_root in _legacy_plugin_roots(root): + if _disable_legacy_plugin_root(legacy_plugin_root): + result["disabled_roots"] += 1 + + if result["disabled_roots"]: + _clear_runtime_caches() + + return result + + +def _legacy_command_dirs(root: Path) -> list[Path]: + return [ + plugin_root / COMMANDS_DIR + for plugin_root in _legacy_plugin_roots(root) + if (plugin_root / COMMANDS_DIR).is_dir() + ] + + +def _legacy_plugin_roots(root: Path) -> list[Path]: + roots = [] + for candidate in [ + root / "usr" / "plugins" / LEGACY_PLUGIN_NAME, + *root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"), + *root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"), + *root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"), + ]: + if candidate.exists() and candidate not in roots: + roots.append(candidate) + return roots + + +def _legacy_scoped_plugin_dirs(root: Path) -> list[Path]: + return [ + *root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"), + *root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"), + *root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"), + ] + + +def _replace_plugin_segment(path: Path, plugin_name: str) -> Path: + parts = list(path.parts) + for index in range(len(parts) - 1): + if parts[index] == "plugins" and parts[index + 1] == LEGACY_PLUGIN_NAME: + parts[index + 1] = plugin_name + return Path(*parts) + return path + + +def _copy_tree_files(source: Path, target: Path) -> int: + if not source.is_dir(): + return 0 + + copied = 0 + for source_file in source.rglob("*"): + if not source_file.is_file(): + continue + relative_path = source_file.relative_to(source) + target_file = target / relative_path + if target_file.exists(): + continue + target_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_file, target_file) + copied += 1 + return copied + + +def _disable_legacy_plugin_root(plugin_root: Path) -> bool: + plugin_root.mkdir(parents=True, exist_ok=True) + enabled_file = plugin_root / plugins.ENABLED_FILE_NAME + disabled_file = plugin_root / plugins.DISABLED_FILE_NAME + changed = enabled_file.exists() or not disabled_file.exists() + enabled_file.unlink(missing_ok=True) + disabled_file.write_text("", encoding="utf-8") + return changed + + +def _clear_runtime_caches() -> None: + cache.clear("*(plugins)*") + cache.clear("*(extensions)*") diff --git a/plugins/_commands/extensions/webui/chat-input-box-start/commands-menu.html b/plugins/_commands/extensions/webui/chat-input-box-start/commands-menu.html new file mode 100644 index 000000000..69e67f561 --- /dev/null +++ b/plugins/_commands/extensions/webui/chat-input-box-start/commands-menu.html @@ -0,0 +1,192 @@ + + + + + +
+ +
+ + + + diff --git a/plugins/_commands/extensions/webui/sidebar-quick-actions-main-start/commands-entry.html b/plugins/_commands/extensions/webui/sidebar-quick-actions-main-start/commands-entry.html new file mode 100644 index 000000000..f8be2790f --- /dev/null +++ b/plugins/_commands/extensions/webui/sidebar-quick-actions-main-start/commands-entry.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/plugins/_commands/helpers/__init__.py b/plugins/_commands/helpers/__init__.py new file mode 100644 index 000000000..390522358 --- /dev/null +++ b/plugins/_commands/helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers for the commands plugin.""" diff --git a/plugins/_commands/helpers/commands.py b/plugins/_commands/helpers/commands.py new file mode 100644 index 000000000..24544c1aa --- /dev/null +++ b/plugins/_commands/helpers/commands.py @@ -0,0 +1,1150 @@ +from __future__ import annotations + +import inspect +import json +import os +import re +import runpy +import shlex +from pathlib import Path +from typing import Any + +import yaml + +from agent import AgentContext +from helpers import files, plugins, projects, yaml as yaml_helper +from helpers.skills import split_frontmatter + + +PLUGIN_NAME = "_commands" +COMMANDS_DIR = "commands" +COMMAND_CONFIG_SUFFIX = ".command.yaml" +LEGACY_COMMAND_FILE_SUFFIX = ".command.md" +TEXT_TEMPLATE_SUFFIX = ".txt" +SCRIPT_TEMPLATE_SUFFIX = ".py" +STANDARD_CONFIG_KEYS = { + "name", + "description", + "argument_hint", + "type", + "template_path", + "script_path", + "include_history", +} +_INVALID_COMMAND_CHARS_RE = re.compile(r"[^a-z0-9_-]+") +_MULTI_DASH_RE = re.compile(r"-{2,}") +_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z0-9_.-]+)\}") + + +def sanitize_command_name(raw_name: str) -> str: + """Sanitize a raw command name to a lowercase, hyphen-separated slug. + + Strips whitespace, lowercases, replaces spaces and invalid characters with hyphens, + collapses consecutive hyphens, and strips leading/trailing hyphens and underscores. + + Raises: + ValueError: If the resulting name is empty. + + """ + raw = (raw_name or "").strip().lower() + name = raw.replace(" ", "-") + name = _INVALID_COMMAND_CHARS_RE.sub("-", name) + name = _MULTI_DASH_RE.sub("-", name).strip("-_") + if not name: + raise ValueError("Command name must contain at least one letter or number") + return name + + +def normalize_command_type(raw_type: str) -> str: + """Normalize a command type string to either ``"text"`` or ``"script"``.""" + command_type = (raw_type or "text").strip().lower() + if command_type not in {"text", "script"}: + raise ValueError('Command type must be either "text" or "script"') + return command_type + + +def command_file_name(command_name: str) -> str: + """Return the ``.command.yaml`` config filename for *command_name*.""" + return f"{sanitize_command_name(command_name)}{COMMAND_CONFIG_SUFFIX}" + + +def command_content_file_name(command_name: str, command_type: str) -> str: + """Return the content filename (``.txt`` or ``.py``) for *command_name* and *command_type*.""" + suffix = ( + TEXT_TEMPLATE_SUFFIX + if normalize_command_type(command_type) == "text" + else SCRIPT_TEMPLATE_SUFFIX + ) + return f"{sanitize_command_name(command_name)}{suffix}" + + +def parse_slash_invocation(raw_message: str, *, fallback_command: str = "") -> dict[str, Any]: + """Parse a raw slash-command message into its component parts. + + Returns a dict with keys: ``raw_text``, ``command_name``, ``raw_arguments``, + and ``arguments`` (parsed by :func:`parse_arguments`). + + Args: + raw_message: The full message string, e.g. ``"/scan --url https://example.com"``. + fallback_command: Command name to use when no slash prefix is found. + + """ + text = (raw_message or "").strip() + slash_match = re.match(r"^/([^\s]+)(?:\s+([\s\S]*))?$", text) + if slash_match: + try: + command_name = sanitize_command_name(slash_match.group(1)) + except ValueError: + command_name = sanitize_command_name(fallback_command) if fallback_command else "" + raw_arguments = (slash_match.group(2) or "").strip() + else: + command_name = sanitize_command_name(fallback_command) if fallback_command else "" + raw_arguments = text + + parsed_arguments = parse_arguments(raw_arguments) + return { + "raw_text": text, + "command_name": command_name, + "raw_arguments": raw_arguments, + "arguments": parsed_arguments, + } + + +def parse_arguments(raw_arguments: str) -> dict[str, Any]: + """Parse a raw argument string into positional args, flags, and tokens. + + Supports positional values, long flags (``--key value``, ``--key=value``), + short flags (``-f``), and short flag bundles (``-vq``). + + Returns a dict with keys: ``raw``, ``tokens``, ``positional``, ``flags``. + + """ + normalized_arguments = (raw_arguments or "").strip() + tokens = _split_arguments(normalized_arguments) + positional: list[str] = [] + flags: dict[str, Any] = {} + + index = 0 + while index < len(tokens): + token = tokens[index] + if token.startswith("--") and len(token) > 2: + key, value, consumed = _parse_long_flag(token, tokens, index) + _set_flag_value(flags, key, value) + index += consumed + continue + + if token.startswith("-") and len(token) > 1: + consumed = _parse_short_flag_bundle(token, tokens, index, flags) + index += consumed + continue + + positional.append(token) + index += 1 + + return { + "raw": normalized_arguments, + "tokens": tokens, + "positional": positional, + "flags": flags, + } + + +def render_command_body( + body: str, + raw_arguments: str, + *, + command_name: str = "", + raw_message: str = "", +) -> str: + """Render *body* as a text template substituting placeholders from *raw_arguments*. + + Args: + body: Template string with ``{placeholder}`` markers. + raw_arguments: Unparsed argument string from the command invocation. + command_name: Optional command name used for slash-invocation parsing fallback. + raw_message: Full original message; when provided takes precedence over raw_arguments. + + """ + invocation = parse_slash_invocation( + raw_message or raw_arguments, + fallback_command=command_name, + ) + if not raw_message: + invocation["raw_arguments"] = (raw_arguments or "").strip() + invocation["arguments"] = parse_arguments(invocation["raw_arguments"]) + return render_text_template(body, invocation) + + +def render_text_template(body: str, invocation: dict[str, Any]) -> str: + """Render *body* as a template substituting placeholders from *invocation* context. + + Unrecognised placeholders resolve to empty string. If *raw_arguments* is present + and the template contains no argument references, the arguments are appended. + + """ + template = body or "" + rendered = template + + context = _build_template_context(invocation) + rendered = _PLACEHOLDER_RE.sub( + lambda match: _resolve_placeholder(match.group(1), context), + rendered, + ) + rendered = _render_legacy_placeholders(rendered, invocation) + rendered = rendered.strip() + + raw_arguments = invocation["raw_arguments"] + if raw_arguments and not _template_references_arguments(template): + suffix = f"Arguments:\n{raw_arguments}" + rendered = f"{rendered}\n\n{suffix}" if rendered else suffix + + return rendered.strip() + + +def get_scope_key(project_name: str = "", agent_profile: str = "") -> str: + """Return the scope identifier key: ``'project'`` when a project is active, else ``'global'``.""" + if project_name: + return "project" + return "global" + + +def get_scope_label(project_name: str = "", agent_profile: str = "") -> str: + """Return the human-readable scope label: ``'Project'`` or ``'Global'``.""" + if project_name: + return "Project" + return "Global" + + +def get_scope_directory(project_name: str = "", agent_profile: str = "") -> str: + """Return the absolute filesystem path to the commands directory for the given scope.""" + return plugins.determine_plugin_asset_path( + PLUGIN_NAME, + project_name, + "", + COMMANDS_DIR, + ) + + +def ensure_scope_directory(project_name: str = "", agent_profile: str = "") -> str: + """Ensure the commands directory for the given scope exists, creating it when absent. + + Returns the absolute path to the directory. + + """ + directory = get_scope_directory(project_name, "") + Path(directory).mkdir(parents=True, exist_ok=True) + return directory + + +def get_scope_payload( + project_name: str = "", + agent_profile: str = "", + *, + ensure_directory: bool = False, +) -> dict[str, Any]: + """Build a scope descriptor dict for the given project/agent context. + + Returns a dict containing ``project_name``, ``scope_key``, ``scope_label``, + ``directory_path``, ``exists``, and the private ``_directory_abs_path`` key. + + Args: + ensure_directory: When ``True``, create the directory if it does not exist. + + """ + directory_path = ( + ensure_scope_directory(project_name, "") + if ensure_directory + else get_scope_directory(project_name, "") + ) + return { + "project_name": project_name, + "scope_key": get_scope_key(project_name, ""), + "scope_label": get_scope_label(project_name, ""), + "directory_path": _normalize_client_path(directory_path), + "exists": os.path.isdir(directory_path), + "_directory_abs_path": directory_path, + } + + +def get_context_scope(context_id: str = "") -> dict[str, str]: + """Resolve the active project name for *context_id* and return a scope mapping. + + Returns ``{"project_name": str}`` — empty string when no project is associated. + + """ + context = _get_context(context_id) + if not context: + return {"project_name": ""} + + return { + "project_name": projects.get_context_project_name(context) or "", + } + + +def list_scope_commands( + project_name: str = "", + agent_profile: str = "", +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """List all commands defined in *project_name* scope (not merged with global). + + Each command entry includes ``override_scopes`` and ``override_count`` fields + indicating lower-scoped commands with the same name. + + Returns: + Tuple of (commands list, stripped scope payload dict). + + """ + scope = get_scope_payload(project_name, "") + commands = _load_scope_commands(project_name) + overrides = _collect_lower_scope_matches(project_name) + + for command in commands: + override_scopes = overrides.get(command["name"], []) + command["override_scopes"] = override_scopes + command["override_count"] = len(override_scopes) + + return commands, strip_private_scope(scope) + + +def list_effective_commands( + project_name: str = "", + agent_profile: str = "", +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Return the merged effective command list for *project_name* scope. + + Project-scoped commands take precedence over global commands of the same name. + Commands are sorted alphabetically by name. + + Returns: + Tuple of (sorted commands list, stripped scope payload dict). + + """ + resolved_scope = get_scope_payload(project_name, "") + merged: dict[str, dict[str, Any]] = {} + + for scope_project in _iter_precedence_scopes(project_name): + for command in _load_scope_commands(scope_project): + merged.setdefault(command["name"], command) + + for command in _discover_builtin_commands(): + merged.setdefault(command["name"], command) + + for command in _discover_plugin_commands(): + merged.setdefault(command["name"], command) + + effective = sorted(merged.values(), key=lambda item: item["name"]) + return effective, strip_private_scope(resolved_scope) + + +def get_command( + path: str, + project_name: str = "", + agent_profile: str = "", +) -> dict[str, Any]: + """Load and return a single command by its config file *path*. + + Validates that *path* belongs to an effective scope for *project_name*. + + Raises: + FileNotFoundError: If the command file does not exist. + ValueError: If the path is outside all valid scopes, not a recognised + config suffix, or the file content is invalid. + + """ + command_path = _validate_command_path(path, project_name, "", allow_plugin=True) + # Determine actual scope from the resolved path to get correct metadata. + # A global command loaded with a project context must report scope=global. + actual_project = "" + for scope in _iter_precedence_scopes(project_name): + scope_dir = get_scope_directory(scope, "") + if files.is_in_dir(command_path, scope_dir): + actual_project = scope + break + command = _load_command_file(command_path, project_name=actual_project) + if not command: + raise ValueError("Command file is invalid or missing required configuration") + if _is_builtin_command_path(command_path): + return _mark_builtin_command(command) + plugin_name = _plugin_name_for_commands_path(command_path) + if plugin_name: + _mark_plugin_command(command, plugin_name) + return command + + +def save_command( + *, + project_name: str = "", + agent_profile: str = "", + existing_path: str = "", + name: str, + description: str, + argument_hint: str = "", + command_type: str = "text", + body: str = "", + include_history: bool = False, + extra_frontmatter: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Create or update a command, writing both the config and content files. + + When *existing_path* is provided, the old files are removed after the new + files are written (rename/move semantics). + + Returns: + The fully-loaded command dict for the saved command. + + Raises: + FileExistsError: If a command with the same name already exists in scope. + ValueError: If required fields are missing or values are invalid. + + """ + command_name = sanitize_command_name(name) + command_description = (description or "").strip() + if not command_description: + raise ValueError("Command description is required") + + normalized_type = normalize_command_type(command_type) + scope_dir = ensure_scope_directory(project_name, "") + target_config_path = files.get_abs_path(scope_dir, command_file_name(command_name)) + target_content_name = command_content_file_name(command_name, normalized_type) + target_content_path = files.get_abs_path(scope_dir, target_content_name) + existing_abs_path = "" + existing_command: dict[str, Any] | None = None + if existing_path: + try: + existing_abs_path = _validate_command_path(existing_path, project_name, "") + existing_command = _load_command_file(existing_abs_path, project_name=project_name) + except FileNotFoundError: + existing_abs_path = "" + existing_command = None + + if existing_abs_path and not os.path.exists(existing_abs_path): + existing_abs_path = "" + existing_command = None + + if os.path.exists(target_config_path) and not _paths_equal( + target_config_path, existing_abs_path + ): + raise FileExistsError(f'A command named "{command_name}" already exists in this scope') + + existing_content_path = _to_abs_path(existing_command.get("content_path", "")) if existing_command else "" + if os.path.exists(target_content_path) and not _paths_equal( + target_content_path, existing_content_path + ): + raise FileExistsError( + f'Command content file "{Path(target_content_path).name}" already exists in this scope' + ) + + content_key = "template_path" if normalized_type == "text" else "script_path" + config = _build_command_config( + name=command_name, + description=command_description, + argument_hint=argument_hint, + command_type=normalized_type, + content_path=target_content_name, + include_history=include_history, + extra_config=extra_frontmatter or {}, + ) + if content_key not in config: + config[content_key] = target_content_name + + files.write_file(target_content_path, _normalize_command_body(body, normalized_type)) + files.write_file(target_config_path, _build_command_yaml(config)) + + if existing_abs_path and not _paths_equal(existing_abs_path, target_config_path): + files.delete_file(existing_abs_path) + + if ( + existing_content_path + and os.path.exists(existing_content_path) + and not _paths_equal(existing_content_path, target_content_path) + ): + files.delete_file(existing_content_path) + + return get_command(target_config_path, project_name, "") + + +def delete_command( + path: str, + project_name: str = "", + agent_profile: str = "", +) -> None: + """Delete the config file and associated content file for *path*. + + Raises: + FileNotFoundError: If the command file does not exist. + ValueError: If *path* is invalid or outside the allowed scope. + + """ + command = get_command(path, project_name, "") + command_path = _validate_command_path(path, project_name, "") + files.delete_file(command_path) + + content_path = _to_abs_path(command.get("content_path", "")) + if content_path and os.path.exists(content_path): + files.delete_file(content_path) + + +def duplicate_command( + path: str, + project_name: str = "", + agent_profile: str = "", +) -> dict[str, Any]: + """Duplicate an existing command, assigning it a unique ``-copy`` suffixed name. + + Returns the newly created command dict. + + Raises: + FileNotFoundError: If the source command does not exist. + ValueError: If the source path is invalid. + + """ + command = get_command(path, project_name, "") + duplicated_name = _generate_duplicate_name(command["name"], project_name=project_name) + return save_command( + project_name=project_name, + name=duplicated_name, + description=command["description"], + argument_hint=command.get("argument_hint", ""), + command_type=command.get("command_type", "text"), + body=command.get("body", ""), + include_history=bool(command.get("include_history", False)), + extra_frontmatter=command.get("frontmatter_extra", {}), + ) + + +async def resolve_command_invocation( + *, + path: str, + slash_text: str, + project_name: str = "", + context_id: str = "", +) -> dict[str, Any]: + """Resolve a slash command invocation, executing text rendering or a Python script hook. + + Args: + path: Path to the command config file. + slash_text: The full slash text entered by the user. + project_name: Active project name (empty string for global scope).' + context_id: Agent context ID, used for script commands that request history. + + Returns: + Dict with keys ``command``, ``invocation``, and ``result`` + (``{"text": str, "effects": list}``). + + Raises: + FileNotFoundError: If the command file is not found. + ValueError: If path or slash_text is invalid. + + """ + command = get_command(path, project_name, "") + invocation = parse_slash_invocation(slash_text, fallback_command=command["name"]) + + if command.get("command_type") == "script": + result = await _run_script_command( + command=command, + invocation=invocation, + project_name=project_name, + context_id=context_id, + ) + else: + text = render_text_template(command.get("body", ""), invocation) + result = {"text": text, "effects": []} + + return { + "command": _public_command_payload(command), + "invocation": invocation, + "result": result, + } + + +def _build_command_config( + *, + name: str, + description: str, + argument_hint: str, + command_type: str, + content_path: str, + include_history: bool, + extra_config: dict[str, Any], +) -> dict[str, Any]: + config: dict[str, Any] = { + "name": name, + "description": description, + "type": command_type, + } + clean_argument_hint = (argument_hint or "").strip() + if clean_argument_hint: + config["argument_hint"] = clean_argument_hint + + if command_type == "text": + config["template_path"] = content_path + else: + config["script_path"] = content_path + if include_history: + config["include_history"] = True + + for key, value in (extra_config or {}).items(): + if key in STANDARD_CONFIG_KEYS: + continue + config[key] = value + + return config + + +def _build_command_yaml(config: dict[str, Any]) -> str: + return f"{yaml_helper.dumps(config).strip()}\n" + + +def _normalize_command_body(body: str, command_type: str) -> str: + cleaned = (body or "").lstrip("\n").rstrip() + if cleaned: + return f"{cleaned}\n" + return "" + + +def _load_command_file( + file_path: str, + *, + project_name: str = "", +) -> dict[str, Any] | None: + if file_path.endswith(COMMAND_CONFIG_SUFFIX): + return _load_yaml_command_file(file_path, project_name=project_name) + if file_path.endswith(LEGACY_COMMAND_FILE_SUFFIX): + return _load_legacy_markdown_file(file_path, project_name=project_name) + return None + + +def _load_yaml_command_file( + file_path: str, + *, + project_name: str = "", +) -> dict[str, Any] | None: + try: + raw_content = files.read_file(file_path) + except FileNotFoundError: + return None + + try: + parsed = yaml.safe_load(raw_content) or {} + except yaml.YAMLError: + return None + if not isinstance(parsed, dict): + return None + + raw_name = str(parsed.get("name") or "").strip() + description = str(parsed.get("description") or "").strip() + if not raw_name or not description: + return None + + try: + command_name = sanitize_command_name(raw_name) + command_type = normalize_command_type(str(parsed.get("type") or "text")) + except ValueError: + return None + + directory_path = str(Path(file_path).parent) + content_key = "template_path" if command_type == "text" else "script_path" + configured_content_path = str(parsed.get(content_key) or "").strip() or command_content_file_name( + command_name, command_type + ) + content_abs_path = files.get_abs_path(directory_path, configured_content_path) + # Content file must live in the same directory as its config file. + # Using directory_path (not the project scope root) allows global commands + # to load correctly even when a project context is active. + if not files.is_in_dir(content_abs_path, directory_path): + return None + + try: + body = files.read_file(content_abs_path) + except FileNotFoundError: + body = "" + + argument_hint = str(parsed.get("argument_hint") or "").strip() + include_history = bool(parsed.get("include_history", False)) + extra_config = { + key: value for key, value in parsed.items() if key not in STANDARD_CONFIG_KEYS + } + + return { + "name": command_name, + "description": description, + "argument_hint": argument_hint, + "command_type": command_type, + "include_history": include_history, + "body": body, + "path": _normalize_client_path(file_path), + "config_path": _normalize_client_path(file_path), + "content_path": _normalize_client_path(content_abs_path), + "directory_path": _normalize_client_path(directory_path), + "project_name": project_name, + "scope_key": get_scope_key(project_name, ""), + "scope_label": get_scope_label(project_name, ""), + "source_scope_key": get_scope_key(project_name, ""), + "source_scope_label": get_scope_label(project_name, ""), + "frontmatter_extra": extra_config, + } + + +def _load_legacy_markdown_file( + file_path: str, + *, + project_name: str = "", +) -> dict[str, Any] | None: + try: + content = files.read_file(file_path) + except FileNotFoundError: + return None + + frontmatter, body, errors = split_frontmatter(content) + if errors: + return None + + raw_name = str(frontmatter.get("name") or "").strip() + description = str(frontmatter.get("description") or "").strip() + if not raw_name or not description: + return None + + try: + command_name = sanitize_command_name(raw_name) + except ValueError: + return None + + argument_hint = str(frontmatter.get("argument_hint") or "").strip() + extra_frontmatter = { + key: value for key, value in frontmatter.items() if key not in {"name", "description", "argument_hint"} + } + directory_path = str(Path(file_path).parent) + return { + "name": command_name, + "description": description, + "argument_hint": argument_hint, + "command_type": "text", + "include_history": False, + "body": body, + "path": _normalize_client_path(file_path), + "config_path": _normalize_client_path(file_path), + "content_path": _normalize_client_path(file_path), + "directory_path": _normalize_client_path(directory_path), + "project_name": project_name, + "scope_key": get_scope_key(project_name, ""), + "scope_label": get_scope_label(project_name, ""), + "source_scope_key": get_scope_key(project_name, ""), + "source_scope_label": get_scope_label(project_name, ""), + "frontmatter_extra": extra_frontmatter, + } + + +def _validate_command_path( + path: str, + project_name: str = "", + agent_profile: str = "", + *, + allow_plugin: bool = False, +) -> str: + command_path = _to_abs_path(path) + # Allow commands from any effective scope (project overrides global, but global is also valid) + valid_roots = [get_scope_directory(scope, "") for scope in _iter_precedence_scopes(project_name)] + if not any(files.is_in_dir(command_path, scope_root) for scope_root in valid_roots): + is_builtin = _is_builtin_command_path(command_path) + plugin_name = "" if is_builtin else _plugin_name_for_commands_path(command_path) + if plugin_name: + if not allow_plugin: + raise ValueError("Plugin commands are read-only") + elif is_builtin: + if not allow_plugin: + raise ValueError("Built-in commands are read-only") + else: + raise ValueError("Command path is outside the selected scope") + if not ( + command_path.endswith(COMMAND_CONFIG_SUFFIX) + or command_path.endswith(LEGACY_COMMAND_FILE_SUFFIX) + ): + raise ValueError("Command path must point to a .command.yaml or .command.md file") + if not os.path.exists(command_path): + raise FileNotFoundError("Command file not found") + return command_path + + +def _iter_precedence_scopes(project_name: str) -> list[str]: + if project_name: + return [project_name, ""] + return [""] + + +def _list_scope_files(scope_dir: str) -> list[str]: + if not os.path.isdir(scope_dir): + return [] + files_in_scope = [ + str(path) + for suffix in (COMMAND_CONFIG_SUFFIX, LEGACY_COMMAND_FILE_SUFFIX) + for path in Path(scope_dir).glob(f"*{suffix}") + if path.is_file() + ] + files_in_scope.sort(key=lambda item: Path(item).name.lower()) + return files_in_scope + + +def _load_scope_commands(project_name: str = "") -> list[dict[str, Any]]: + commands: list[dict[str, Any]] = [] + scope_dir = get_scope_directory(project_name, "") + + for file_path in _list_scope_files(scope_dir): + command = _load_command_file(file_path, project_name=project_name) + if command: + commands.append(command) + + commands.sort(key=lambda item: item["name"]) + return commands + + +def _discover_plugin_commands() -> list[dict[str, Any]]: + """Discover commands contributed by enabled plugins.""" + commands: list[dict[str, Any]] = [] + for plugin_name in plugins.get_enabled_plugins(None): + if plugin_name == PLUGIN_NAME: + continue + plugin_dir = plugins.find_plugin_dir(plugin_name) + if not plugin_dir: + continue + plugin_commands_dir = files.get_abs_path(plugin_dir, COMMANDS_DIR) + if not os.path.isdir(plugin_commands_dir): + continue + for file_path in _list_scope_files(plugin_commands_dir): + command = _load_command_file(file_path, project_name="") + if command: + commands.append(_mark_plugin_command(command, plugin_name)) + return commands + + +def _discover_builtin_commands() -> list[dict[str, Any]]: + plugin_dir = plugins.find_plugin_dir(PLUGIN_NAME) + if not plugin_dir: + return [] + commands_dir = files.get_abs_path(plugin_dir, COMMANDS_DIR) + commands: list[dict[str, Any]] = [] + for file_path in _list_scope_files(commands_dir): + command = _load_command_file(file_path, project_name="") + if command: + commands.append(_mark_builtin_command(command)) + return commands + + +def _collect_lower_scope_matches(project_name: str = "") -> dict[str, list[str]]: + lower_scope_matches: dict[str, list[str]] = {} + if not project_name: + return lower_scope_matches + + for command in _load_scope_commands(""): + lower_scope_matches.setdefault(command["name"], []).append(get_scope_label("", "")) + + return lower_scope_matches + + +def _generate_duplicate_name( + command_name: str, + *, + project_name: str = "", +) -> str: + base_name = sanitize_command_name(f"{command_name}-copy") + candidate = base_name + counter = 2 + scope_dir = ensure_scope_directory(project_name, "") + + while os.path.exists(files.get_abs_path(scope_dir, command_file_name(candidate))): + candidate = f"{base_name}-{counter}" + counter += 1 + + return candidate + + +def _build_template_context(invocation: dict[str, Any]) -> dict[str, Any]: + arguments = invocation.get("arguments", {}) + return { + "full": invocation.get("raw_text", ""), + "raw": invocation.get("raw_arguments", ""), + "command": invocation.get("command_name", ""), + "args": { + "raw": arguments.get("raw", ""), + "tokens": arguments.get("tokens", []), + "positional": arguments.get("positional", []), + "flags": arguments.get("flags", {}), + }, + } + + +def _resolve_placeholder(path: str, context: dict[str, Any]) -> str: + resolved = _resolve_path(context, path) + if resolved is None: + return "" + if isinstance(resolved, (dict, list)): + return json.dumps(resolved, ensure_ascii=False) + return str(resolved) + + +def _resolve_path(value: Any, path: str) -> Any: + current = value + for part in path.split("."): + if isinstance(current, dict): + if part in current: + current = current[part] + continue + part_with_dash = part.replace("_", "-") + if part_with_dash in current: + current = current[part_with_dash] + continue + return None + + if isinstance(current, list): + if not part.isdigit(): + return None + index = int(part) + if index < 0 or index >= len(current): + return None + current = current[index] + continue + + return None + return current + + +def _render_legacy_placeholders(template: str, invocation: dict[str, Any]) -> str: + rendered = template + arguments = invocation.get("arguments", {}) + positional = arguments.get("positional", []) + for index in range(10): + rendered = rendered.replace(f"${index}", positional[index] if index < len(positional) else "") + rendered = rendered.replace("$ARGUMENTS", invocation.get("raw_arguments", "")) + return rendered + + +def _template_references_arguments(template: str) -> bool: + if "$ARGUMENTS" in template: + return True + if any(f"${index}" in template for index in range(10)): + return True + if "{raw}" in template: + return True + return "{args." in template + + +def _parse_long_flag(token: str, tokens: list[str], index: int) -> tuple[str, Any, int]: + flag_token = token[2:] + if "=" in flag_token: + key, value = flag_token.split("=", 1) + return _normalize_flag_name(key), value, 1 + + key = _normalize_flag_name(flag_token) + next_index = index + 1 + if next_index < len(tokens) and not tokens[next_index].startswith("-"): + return key, tokens[next_index], 2 + return key, True, 1 + + +def _parse_short_flag_bundle( + token: str, tokens: list[str], index: int, flags: dict[str, Any] +) -> int: + short_token = token[1:] + if len(short_token) > 1 and "=" not in short_token: + for char in short_token: + _set_flag_value(flags, _normalize_flag_name(char), True) + return 1 + + if "=" in short_token: + key, value = short_token.split("=", 1) + _set_flag_value(flags, _normalize_flag_name(key), value) + return 1 + + key = _normalize_flag_name(short_token) + _set_flag_value(flags, key, True) + return 1 + + +def _set_flag_value(flags: dict[str, Any], key: str, value: Any) -> None: + if key in flags: + current = flags[key] + if isinstance(current, list): + current.append(value) + else: + flags[key] = [current, value] + return + flags[key] = value + + +def _normalize_flag_name(raw_flag: str) -> str: + return (raw_flag or "").strip().lower().replace("-", "_") + + +def _split_arguments(raw_arguments: str) -> list[str]: + if not raw_arguments: + return [] + try: + return shlex.split(raw_arguments) + except ValueError: + return raw_arguments.split() + + +async def _run_script_command( + *, + command: dict[str, Any], + invocation: dict[str, Any], + project_name: str, + context_id: str, +) -> dict[str, Any]: + script_path = _to_abs_path(command.get("content_path", "")) + if not script_path: + raise ValueError("Script command is missing script_path") + if not os.path.exists(script_path): + raise ValueError("Script file not found for this command") + + module_globals = runpy.run_path(script_path) + hook = module_globals.get("run") + if not callable(hook): + raise ValueError('Script command must expose a callable "run(payload)" function') + + context = _get_context(context_id) + history = _extract_chat_history(context) if command.get("include_history") else [] + payload = { + "command": _public_command_payload(command), + "invocation": invocation, + "arguments": invocation.get("arguments", {}), + "context": { + "context_id": context_id, + "project_name": project_name, + "agent": getattr(context, "agent0", None) if context else None, + "chat_history": history, + }, + } + + result = hook(payload) + if inspect.isawaitable(result): + result = await result + return _normalize_script_result(result) + + +def _normalize_script_result(result: Any) -> dict[str, Any]: + if isinstance(result, str): + return {"text": result, "effects": []} + + if isinstance(result, dict): + text = result.get("text") + if text is None: + text = result.get("replacement_text") + + effects = result.get("effects") + if effects is None: + effects = [] + if not isinstance(effects, list): + raise ValueError("Script result.effects must be an array when provided") + + normalized_text = str(text) if text is not None else "" + return {"text": normalized_text, "effects": effects} + + raise ValueError("Script run(payload) must return either a string or an object") + + +def _extract_chat_history(context: AgentContext | None) -> list[Any]: + if not context: + return [] + + for attribute in ("chat_history", "history", "messages"): + value = getattr(context, attribute, None) + if isinstance(value, list): + return value + + getter = getattr(context, "get_data", None) + if callable(getter): + for key in ("chat_history", "messages", "history"): + value = getter(key) + if isinstance(value, list): + return value + + return [] + + +def _public_command_payload(command: dict[str, Any]) -> dict[str, Any]: + payload = dict(command) + payload.pop("body", None) + return payload + + +def _normalize_client_path(path: str) -> str: + return files.normalize_a0_path(path).replace("\\", "/") + + +def _paths_equal(path_a: str, path_b: str) -> bool: + if not path_a or not path_b: + return False + return os.path.normcase(os.path.normpath(path_a)) == os.path.normcase( + os.path.normpath(path_b) + ) + + +def _get_context(context_id: str = "") -> AgentContext | None: + if context_id: + return AgentContext.get(context_id) + return AgentContext.current() or AgentContext.first() + + +def _to_abs_path(path: str) -> str: + return files.fix_dev_path(path) + + +def _plugin_scope_label(plugin_name: str) -> str: + return f"Plugin: {plugin_name}" + + +def _mark_plugin_command(command: dict[str, Any], plugin_name: str) -> dict[str, Any]: + scope_label = _plugin_scope_label(plugin_name) + command["source_plugin"] = plugin_name + command["scope_key"] = "plugin" + command["scope_label"] = scope_label + command["source_scope_key"] = "plugin" + command["source_scope_label"] = scope_label + return command + + +def _mark_builtin_command(command: dict[str, Any]) -> dict[str, Any]: + command["source_plugin"] = PLUGIN_NAME + command["scope_key"] = "builtin" + command["scope_label"] = "Built-in" + command["source_scope_key"] = "builtin" + command["source_scope_label"] = "Built-in" + return command + + +def _builtin_commands_dir() -> str: + plugin_dir = plugins.find_plugin_dir(PLUGIN_NAME) + return files.get_abs_path(plugin_dir, COMMANDS_DIR) if plugin_dir else "" + + +def _is_builtin_command_path(path: str) -> bool: + commands_dir = _builtin_commands_dir() + return bool(commands_dir and files.is_in_dir(_to_abs_path(path), commands_dir)) + + +def _plugin_name_for_commands_path(path: str) -> str: + abs_path = _to_abs_path(path) + for plugin_name in plugins.get_enabled_plugins(None): + if plugin_name == PLUGIN_NAME: + continue + plugin_dir = plugins.find_plugin_dir(plugin_name) + if not plugin_dir: + continue + plugin_commands_dir = files.get_abs_path(plugin_dir, COMMANDS_DIR) + if files.is_in_dir(abs_path, plugin_commands_dir): + return plugin_name + return "" + + +def _is_plugin_commands_dir(path: str) -> bool: + """Check if a path is inside any installed plugin's commands/ subdirectory.""" + return bool(_plugin_name_for_commands_path(path)) + + +def strip_private_scope(scope: dict[str, Any]) -> dict[str, Any]: + """Return a copy of *scope* with all keys prefixed by ``_`` removed.""" + return {key: value for key, value in scope.items() if not key.startswith("_")} + + +def _strip_private_scope(scope: dict[str, Any]) -> dict[str, Any]: + return strip_private_scope(scope) diff --git a/plugins/_commands/plugin.yaml b/plugins/_commands/plugin.yaml new file mode 100644 index 000000000..799c0b43e --- /dev/null +++ b/plugins/_commands/plugin.yaml @@ -0,0 +1,8 @@ +name: _commands +title: Commands +description: YAML-configured slash commands with text templates or Python hooks. +version: 0.5.0 +settings_sections: [] +per_project_config: false +per_agent_config: false +always_enabled: false diff --git a/plugins/_commands/skills/commands-create-slash-command/SKILL.md b/plugins/_commands/skills/commands-create-slash-command/SKILL.md new file mode 100644 index 000000000..a594038bc --- /dev/null +++ b/plugins/_commands/skills/commands-create-slash-command/SKILL.md @@ -0,0 +1,72 @@ +--- +name: commands-create-slash-command +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. +version: 1.0.0 +tags: ["commands", "slash-commands", "plugin", "yaml", "python", "templates"] +triggers: + - create slash command + - add slash command + - update slash command + - edit slash command + - commands plugin +--- + +# Commands Plugin Slash Command Authoring + +Use this skill when the user wants a reusable `/command` for Agent Zero's built-in `_commands` plugin. + +## Source Of Truth + +- Slash commands are file-backed, not database rows. +- Each command uses: + - one config file: `.command.yaml` + - one content file: + - text template: `.txt`, or + - python hook: `.py` +- Required config keys: + - `name` + - `description` + - `type` (`text` or `script`) +- Optional config keys: + - `argument_hint` + - `include_history` (script commands) +- Preserve unknown config keys when editing existing commands. + +## Scope Resolution + +Choose the target folder from the requested scope: + +- Project: `usr/projects//.a0proj/plugins/_commands/commands/` +- Global fallback: `usr/plugins/_commands/commands/` + +If the user does not specify a scope, prefer the active chat scope when it is clear. Otherwise use the global scope. + +## File Rules + +- Config file format: `.command.yaml` +- Slash command name should be lowercase and hyphenated, for example `explain-code` +- For text commands, keep the `.txt` template concise and directly reusable +- For script commands, implement `run(payload)` in the `.py` file +- If the command expects trailing input, use `{raw}`, `{args.positional.0}`, or `{args.flags.some_flag}` + +Use the bundled templates in `template.command.yaml` and `template.command.txt` when creating a new text command from scratch. + +## Editing Workflow + +1. Determine scope and final slash command name. +2. Check whether a command file already exists in that scope. +3. If it exists, load the file first and preserve unknown frontmatter keys. +4. Update YAML config and template/script content. +5. Save the file in the correct scope folder. +6. Report: + - the saved config path + - the saved content path + - the slash command name in `/name` form + +## Output Contract + +After saving, explicitly state the final file path and the exact slash command invocation, for example: + +- `Saved config: /a0/usr/plugins/_commands/commands/explain-code.command.yaml` +- `Saved content: /a0/usr/plugins/_commands/commands/explain-code.txt` +- `Invoke with: /explain-code` diff --git a/plugins/_commands/skills/commands-create-slash-command/template.command.txt b/plugins/_commands/skills/commands-create-slash-command/template.command.txt new file mode 100644 index 000000000..312a318a3 --- /dev/null +++ b/plugins/_commands/skills/commands-create-slash-command/template.command.txt @@ -0,0 +1,3 @@ +Describe the work to perform here. + +{raw} diff --git a/plugins/_commands/skills/commands-create-slash-command/template.command.yaml b/plugins/_commands/skills/commands-create-slash-command/template.command.yaml new file mode 100644 index 000000000..ee8b4150f --- /dev/null +++ b/plugins/_commands/skills/commands-create-slash-command/template.command.yaml @@ -0,0 +1,5 @@ +name: example-command +description: Briefly describe what this slash command does. +argument_hint: Optional free-form text after /example-command +type: text +template_path: example-command.txt diff --git a/plugins/_commands/tests/conftest.py b/plugins/_commands/tests/conftest.py new file mode 100644 index 000000000..c9831dbb4 --- /dev/null +++ b/plugins/_commands/tests/conftest.py @@ -0,0 +1,11 @@ +# tests/conftest.py +import sys +from pathlib import Path + +# Ensure A0 framework root is first in sys.path so that +# `from helpers import ...` resolves to A0's helpers/, not +# the Commands plugin's local helpers/ directory. +a0_root = str(Path(__file__).resolve().parents[3]) # tests/ → _commands → plugins → a0 +while a0_root in sys.path: + sys.path.remove(a0_root) +sys.path.insert(0, a0_root) diff --git a/plugins/_commands/tests/test_commands_plugin.py b/plugins/_commands/tests/test_commands_plugin.py new file mode 100644 index 000000000..7cb4333a2 --- /dev/null +++ b/plugins/_commands/tests/test_commands_plugin.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import threading +import uuid +from dataclasses import dataclass, field +from pathlib import Path +import sys + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).resolve().parents[4] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from agent import AgentContext +from helpers import files, projects, skills as skills_helper +from initialize import initialize_agent +from plugins._commands.api.commands import Commands +from plugins._commands.commands import connector_commands +from plugins._commands.helpers import commands as commands_helper + + +@dataclass +class ScopeFixture: + prefix: str + project_name: str + created_paths: list[str] = field(default_factory=list) + + +def _track_paths(scope: ScopeFixture, command: dict) -> dict: + for key in ("path", "config_path", "content_path"): + command_path = files.fix_dev_path(command.get(key, "")) + if command_path and command_path not in scope.created_paths: + scope.created_paths.append(command_path) + return command + + +def _save_command( + scope: ScopeFixture, + *, + project_name: str = "", + name: str, + description: str, + body: str = "", + argument_hint: str = "", + command_type: str = "text", + include_history: bool = False, + extra_frontmatter: dict | None = None, +) -> dict: + command = commands_helper.save_command( + project_name=project_name, + name=name, + description=description, + body=body, + argument_hint=argument_hint, + command_type=command_type, + include_history=include_history, + extra_frontmatter=extra_frontmatter or {}, + ) + return _track_paths(scope, command) + + +@pytest.fixture +def scope_fixture() -> ScopeFixture: + suffix = uuid.uuid4().hex[:8] + scope = ScopeFixture( + prefix=f"commands-test-{suffix}", + project_name=f"commands_project_{suffix}", + ) + + yield scope + + for path in reversed(scope.created_paths): + files.delete_file(path) + + files.delete_dir(files.get_abs_path("usr", "projects", scope.project_name)) + + +def _new_handler() -> Commands: + app = Flask("commands_plugin_tests") + app.secret_key = "commands-plugin-tests" + return Commands(app, threading.RLock()) + + +def test_command_config_and_template_files_round_trip( + scope_fixture: ScopeFixture, +) -> None: + command = _save_command( + scope_fixture, + name=f"Explain {scope_fixture.prefix}", + description="Explain a code sample clearly.", + body="Explain the sample.\n\n{raw}", + argument_hint="Paste code or describe the module.", + command_type="text", + extra_frontmatter={"category": "analysis", "audience": "team"}, + ) + + config_path = Path(files.fix_dev_path(command["path"])) + content_path = Path(files.fix_dev_path(command["content_path"])) + assert config_path.name == f"explain-{scope_fixture.prefix}.command.yaml" + assert content_path.name == f"explain-{scope_fixture.prefix}.txt" + + loaded = commands_helper.get_command(command["path"]) + assert loaded["frontmatter_extra"] == { + "category": "analysis", + "audience": "team", + } + + config_yaml = files.read_file(str(config_path)) + assert "category: analysis" in config_yaml + assert "audience: team" in config_yaml + assert f"name: explain-{scope_fixture.prefix}" in config_yaml + assert "type: text" in config_yaml + + template_text = files.read_file(str(content_path)) + assert "Explain the sample." in template_text + + +def test_parse_arguments_and_render_template_support_flags() -> None: + parsed = commands_helper.parse_arguments( + '--git-url=https://github.com/acme/repo "quoted phrase" -v 30%' + ) + assert parsed["flags"]["git_url"] == "https://github.com/acme/repo" + assert parsed["flags"]["v"] is True + assert parsed["positional"] == ["quoted phrase", "30%"] + + invocation = commands_helper.parse_slash_invocation( + '/optimize 30% --mode fast --git-url=https://github.com/acme/repo' + ) + rendered = commands_helper.render_text_template( + "Pct: {args.positional.0}\nMode: {args.flags.mode}\nURL: {args.flags.git_url}\nRaw: {raw}", + invocation, + ) + assert rendered == ( + "Pct: 30%\n" + "Mode: fast\n" + "URL: https://github.com/acme/repo\n" + "Raw: 30% --mode fast --git-url=https://github.com/acme/repo" + ) + + appended = commands_helper.render_text_template( + "Summarize this request.", + commands_helper.parse_slash_invocation("/summarize alpha beta"), + ) + assert appended == "Summarize this request.\n\nArguments:\nalpha beta" + + invalid_invocation = commands_helper.parse_slash_invocation("/?") + assert invalid_invocation["command_name"] == "" + + +def test_list_effective_commands_project_overrides_global( + scope_fixture: ScopeFixture, +) -> None: + shared_name = f"{scope_fixture.prefix}-shared" + + _save_command( + scope_fixture, + name=shared_name, + description="global description", + body="global body", + command_type="text", + ) + _save_command( + scope_fixture, + project_name=scope_fixture.project_name, + name=shared_name, + description="project description", + body="project body", + command_type="text", + ) + + project_commands, _ = commands_helper.list_effective_commands( + scope_fixture.project_name + ) + global_commands, _ = commands_helper.list_effective_commands("") + + assert {command["name"]: command for command in project_commands}[shared_name][ + "description" + ] == "project description" + assert {command["name"]: command for command in global_commands}[shared_name][ + "description" + ] == "global description" + + scoped_commands, _ = commands_helper.list_scope_commands(scope_fixture.project_name) + scoped_command = next( + command for command in scoped_commands if command["name"] == shared_name + ) + assert scoped_command["override_count"] == 1 + assert scoped_command["override_scopes"] == ["Global"] + + +def test_models_command_always_opens_modal(): + result = connector_commands.run( + { + "invocation": { + "command_name": "models", + "raw_arguments": "default", + }, + "context": {"context_id": ""}, + } + ) + + assert result == { + "text": "", + "effects": [{"type": "open_plugin_config", "plugin": "_model_config"}], + } + + +@pytest.mark.asyncio +async def test_commands_api_crud_and_resolve_text_and_script( + scope_fixture: ScopeFixture, +) -> None: + handler = _new_handler() + command_name = f"{scope_fixture.prefix}-context" + + context = AgentContext( + config=initialize_agent({}), + set_current=True, + ) + context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, scope_fixture.project_name) + + try: + saved = await handler.process( + { + "action": "save", + "project_name": scope_fixture.project_name, + "name": command_name, + "description": "context override", + "command_type": "text", + "body": ( + "Repo: {args.flags.git_url}\n" + "Mode: {args.flags.mode}\n" + "Raw: {raw}" + ), + }, + None, + ) + assert isinstance(saved, dict) + assert saved["ok"] is True + saved_command = _track_paths(scope_fixture, saved["command"]) + + loaded = await handler.process( + { + "action": "get", + "project_name": scope_fixture.project_name, + "path": saved_command["path"], + }, + None, + ) + assert isinstance(loaded, dict) + assert loaded["command"]["description"] == "context override" + + resolved_text = await handler.process( + { + "action": "resolve", + "project_name": scope_fixture.project_name, + "path": saved_command["path"], + "slash_text": f"/{command_name} --git-url=https://github.com/acme/repo --mode deep", + "context_id": context.id, + }, + None, + ) + assert isinstance(resolved_text, dict) + assert resolved_text["ok"] is True + rendered_text = resolved_text["resolution"]["result"]["text"] + assert "Repo: https://github.com/acme/repo" in rendered_text + assert "Mode: deep" in rendered_text + + script_saved = await handler.process( + { + "action": "save", + "project_name": scope_fixture.project_name, + "name": f"{command_name}-script", + "description": "script command", + "command_type": "script", + "include_history": True, + "body": ( + "def run(payload):\n" + " flags = payload['arguments'].get('flags', {})\n" + " return {\n" + " 'text': f\"Script mode: {flags.get('mode', 'none')}\",\n" + " 'effects': [\n" + " {'type': 'toast', 'level': 'success', 'message': 'Script executed'}\n" + " ],\n" + " }\n" + ), + }, + None, + ) + assert isinstance(script_saved, dict) + assert script_saved["ok"] is True + script_command = _track_paths(scope_fixture, script_saved["command"]) + + resolved_script = await handler.process( + { + "action": "resolve", + "project_name": scope_fixture.project_name, + "path": script_command["path"], + "slash_text": f"/{command_name}-script --mode turbo", + "context_id": context.id, + }, + None, + ) + assert isinstance(resolved_script, dict) + assert resolved_script["ok"] is True + assert resolved_script["resolution"]["result"]["text"] == "Script mode: turbo" + assert resolved_script["resolution"]["result"]["effects"] == [ + { + "type": "toast", + "level": "success", + "message": "Script executed", + } + ] + + duplicated = await handler.process( + { + "action": "duplicate", + "project_name": scope_fixture.project_name, + "path": saved_command["path"], + }, + None, + ) + assert isinstance(duplicated, dict) + assert duplicated["ok"] is True + assert duplicated["command"]["name"].startswith(f"{command_name}-copy") + duplicated_command = _track_paths(scope_fixture, duplicated["command"]) + + effective_list = await handler.process( + {"action": "list_effective", "context_id": context.id}, + None, + ) + assert isinstance(effective_list, dict) + effective_by_name = { + command["name"]: command for command in effective_list["commands"] + } + assert effective_by_name[command_name]["description"] == "context override" + assert effective_by_name[command_name]["source_scope_key"] == "project" + + scope_info = await handler.process( + {"action": "scope_info", "context_id": context.id}, + None, + ) + assert isinstance(scope_info, dict) + assert scope_info["scope"]["project_name"] == scope_fixture.project_name + + deleted = await handler.process( + { + "action": "delete", + "project_name": scope_fixture.project_name, + "path": duplicated_command["path"], + }, + None, + ) + assert isinstance(deleted, dict) + assert deleted["ok"] is True + finally: + AgentContext.remove(context.id) + AgentContext.set_current("") + + +def test_plugin_scoped_skill_is_discoverable() -> None: + skill = skills_helper.find_skill("commands-create-slash-command") + assert skill is not None + assert skill.skill_md_path.as_posix().endswith( + "plugins/_commands/skills/commands-create-slash-command/SKILL.md" + ) diff --git a/plugins/_commands/tests/test_legacy_migration.py b/plugins/_commands/tests/test_legacy_migration.py new file mode 100644 index 000000000..8242db5b2 --- /dev/null +++ b/plugins/_commands/tests/test_legacy_migration.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parents[4] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from helpers import plugins +from plugins._commands.extensions.python.startup_migration._20_migrate_legacy_commands import ( + migrate_legacy_commands, +) + + +def test_migrate_legacy_commands_copies_user_data_and_disables_old_plugin(tmp_path: Path): + legacy_root = tmp_path / "usr" / "plugins" / "commands" + legacy_commands = legacy_root / "commands" + legacy_skills = legacy_root / "skills" / "custom-skill" + project_commands = ( + tmp_path + / "usr" + / "projects" + / "demo" + / ".a0proj" + / "plugins" + / "commands" + / "commands" + ) + new_commands = tmp_path / "usr" / "plugins" / "_commands" / "commands" + + legacy_commands.mkdir(parents=True) + legacy_skills.mkdir(parents=True) + project_commands.mkdir(parents=True) + new_commands.mkdir(parents=True) + + (legacy_root / "plugin.yaml").write_text("name: commands\n", encoding="utf-8") + (legacy_root / plugins.ENABLED_FILE_NAME).write_text("", encoding="utf-8") + (legacy_commands / "demo.command.yaml").write_text( + "name: demo\ndescription: Demo\ntype: text\ntemplate_path: demo.txt\n", + encoding="utf-8", + ) + (legacy_commands / "demo.txt").write_text("legacy demo\n", encoding="utf-8") + (legacy_commands / "keep.command.yaml").write_text( + "name: keep\ndescription: Keep\ntype: text\ntemplate_path: keep.txt\n", + encoding="utf-8", + ) + (new_commands / "keep.command.yaml").write_text("existing\n", encoding="utf-8") + (project_commands / "project.command.yaml").write_text( + "name: project\ndescription: Project\ntype: text\ntemplate_path: project.txt\n", + encoding="utf-8", + ) + (legacy_skills / "SKILL.md").write_text("---\nname: custom-skill\n---\n", encoding="utf-8") + + result = migrate_legacy_commands(tmp_path) + + assert result["copied_commands"] == 3 + assert result["copied_skills"] == 1 + assert result["disabled_roots"] == 2 + + assert (new_commands / "demo.command.yaml").read_text(encoding="utf-8").startswith( + "name: demo" + ) + assert (new_commands / "demo.txt").read_text(encoding="utf-8") == "legacy demo\n" + assert (new_commands / "keep.command.yaml").read_text(encoding="utf-8") == "existing\n" + assert ( + tmp_path + / "usr" + / "projects" + / "demo" + / ".a0proj" + / "plugins" + / "_commands" + / "commands" + / "project.command.yaml" + ).exists() + assert ( + tmp_path + / "usr" + / "plugins" + / "_commands" + / "skills" + / "custom-skill" + / "SKILL.md" + ).exists() + assert not (legacy_root / plugins.ENABLED_FILE_NAME).exists() + assert (legacy_root / plugins.DISABLED_FILE_NAME).exists() + assert ( + tmp_path + / "usr" + / "projects" + / "demo" + / ".a0proj" + / "plugins" + / "commands" + / plugins.DISABLED_FILE_NAME + ).exists() diff --git a/plugins/_commands/tests/test_plugin_command_discovery.py b/plugins/_commands/tests/test_plugin_command_discovery.py new file mode 100644 index 000000000..9a371ef1c --- /dev/null +++ b/plugins/_commands/tests/test_plugin_command_discovery.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import os +import shutil +import uuid +from pathlib import Path +import sys + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[4] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from helpers import cache, files, plugins +from plugins._commands.api.commands import Commands +from plugins._commands.helpers import commands as commands_helper + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _clear_plugin_cache(): + """Ensure plugin list cache is fresh for every test.""" + cache.clear("*(plugins)*") + yield + cache.clear("*(plugins)*") + + +@pytest.fixture() +def fake_plugin(): + """Create a temporary plugin in usr/plugins/ with a commands/ directory.""" + suffix = uuid.uuid4().hex[:8] + plugin_name = f"_test_cmd_disc_{suffix}" + plugin_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name) + commands_dir = os.path.join(plugin_dir, "commands") + os.makedirs(commands_dir, exist_ok=True) + + # Write plugin.yaml so the plugin is discoverable + files.write_file( + os.path.join(plugin_dir, "plugin.yaml"), + f"name: {plugin_name}\ntitle: Test\ndescription: Test\n", + ) + + yield {"name": plugin_name, "dir": plugin_dir, "commands_dir": commands_dir} + + # Cleanup + shutil.rmtree(plugin_dir, ignore_errors=True) + cache.remove(plugins.PLUGINS_LIST_CACHE_AREA, "") + + +def _write_plugin_command( + fake_plugin: dict, + *, + name: str, + description: str, + body: str = "default body", +) -> str: + """Write a .command.yaml + .txt into the fake plugin's commands/ dir. + + Returns the config file path. + """ + slug = commands_helper.sanitize_command_name(name) + cdir = fake_plugin["commands_dir"] + config_path = os.path.join(cdir, f"{slug}.command.yaml") + content_path = os.path.join(cdir, f"{slug}.txt") + + files.write_file( + config_path, + f"name: {slug}\ndescription: {description}\ntype: text\ntemplate_path: {slug}.txt\n", + ) + files.write_file(content_path, body) + return config_path + + +# ── Tests ───────────────────────────────────────────────────────────────────── + + +def test_discover_plugin_commands_finds_plugin_commands(fake_plugin: dict): + """Plugin commands must be discovered without relying on real installs.""" + _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-build", + description="build test", + ) + + discovered = commands_helper._discover_plugin_commands() + names = {c["name"] for c in discovered} + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-build") + assert expected in names, f"Expected {expected!r} in discovered names, got {names}" + + for cmd in discovered: + if cmd["name"] == expected: + assert cmd["source_plugin"] == fake_plugin["name"] + assert cmd["scope_key"] == "plugin" + assert cmd["scope_label"] == f"Plugin: {fake_plugin['name']}" + assert cmd["source_scope_key"] == "plugin" + assert cmd["source_scope_label"] == f"Plugin: {fake_plugin['name']}" + break + + +def test_discover_plugin_commands_skips_own_plugin(): + """The commands plugin itself must NOT appear in _discover_plugin_commands.""" + discovered = commands_helper._discover_plugin_commands() + for cmd in discovered: + assert cmd.get("source_plugin") != "_commands" + + +def test_discover_plugin_commands_skips_disabled_plugins(fake_plugin: dict): + """Disabled plugins must not contribute slash commands to the picker.""" + _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-disabled", + description="disabled command", + ) + files.write_file(os.path.join(fake_plugin["dir"], plugins.DISABLED_FILE_NAME), "") + cache.clear("*(plugins)*") + + discovered = commands_helper._discover_plugin_commands() + names = {command["name"] for command in discovered} + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-disabled") + assert expected not in names + + +def test_discover_builtin_commands_marks_own_commands_read_only(): + """Bundled _commands command files are discoverable as built-ins, not plugin commands.""" + discovered = commands_helper._discover_builtin_commands() + command = next((cmd for cmd in discovered if cmd["name"] == "new"), None) + + assert command is not None + assert command["source_plugin"] == "_commands" + assert command["scope_key"] == "builtin" + assert command["scope_label"] == "Built-in" + + loaded = commands_helper.get_command(command["path"]) + assert loaded["name"] == "new" + assert loaded["scope_key"] == "builtin" + + with pytest.raises(ValueError, match="Built-in commands are read-only"): + commands_helper.save_command( + existing_path=command["path"], + name="new", + description="updated description", + body="updated body", + ) + + with pytest.raises(ValueError, match="Built-in commands are read-only"): + commands_helper.delete_command(command["path"]) + + +def test_builtin_commands_use_canonical_names_only(): + discovered = commands_helper._discover_builtin_commands() + names = {command["name"] for command in discovered} + + assert {"attach", "computer-use", "models", "plugins", "project"} <= names + assert { + "computer", + "cu", + "disconnect", + "exit", + "help", + "image", + "img", + "keys", + "model", + "plugin", + "projects", + }.isdisjoint(names) + + +def test_webui_effective_list_hides_webui_hidden_commands(): + effective, _ = commands_helper.list_effective_commands("") + chats = next(command for command in effective if command["name"] == "chats") + response = object.__new__(Commands)._list_effective({"context_id": ""}) + names = {command["name"] for command in response["commands"]} + + assert chats["frontmatter_extra"]["webui_hidden"] is True + assert "chats" not in names + + +def test_list_effective_includes_plugin_commands(fake_plugin: dict): + """list_effective_commands must include commands from other plugins.""" + _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-effective", + description="effective test", + ) + + effective, _ = commands_helper.list_effective_commands("") + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-effective") + command = next((item for item in effective if item["name"] == expected), None) + assert command is not None + assert command["scope_key"] == "plugin" + assert command["scope_label"] == f"Plugin: {fake_plugin['name']}" + + +def test_plugin_commands_appear_in_effective_list(fake_plugin: dict): + """Commands from a freshly-created plugin appear in effective list.""" + _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-hello", + description="A test command from a plugin", + body="Hello from plugin", + ) + + effective, _ = commands_helper.list_effective_commands("") + expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-hello") + command = next((item for item in effective if item["name"] == expected), None) + assert command is not None + assert command["source_plugin"] == fake_plugin["name"] + + +def test_precedence_global_overrides_plugin(fake_plugin: dict): + """A global command with the same name takes precedence over a plugin command.""" + shared_name = f"{fake_plugin['name']}-shared" + slug = commands_helper.sanitize_command_name(shared_name) + + # 1. Plugin command (lowest precedence) + _write_plugin_command( + fake_plugin, + name=shared_name, + description="plugin version", + body="plugin body", + ) + + # 2. Global command (higher precedence) + try: + commands_helper.save_command( + name=shared_name, + description="global version", + body="global body", + ) + + effective, _ = commands_helper.list_effective_commands("") + by_name = {c["name"]: c for c in effective} + assert slug in by_name + assert by_name[slug]["description"] == "global version" + finally: + scope_dir = commands_helper.get_scope_directory("") + files.delete_file(os.path.join(scope_dir, f"{slug}.command.yaml")) + files.delete_file(os.path.join(scope_dir, f"{slug}.txt")) + + +def test_source_plugin_field_on_discovered_command(fake_plugin: dict): + """Discovered commands must carry the source_plugin field.""" + _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-src-test", + description="source plugin test", + ) + + discovered = commands_helper._discover_plugin_commands() + slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-src-test") + match = next((c for c in discovered if c["name"] == slug), None) + assert match is not None + assert match["source_plugin"] == fake_plugin["name"] + assert match["scope_key"] == "plugin" + assert match["scope_label"] == f"Plugin: {fake_plugin['name']}" + assert match["source_scope_key"] == "plugin" + assert match["source_scope_label"] == f"Plugin: {fake_plugin['name']}" + + +def test_is_plugin_commands_dir_recognises_plugin_path(fake_plugin: dict): + """_is_plugin_commands_dir must return True for files inside plugin commands/ dirs.""" + _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-path-check", + description="path test", + ) + slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-path-check") + config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml") + normalized = commands_helper._normalize_client_path(config_path) + + assert commands_helper._is_plugin_commands_dir(normalized) is True + + +def test_is_plugin_commands_dir_rejects_non_plugin_path(tmp_path: Path): + """_is_plugin_commands_dir must return False for arbitrary paths.""" + non_plugin_path = tmp_path / "not-a-plugin" / "commands" / "foo.txt" + assert commands_helper._is_plugin_commands_dir(str(non_plugin_path)) is False + + +def test_get_command_can_load_plugin_command(fake_plugin: dict): + """get_command must work for commands inside plugin directories.""" + _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-loadable", + description="loadable test", + body="load me", + ) + slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-loadable") + config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml") + normalized = commands_helper._normalize_client_path(config_path) + + command = commands_helper.get_command(normalized, project_name="demo-project") + assert command["name"] == slug + assert command["description"] == "loadable test" + assert command["body"] == "load me" + assert command["source_plugin"] == fake_plugin["name"] + assert command["scope_key"] == "plugin" + assert command["scope_label"] == f"Plugin: {fake_plugin['name']}" + assert command["source_scope_key"] == "plugin" + assert command["source_scope_label"] == f"Plugin: {fake_plugin['name']}" + + +def test_save_command_rejects_plugin_existing_path(fake_plugin: dict): + """Editing a plugin command must fail because plugin commands are read-only.""" + config_path = _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-readonly-edit", + description="read-only test", + body="plugin body", + ) + normalized = commands_helper._normalize_client_path(config_path) + + with pytest.raises(ValueError, match="Plugin commands are read-only"): + commands_helper.save_command( + existing_path=normalized, + name=f"{fake_plugin['name']}-readonly-edit", + description="updated description", + body="updated body", + ) + + +def test_delete_command_rejects_plugin_command(fake_plugin: dict): + """Deleting a plugin command must fail because plugin commands are read-only.""" + config_path = _write_plugin_command( + fake_plugin, + name=f"{fake_plugin['name']}-readonly-delete", + description="read-only delete", + ) + normalized = commands_helper._normalize_client_path(config_path) + + with pytest.raises(ValueError, match="Plugin commands are read-only"): + commands_helper.delete_command(normalized) + + assert os.path.exists(config_path) diff --git a/plugins/_commands/webui/commands-slash-store.js b/plugins/_commands/webui/commands-slash-store.js new file mode 100644 index 000000000..0d43de546 --- /dev/null +++ b/plugins/_commands/webui/commands-slash-store.js @@ -0,0 +1,513 @@ +import { createStore } from "/js/AlpineStore.js"; +import { callJsonApi } from "/js/api.js"; +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; +import { store as chatInputStore } from "/components/chat/input/input-store.js"; +import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js"; +import { + toastFrontendError, + toastFrontendInfo, + toastFrontendSuccess, +} from "/components/notifications/notification-store.js"; +import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js"; + +const COMMANDS_API_PATH = "/plugins/_commands/commands"; + +function sanitizeCommandName(rawName) { + return (rawName || "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); +} + +function parseSlashInput(message) { + const text = String(message || ""); + const match = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/); + if (!match) { + return { + active: false, + query: "", + rawArguments: "", + rawMessage: text, + }; + } + + return { + active: true, + query: (match[1] || "").trim().toLowerCase(), + rawArguments: match[2] || "", + rawMessage: text, + }; +} + +function notifyError(message) { + void toastFrontendError(message, "Commands"); +} + +function notifySuccess(message) { + void toastFrontendSuccess(message, "Commands"); +} + +const HTML_ESCAPE = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value) { + return String(value || "").replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]); +} + +function notifyInfo(title, message) { + const formatted = escapeHtml(message).replace(/\n/g, "
"); + void toastFrontendInfo(formatted, title || "Commands", 3, "", undefined, true); +} + +const model = { + loading: false, + applying: false, + commands: [], + contextScope: { project_name: "" }, + lastContextId: "", + active: false, + dismissed: false, + query: "", + rawArguments: "", + rawMessage: "", + selectedIndex: 0, + boundInput: null, + keydownHandler: null, + inputHandler: null, + focusHandler: null, + commandsUpdatedHandler: null, + + get menuVisible() { + return this.active && !this.dismissed; + }, + + get filteredCommands() { + const needle = (this.query || "").trim().toLowerCase(); + const commands = Array.isArray(this.commands) ? this.commands : []; + + if (!needle) return commands; + + return commands.filter((command) => { + const haystack = `${command?.name || ""} ${command?.description || ""}`.toLowerCase(); + return haystack.includes(needle); + }); + }, + + get selectedCommand() { + const commands = this.filteredCommands; + if (!commands.length) return null; + return commands[this.selectedIndex] || commands[0] || null; + }, + + get emptyStateLabel() { + const name = sanitizeCommandName(this.query || ""); + return name ? `Create /${name}` : "Create slash command"; + }, + + onMount() { + this.ensureBindings(); + + this.keydownHandler = (event) => this.handleKeydown(event); + this.commandsUpdatedHandler = () => { + this.commands = []; + if (this.menuVisible) { + void this.loadCommands(true); + } + }; + + document.addEventListener("keydown", this.keydownHandler, true); + window.addEventListener("commands:updated", this.commandsUpdatedHandler); + this.handleInput(); + }, + + cleanup() { + this.removeBindings(); + if (this.keydownHandler) { + document.removeEventListener("keydown", this.keydownHandler, true); + } + if (this.commandsUpdatedHandler) { + window.removeEventListener("commands:updated", this.commandsUpdatedHandler); + } + this.keydownHandler = null; + this.commandsUpdatedHandler = null; + this.dismissed = false; + this.active = false; + this.query = ""; + this.rawArguments = ""; + this.rawMessage = ""; + this.selectedIndex = 0; + this.applying = false; + }, + + ensureBindings() { + const input = this.getInputElement(); + if (!input || input === this.boundInput) return; + + this.removeBindings(); + + this.inputHandler = (event) => this.handleInput(event); + this.focusHandler = () => this.handleInput(); + input.addEventListener("input", this.inputHandler); + input.addEventListener("focus", this.focusHandler); + this.boundInput = input; + }, + + removeBindings() { + if (this.boundInput && this.inputHandler) { + this.boundInput.removeEventListener("input", this.inputHandler); + } + if (this.boundInput && this.focusHandler) { + this.boundInput.removeEventListener("focus", this.focusHandler); + } + this.boundInput = null; + this.inputHandler = null; + this.focusHandler = null; + }, + + getInputElement() { + return document.getElementById("chat-input"); + }, + + getInputMessage(event = null) { + const target = event?.target || null; + const targetEditor = target?.closest?.("#chat-input"); + if (targetEditor?.isContentEditable || target?.isContentEditable) { + return ( + chatInputStore?._editorToMarkdown?.() || + targetEditor?.textContent || + target?.textContent || + "" + ); + } + if (typeof target?.value === "string") return target.value; + + const input = this.getInputElement(); + if (input?.isContentEditable) { + return chatInputStore?._editorToMarkdown?.() ?? input.textContent ?? ""; + } + if (typeof input?.value === "string") return input.value; + return chatInputStore?.message ?? ""; + }, + + getContextId() { + return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || ""; + }, + + async loadCommands(force = false) { + const contextId = this.getContextId(); + + if (!force && this.commands.length && contextId === this.lastContextId) { + this.ensureSelection(); + return; + } + + this.loading = true; + try { + const response = await callJsonApi(COMMANDS_API_PATH, { + action: "list_effective", + context_id: contextId, + }); + this.commands = Array.isArray(response?.commands) ? response.commands : []; + this.contextScope = response?.scope || { + project_name: "", + }; + this.lastContextId = contextId; + this.ensureSelection(); + } catch (error) { + console.error("Failed to load effective commands:", error); + this.commands = []; + this.contextScope = { project_name: "" }; + } finally { + this.loading = false; + } + }, + + handleInput(event = null) { + this.ensureBindings(); + this.dismissed = false; + + const message = this.getInputMessage(event); + const parsed = parseSlashInput(message); + + this.active = parsed.active; + this.query = parsed.query; + this.rawArguments = parsed.rawArguments; + this.rawMessage = parsed.rawMessage; + + if (!this.active) { + this.selectedIndex = 0; + return; + } + + this.ensureSelection(); + void this.loadCommands(); + }, + + handleKeydown(event) { + const input = this.getInputElement(); + if (!this.menuVisible || !input || document.activeElement !== input) return; + if (event.isComposing || event.keyCode === 229) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + this.moveSelection(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + this.moveSelection(-1); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.dismissed = true; + return; + } + + if (event.key === "Enter" && this.selectedCommand) { + event.preventDefault(); + event.stopPropagation(); + void this.applySelection(this.selectedCommand); + } + }, + + ensureSelection() { + const commands = this.filteredCommands; + if (!commands.length) { + this.selectedIndex = 0; + return; + } + if (this.selectedIndex >= commands.length) { + this.selectedIndex = 0; + } + }, + + moveSelection(delta) { + const commands = this.filteredCommands; + if (!commands.length) return; + const nextIndex = + (this.selectedIndex + delta + commands.length) % commands.length; + this.selectedIndex = nextIndex; + this.scrollSelectedIntoView(); + }, + + scrollSelectedIntoView() { + requestAnimationFrame(() => { + document + .querySelector(".commands-slash-results .commands-slash-item.active") + ?.scrollIntoView({ block: "nearest" }); + }); + }, + + async applySelection(command) { + if (!command || this.applying) return; + const input = this.getInputElement(); + if (!input) return; + + this.applying = true; + try { + const contextId = this.getContextId(); + const fallbackSlash = this.rawMessage?.trim() + ? this.rawMessage + : this.rawArguments + ? `/${command.name} ${this.rawArguments}` + : `/${command.name}`; + + const response = await callJsonApi(COMMANDS_API_PATH, { + action: "resolve", + path: command.path, + slash_text: fallbackSlash, + project_name: this.contextScope?.project_name || "", + context_id: contextId, + }); + + const applied = await this.applyResolution(response?.resolution, input); + if (!applied?.hadToast && !applied?.hadError) { + notifySuccess(`Applied /${command.name}`); + } + } catch (error) { + console.error("Failed to apply slash command:", error); + notifyError(error?.message || "Failed to apply slash command."); + } finally { + this.applying = false; + } + }, + + async applyResolution(resolution, input) { + const result = resolution?.result || {}; + const hasText = typeof result.text === "string"; + let nextText = hasText ? result.text : this.getInputMessage(); + const effects = Array.isArray(result.effects) ? result.effects : []; + let hadToast = false; + let hadError = false; + + for (const effect of effects) { + if (!effect || typeof effect !== "object") continue; + const type = String(effect.type || "").trim().toLowerCase(); + if (type === "replace_input") { + nextText = String(effect.text || ""); + continue; + } + if (type === "append_input") { + const chunk = String(effect.text || ""); + nextText = nextText ? `${nextText}\n${chunk}` : chunk; + continue; + } + if (type === "toast") { + hadToast = true; + const level = String(effect.level || "info").toLowerCase(); + const message = String(effect.message || ""); + if (!message) continue; + if (level === "error") { + hadError = true; + notifyError(message); + } else { + notifySuccess(message); + } + continue; + } + if (type === "new_chat") { + await chatsStore?.newChat?.(); + continue; + } + if (type === "select_chat") { + const contextId = String(effect.context_id || "").trim(); + if (contextId) await chatsStore?.selectChat?.(contextId); + continue; + } + if (type === "reset_chat") { + await chatsStore?.resetChat?.(String(effect.context_id || "") || null); + continue; + } + if (type === "pause_agent") { + await chatInputStore?.pauseAgent?.(Boolean(effect.paused)); + continue; + } + if (type === "nudge_agent") { + await chatInputStore?.nudge?.(); + continue; + } + if (type === "open_modal") { + const path = String(effect.path || "").trim(); + if (path) await window.openModal?.(path); + continue; + } + if (type === "show_markdown") { + hadToast = true; + notifyInfo( + String(effect.title || "Slash Command"), + String(effect.content || ""), + ); + continue; + } + if (type === "open_plugin_config") { + const pluginName = String(effect.plugin || "").trim(); + if (pluginName) { + const { store } = await import("/components/plugins/plugin-settings-store.js"); + await store.openConfig( + pluginName, + String(effect.project_name || ""), + String(effect.agent_profile || ""), + ); + } + continue; + } + if (type === "compact_chat") { + const { store } = await import("/plugins/_chat_compaction/webui/compact-store.js"); + await store.fetchStats(); + continue; + } + if (type === "attach_files") { + await this.openAttachmentPicker(effect); + continue; + } + if (type === "copy_transcript") { + await this.copyTranscript(); + hadToast = true; + continue; + } + if (type === "clear_transcript") { + const history = document.getElementById("chat-history"); + if (history) history.innerHTML = ""; + continue; + } + } + + if (typeof input.value === "string") input.value = nextText; + chatInputStore.message = nextText; + input.dispatchEvent(new Event("input", { bubbles: true })); + chatInputStore.adjustTextareaHeight(); + input.focus(); + if (typeof input.setSelectionRange === "function") { + input.setSelectionRange(nextText.length, nextText.length); + } else { + chatInputStore?._setEditorCaret?.(nextText.length); + } + + this.active = false; + this.dismissed = false; + this.query = ""; + this.rawArguments = ""; + this.rawMessage = nextText; + this.selectedIndex = 0; + return { hadToast, hadError }; + }, + + openAttachmentPicker(effect = {}) { + return new Promise((resolve) => { + const picker = document.createElement("input"); + let settled = false; + const done = () => { + if (settled) return; + settled = true; + picker.remove(); + resolve(); + }; + picker.type = "file"; + picker.multiple = true; + picker.accept = String(effect.accept || "*"); + picker.style.display = "none"; + picker.addEventListener("change", () => { + attachmentsStore?.handleFiles?.(picker.files || []); + done(); + }, { once: true }); + window.addEventListener("focus", () => setTimeout(done, 500), { once: true }); + document.body.appendChild(picker); + picker.click(); + }); + }, + + async copyTranscript() { + const text = document.getElementById("chat-history")?.innerText?.trim() || ""; + if (!text) { + notifyError("No visible transcript to copy."); + return; + } + await navigator.clipboard.writeText(text); + notifySuccess("Transcript copied."); + }, + + openCreateCommand() { + commandsManagerStore.openManager({ + projectName: this.contextScope?.project_name || "", + prefillName: sanitizeCommandName(this.query || ""), + openEditor: true, + }); + this.dismissed = true; + }, +}; + +export const store = createStore("commandsSlash", model); diff --git a/plugins/_commands/webui/commands-store.js b/plugins/_commands/webui/commands-store.js new file mode 100644 index 000000000..8cae2b843 --- /dev/null +++ b/plugins/_commands/webui/commands-store.js @@ -0,0 +1,417 @@ +import { createStore } from "/js/AlpineStore.js"; +import { callJsonApi } from "/js/api.js"; +import { + toastFrontendError, + toastFrontendSuccess, +} from "/components/notifications/notification-store.js"; +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; + +const COMMANDS_API_PATH = "/plugins/_commands/commands"; +const MAIN_MODAL_PATH = "/plugins/_commands/webui/main.html"; +const EDITOR_MODAL_PATH = "/plugins/_commands/webui/editor.html"; + +function createEmptyEditor() { + return { + mode: "create", + existingPath: "", + path: "", + name: "", + description: "", + argumentHint: "", + commandType: "text", + includeHistory: false, + body: "", + extraFrontmatter: {}, + }; +} + +function safeStringify(value) { + try { + return JSON.stringify(value ?? {}); + } catch { + return ""; + } +} + +function sanitizeCommandName(rawName) { + return (rawName || "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); +} + +function buildDefaultBody(commandType = "text") { + if (commandType === "script") { + return [ + "def run(payload):", + " args = payload.get('arguments', {})", + " flags = args.get('flags', {})", + " positional = args.get('positional', [])", + " return {", + " 'text': f\"Script command received args: {positional} flags: {flags}\",", + " 'effects': [],", + " }", + "", + ].join("\n"); + } + return "Describe the work to perform here.\n\n{raw}"; +} + +function notifyError(message) { + void toastFrontendError(message, "Commands"); +} + +function notifySuccess(message) { + void toastFrontendSuccess(message, "Commands"); +} + +function emitCommandsUpdated() { + window.dispatchEvent(new CustomEvent("commands:updated")); +} + +const model = { + loading: false, + saving: false, + projects: [], + projectName: "", + scope: null, + contextScope: { project_name: "" }, + commands: [], + pendingScope: null, + pendingCreate: null, + editor: createEmptyEditor(), + editorSnapshot: "", + + get selectedScopeLabel() { + return this.scope?.scope_label || "Global"; + }, + + get selectedScopeDirectory() { + return this.scope?.directory_path || ""; + }, + + get hasCommands() { + return (this.commands || []).length > 0; + }, + + get editorTitle() { + return this.editor.mode === "edit" ? "Edit Slash Command" : "Create Slash Command"; + }, + + get editorDirty() { + return this._serializeEditor() !== this.editorSnapshot; + }, + + get editorBodyLabel() { + return this.editor.commandType === "script" ? "Python hook" : "Text template"; + }, + + openManager(options = {}) { + const hasExplicitScope = Object.prototype.hasOwnProperty.call(options, "projectName"); + + this.pendingScope = hasExplicitScope + ? { + projectName: options.projectName || "", + } + : null; + + this.pendingCreate = + options.openEditor || options.prefillName + ? { + name: options.prefillName || "", + } + : null; + + return window.openModal?.(MAIN_MODAL_PATH); + }, + + async onOpen() { + await this.loadProjects(); + + try { + await this.resolveInitialScope(); + await this.loadCommands(); + } catch (error) { + console.error("Failed to initialize commands manager:", error); + this.scope = null; + this.commands = []; + notifyError(error?.message || "Failed to open the commands manager."); + } + + if (this.pendingCreate) { + const pendingCreate = { ...this.pendingCreate }; + this.pendingCreate = null; + await this.openCreateCommand({ name: pendingCreate.name }); + } + }, + + cleanup() { + this.loading = false; + this.saving = false; + this.projects = []; + this.projectName = ""; + this.scope = null; + this.contextScope = { project_name: "" }; + this.commands = []; + this.pendingScope = null; + this.pendingCreate = null; + this.resetEditor(); + }, + + async loadProjects() { + try { + const response = await callJsonApi("projects", { action: "list_options" }); + this.projects = Array.isArray(response?.data) ? response.data : []; + } catch { + this.projects = []; + } + }, + + normalizeProject(projectName) { + if (!projectName) return ""; + return (this.projects || []).some((project) => project?.key === projectName) + ? projectName + : ""; + }, + + async resolveInitialScope() { + const contextId = + chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || ""; + const scopeInfo = await callJsonApi(COMMANDS_API_PATH, { + action: "scope_info", + context_id: contextId, + }); + + this.contextScope = scopeInfo?.context_scope || { + project_name: "", + }; + + const preferredScope = this.pendingScope || scopeInfo?.scope || {}; + this.projectName = this.normalizeProject(preferredScope.project_name || ""); + this.pendingScope = null; + }, + + async loadCommands() { + this.loading = true; + + try { + const response = await callJsonApi(COMMANDS_API_PATH, { + action: "list_scope", + project_name: this.projectName || "", + }); + + this.commands = Array.isArray(response?.commands) ? response.commands : []; + this.scope = response?.scope || null; + } catch (error) { + console.error("Failed to load commands:", error); + this.commands = []; + this.scope = null; + notifyError(error?.message || "Failed to load commands."); + } finally { + this.loading = false; + } + }, + + async refresh() { + await this.loadCommands(); + }, + + async onScopeChanged() { + this.projectName = this.normalizeProject(this.projectName); + await this.loadCommands(); + }, + + overrideBadgeLabel(command) { + const count = Number(command?.override_count || 0); + if (!count) return ""; + if (count === 1) { + return `Overrides ${command.override_scopes[0]}`; + } + return `Overrides ${count} lower scopes`; + }, + + async browseScopeFolder() { + try { + const response = await callJsonApi(COMMANDS_API_PATH, { + action: "scope_info", + project_name: this.projectName || "", + ensure_directory: true, + }); + if (response?.scope?.directory_path) { + await fileBrowserStore.open(response.scope.directory_path); + } + } catch (error) { + console.error("Failed to open scope folder:", error); + notifyError(error?.message || "Failed to open scope folder."); + } + }, + + async openCreateCommand(options = {}) { + if (Object.prototype.hasOwnProperty.call(options, "projectName")) { + this.projectName = this.normalizeProject(options.projectName || ""); + await this.loadCommands(); + } + + const suggestedName = sanitizeCommandName(options.name || ""); + this.editor = { + ...createEmptyEditor(), + mode: "create", + name: suggestedName, + commandType: "text", + body: buildDefaultBody("text"), + }; + this.editorSnapshot = this._serializeEditor(); + await this.openEditorModal(); + }, + + async openEditCommand(command) { + if (!command?.path) return; + + try { + const response = await callJsonApi(COMMANDS_API_PATH, { + action: "get", + path: command.path, + project_name: this.projectName || "", + }); + const loaded = response?.command || command; + this.editor = { + mode: "edit", + existingPath: loaded.path || "", + path: loaded.path || "", + name: loaded.name || "", + description: loaded.description || "", + argumentHint: loaded.argument_hint || "", + commandType: loaded.command_type || "text", + includeHistory: Boolean(loaded.include_history), + body: loaded.body || "", + extraFrontmatter: loaded.frontmatter_extra || {}, + }; + this.editorSnapshot = this._serializeEditor(); + await this.openEditorModal(); + } catch (error) { + console.error("Failed to load command:", error); + notifyError(error?.message || "Failed to load command."); + } + }, + + async duplicateCommand(command) { + if (!command?.path) return; + + try { + const response = await callJsonApi(COMMANDS_API_PATH, { + action: "duplicate", + path: command.path, + project_name: this.projectName || "", + }); + await this.loadCommands(); + emitCommandsUpdated(); + notifySuccess(`Duplicated /${response?.command?.name || command.name}`); + if (response?.command) { + await this.openEditCommand(response.command); + } + } catch (error) { + console.error("Failed to duplicate command:", error); + notifyError(error?.message || "Failed to duplicate command."); + } + }, + + async deleteCommand(command) { + if (!command?.path) return; + + try { + await callJsonApi(COMMANDS_API_PATH, { + action: "delete", + path: command.path, + project_name: this.projectName || "", + }); + await this.loadCommands(); + emitCommandsUpdated(); + notifySuccess(`Deleted /${command.name}`); + } catch (error) { + console.error("Failed to delete command:", error); + notifyError(error?.message || "Failed to delete command."); + } + }, + + async openEditorModal() { + await window.openModal?.(EDITOR_MODAL_PATH, () => this.confirmCloseEditor()); + this.resetEditor(); + }, + + confirmCloseEditor() { + if (!this.editorDirty) return true; + return window.confirm("Discard unsaved slash command changes?"); + }, + + async closeEditor() { + await window.closeModal?.(EDITOR_MODAL_PATH); + }, + + setEditorType(nextType) { + const normalizedType = nextType === "script" ? "script" : "text"; + if (this.editor.commandType === normalizedType) return; + this.editor.commandType = normalizedType; + this.editor.includeHistory = + normalizedType === "script" ? this.editor.includeHistory : false; + this.editor.body = buildDefaultBody(normalizedType); + }, + + async saveEditor() { + this.saving = true; + + try { + const response = await callJsonApi(COMMANDS_API_PATH, { + action: "save", + project_name: this.projectName || "", + existing_path: this.editor.existingPath || "", + name: this.editor.name || "", + description: this.editor.description || "", + argument_hint: this.editor.argumentHint || "", + command_type: this.editor.commandType || "text", + include_history: + this.editor.commandType === "script" ? Boolean(this.editor.includeHistory) : false, + body: this.editor.body || "", + extra_frontmatter: this.editor.extraFrontmatter || {}, + }); + + this.editor.path = response?.command?.path || ""; + this.editor.existingPath = response?.command?.path || ""; + this.editorSnapshot = this._serializeEditor(); + await this.loadCommands(); + emitCommandsUpdated(); + notifySuccess( + `${this.editor.mode === "edit" ? "Updated" : "Saved"} /${response?.command?.name || this.editor.name}`, + ); + await window.closeModal?.(EDITOR_MODAL_PATH); + } catch (error) { + console.error("Failed to save command:", error); + notifyError(error?.message || "Failed to save command."); + } finally { + this.saving = false; + } + }, + + resetEditor() { + this.editor = createEmptyEditor(); + this.editorSnapshot = this._serializeEditor(); + }, + + _serializeEditor() { + return safeStringify({ + existingPath: this.editor.existingPath || "", + name: this.editor.name || "", + description: this.editor.description || "", + argumentHint: this.editor.argumentHint || "", + commandType: this.editor.commandType || "text", + includeHistory: Boolean(this.editor.includeHistory), + body: this.editor.body || "", + extraFrontmatter: this.editor.extraFrontmatter || {}, + }); + }, +}; + +export const store = createStore("commandsManager", model); diff --git a/plugins/_commands/webui/editor.html b/plugins/_commands/webui/editor.html new file mode 100644 index 000000000..8d6725a2f --- /dev/null +++ b/plugins/_commands/webui/editor.html @@ -0,0 +1,240 @@ + + + Slash Command + + + +
+ +
+ + + + + + diff --git a/plugins/_commands/webui/main.html b/plugins/_commands/webui/main.html new file mode 100644 index 000000000..dde4b6e62 --- /dev/null +++ b/plugins/_commands/webui/main.html @@ -0,0 +1,395 @@ + + + Commands + + + +
+ +
+ + + + diff --git a/plugins/_commands/webui/thumbnail.png b/plugins/_commands/webui/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..428b2b9bd3a6236fb1b6529891b4ce29e53414f8 GIT binary patch literal 5637 zcmcgw2{@E%-$zc-qM_vwI%6zHY0N%jCc_ys7@Z`t6w#UG85A>`8Ou;{rW7SH(qb27 zUn)zsq|=+F5QQj7$W}z;jqp9wdcN;;-uL~k>$|?WuIIU*x$ocq_uudPnF-&y!*bQ~ z4a;R@WL8`L`AOiQ=tJKlkdaxjMtaH0#3iW8$SmV}+V2+bw%$%+ z@w^QgY#tLZ4Dj{=(K0f|rU5<-)&W2WWdiP=TrzCDq8bMEWRqb|IBTS}4;Aq6qzCeW zU4c97S%C*wL^jOS1Zo^W0tvhUAp;uV?Zp+40?4qByd>~hI*ouqKSG2D$S{gjA#}I( zPAHYf2cUREB%Fmr;-LhhA)CQuu$as(P&5*aLLjjS6c&y|k}yOP77P9Sfr0AyY!1ne zX7O1W_>Bzn5DI-r2!y}Czo9?IkjHmNpol~w0*OYT(Qps}7l^n*MgW{E(Eh?e0|YF- zr;pH+$AwB68BCt9kPHJ&{p5qUkG1tzVy@tGq`;UV0vJ9Blpzw~?JafeBU&J|1HPH@ zU9`Yn|cg=Arf3=R^`A#hM|BQ%x)XW&sdI15c=qmgVP5zAtI;rX-tr$S=$SRlt&tt1SN z1z-tyBpgk^a^M&S77sVV;ZblD5(nUr93xN%<_nt@S0H3?S-=--JV7Q7fHh*WI2bqq z$2NjvnOFjx$iQ>pXf%K!5(!MK5sv*)oV$ z=P%gwCjo%Q5CUWvSjc!d5(CoOBXJ)KnP7lKk&wtwP-`CBlOy^cP>d9c#*;AE{{{uy zh|Lf({w_}m@j=C)bzp?P&0A5aJNZ10rx&;& zu(LFSTA5SvC?XyYM;oG~J@YBWG=T5v2e2*pJa6d7q9=KNC%g;>8bd&%iEuO#>^~wF zi-sF<*kBw8Od}l5h`>a0@UYMFU~2zTELd|4k%pogp-~nD6v_f+M#Uj%R5Nn|#mEeg zHZp=Cz8XaL6oBawecl#!0S7-nd3l2TU|*3KENP2_PBB2crAnof-!q!XLgATgBP1L} zFv7tJL@W|+ghWZrCjuxU3WMh0IbY=cJphpMYkL2hvrlRSJdV(x!3QYrU^o2x^7$mh z@?db?0dUVnd~{Q~H~sIO`OgaclfwT;XAn|vJ|zk9si3~)?^`W?>3<5?wbJYgKIhbo z;r=zdCg0x}@Ei^ng$D3&CIh??I9N8Eh(e*@0KmlJIRJqP7%`1MTH)mha(#UIM2q;> z`adH7bY^@J1`i_X1-M`q`QQEt@hs;rzqK zM}7rXV*NV2o{UE!F-)9bRkZvNt+YuY4!Y~c#Pg$%QWPg;37Urr4LroJ2Cp1l-5}Oe z7}kD%;!29*ZR=(Gpb9c#r~*_${I`RQSmy7Rr%WVC`a4H^^6j>o7T5;8YtOL>&UE@| z>*9x3CzejPl=|+Uj$hW6()q*43oW&}-=Q_V3BPOkz8)%czA+s#i@r`7h%L#KoJ`A} zdfwcBzBqfPI@q4M4%ItcBbz;hSAHZD~W?ob%+-gCbbY#L~tF$I+fGcBe|$W?zqi z$Wv4_I%}iMOFJAMK1eMYGs!jlY5z@RHFNjzxhV+c80r*X8N{5g$atC6y{~=$xacVc z-gDR-t;w4_S()5F^fEyfjHqIrE*0JOb}`q^0r%RO(Y-0>*SxzGv#Iy@)oE>ai9G{V zkOWL6Uo3d%pfP>6&%-6dWs#xY?)t{A#_9)U?uPhq5MZ}9h*kq#zB(vg zhJKWfsGKoQzvDPUu|OD>Y#4tzvFGLhzXTP8O7xXb6t1) z50{u}Q;r2?go1HpR7iMa+ugWQrR=*e ze=2U@Xv6K|C2D$UTkflquH4bg5^-;|kM8#v^Ld-sKj?io{;HlSOspVz8V2gdEf}LL zy}Nwr2C?Lv8+*t_g^#mChl*FbTWnXNNYp{)myTB&4sW?X(Df=z*c1}gmS2`0L9>JO zj%{Lq{tvN7k4zq}%1~`3qk{IG+VJkItCszuTBq`PYQVc#GZ0ZXITyWW>>>5%*s98r z%(&}U_qyU>_bOYaLxjbX#g{S_VCnboQ6Tc+8huHfgMO@zthHxD%Y>X#QE^-$mav~M z6uf`1Qc47M?evu>ukI+xQ5$W@y<5ITS9^r7aK`W+6(WDN?{@!6+|xb3)|F|Ja4+p_ zJzNxUzCcooK}@~atu-H2S>TeM z@s?d2aH$tB+-a6f`Bf)t+hRaQZ#$=R*|S%aE-SY=6f9PB1scZ`j)i6Li{k2sVe^N< zY#8H*%tl6R8PiD?TK4`1j&k0XzF3mCzy0mNxdOqk#R`Gd?;V4FtH_HurGKC$R@vKPar~{hqZNohkO~2I~X?r70WuKd+ouGRO&p9%1 z{KBux%-@URC-OBo=Xii!hoRkjP4{=@hd@~J#j=~*N87SXCcuWLRe0TvzuC3u7V2qW zON?uzS#%0%Q6Ve%W4EGaYl{vQ4^{-iDSk&B`!+UtVy>hg|7T+E3PNY-Z{x`WrmBU) z1B}}VI5Bi$q+!A5@#_Hni#OcPduenzDlXUS?Wi_~ekf;FEZ%gM$b>^>*VF81Y*Xsb zYPLHfuXVqrCwZ@LCmYC-^SBqEFV+_aT1J;6iN~OnwY1s(2F2^74yQe*_xHpeg~xia-9pQ`o5L-e*l2Gc(0#uuWwspJdP{Wsno2RjX?*+Y;Y<1l*2SZA zWgm>Q4~?}Aox8p5TAGz9Jbvj@h1M1F(e*doc~y>1?@o^338I$#VQSrBhsmvK1!pMU z$F0^MtX!T0(XjxEc6FT1ay;GFN{cM!Y1%s}l|BhZX5G6`s)hGe(_`%GePMKV5Jr!% z>%2@2Z_;%Srq6ARuZv9&$Q5<>^v7(wwOem_q|1wa&)=)BfTR~F!rUd*Q)60r)|4{6 zg3ZMn1q(|T3y`bt{&R>EF1cuIUcNO8O8-f@e*rw#Z>8e%HrFI3cVwFG!3S4(CM?b` zRa{cbQcf_gT^w7GsLp_Uoj&Y!kkJ4B<;F90IyM@8^v)0=p{02|J!bi>fT|;L$5KyR z9)QyKLHaK`89YkeP=z6%^DYQ`VzJjaEvVJFX}o7oyVKyRm0EP(`u;duMjm-}7UjH> zZb@TLdQwnhb$6Y zEm!MG6DNG%CEpKmPNaLdG<4cznSzQXg&lo(eCp6T!*^~M2w4BjQWFzqcTYpnQT*AGizJ$qG&AAS^Aci_V7fZ*UQ_w;)j4xrReX{Y%5 zk6T}pJI6JkMbT7fdQW}R{umRxxYvFd&9%U>Z_Zoq^(wT8DlsJ=zVa%w)+~~{w7D^| zVVvmUh!^XegsNPM<+M6%c<(siVA-bKe9q{=vU>}TOVtXm*}S+2tG)`oBBnb>3?3Br z_3Z<*uzveqw--AOB^Ktd-QJ1+4SAR_6F5l7ay=t&haH&S8lMo9`4$WdKuIsnPf*Sb zx^f~t`Hr?GEAnnCL9cc#Z8@<68Lj@Cc9>Rc!0epyxR>juV-oX9@6%;gYdm^yZ=7|h z-!Rewhz`b3qXHoX7HwB*j?%HWEFjdS%?9?JbIwPcL+)K1sMS8*qw~sORkm&gL8Z-K za@ZjLX!**ix?K`Q9ZPlk9euZmKvJn?cttYoh^f=IFz=Ji5FNQ?J$3zyyiCgBS!$Jf z{fNba>q5nr=e+o{j|W4Rh^Mjzt+L=544!>5-v$}VjouKbf*kl3^W8y4LGC;Ak}z=* z|6$ECYs#ARv++tMkAK8c`~={{_)_brO;3eyYSL2_`NyeS2^+CdqE>{5Siz<2eM*hq zMFeGSdNLKhMkX-jZtqmea@f