Slash Commands — Unified Design#
Design goal: merge OpenProgram's currently scattered, mutually disconnected "command sources" — the CLI hardcoded table, the Web composer hardcoded table, /api/plugins/commands, MCP prompts, and skills — into a single unified slash-command registry: five layers, one format, one rendering pipeline, one UI.
For the reference implementations these choices draw on, see slash-commands-references.md. Wherever this document says a design is taken from a project, it means that project's design choice is reused directly.
1. Source Layers#
Load order is low to high, with a higher-priority layer overriding a lower-priority one of the same name; the overridden command is not lost and can still be invoked explicitly via /source:name.
| Layer | Source | Directory / Interface | Author | Hot reload |
|---|---|---|---|---|
| L0 | built-in | hardcoded in source | OpenProgram itself | no (requires restart) |
| L1 | plugins | entrypoints.commands under ~/.openprogram/plugins/<pkg>/... |
plugin authors | on plugin reload |
| L2 | mcp-prompts | list_prompts() of connected MCP servers |
MCP servers | on session reconnect |
| L3 | skills | ~/.openprogram/skills/<name>/SKILL.md |
skill authors / users | via watcher |
| L4 | user | ~/.openprogram/commands/**/*.md |
current user | via watcher |
| L5 | project | <cwd>/.openprogram/commands/**/*.md |
project maintainers | via watcher |
Override rules borrow from claude-code: the later-loaded one wins; within the same source, duplicates of the same name are deduplicated by realpath to prevent symlinks from loading the same file twice.
Explicit namespace format: /(plugin)name, /(mcp:linear)name, /(skill)name, /(user)name, /(project)name. The text inside the parentheses is the source label.
On conflict, the menu shows the primary entry plus a hint "this name has N other sources, press ⇥ to switch," borrowing the disambiguation UI approach from claude-code.
2. File Format#
Borrow claude-code's markdown + YAML frontmatter; the field set is the union of claude-code + opencode + openclaw + hermes, then trimmed of what's meaningless to us (i18n, provider routing, platform filter).
---
# Identity ----------------------------------------------------------
name: review # optional; defaults to the file name
aliases: [r, rev] # optional; also subject to override rules in the name table
description: Review the current diff per team conventions
when-to-use: | # long description; shown in the picker detail panel
Use when the user wants a round of style + bug review on uncommitted changes.
hidden: false # if true, does not appear in the completion menu, but can still be triggered explicitly
# Arguments ----------------------------------------------------------
arguments: # positional argument declarations (opencode style)
- name: target
description: File path or directory; defaults to the current diff
required: false
argument-hint: "[target]" # grayed-out hint in the menu (claude-code)
# Execution ----------------------------------------------------------
type: prompt # prompt | local | local-jsx, default prompt
context: inline # inline | fork, default inline
agent: general-purpose # only takes effect when context: fork
model: inherit # inherit | opus | sonnet | haiku | <full id>
effort: medium # low | medium | high | max | <int>
allowed-tools: # tool allowlist passed to the sub-agent in fork mode
- Read
- Grep
# Trigger conditions (claude-code only) -----------------------------
paths: # globs; the command only appears in completion when matched
- "src/**/*.{ts,tsx}"
- "**/*.py"
requires: # openclaw-style prerequisite checks
any-bins: [git]
config: [openai_api_key]
# Hooks ----------------------------------------------------------
hooks: # shares event names with plugins/hooks
PreToolUse: ...
# Meta -----------------------------------------------------------
version: 1.0.0
---
Please review this code at {{target}}:
- Find potential bugs
- Check whether it conforms to CONVENTIONS.md
- Output patch suggestions
Additional context:
$ARGUMENTS
Recent commits:
!`git log -5 --oneline`
Current diff:
@`git diff --staged`
The authoritative field table is in Section 7.
3. Command Body Template Syntax#
Borrow all of claude-code's, plus opencode's $0..$9, plus hermes's timeout-bounded shell.
| Syntax | Meaning | Source |
|---|---|---|
$ARGUMENTS |
the entire text the user typed after the command | claude-code |
$0..$9 |
the Nth positional argument (shell-style tokenization) | opencode |
{{name}} |
a named argument declared in arguments: |
opencode + custom |
${OPENPROGRAM_COMMAND_DIR} |
absolute path of the directory containing the command file | claude-code |
${OPENPROGRAM_SESSION_ID} |
current session id | claude-code |
${OPENPROGRAM_CWD} |
current working directory | new |
!`cmd` or code block `` ```! |
execute in the host shell, splice stdout back into the prompt; 2s timeout | claude-code + hermes |
@`path` |
read file contents and splice back into the prompt; the path must be inside trusted_roots | new |
<<command-name>>name<</command-name>> |
reference another command and expand it (recursion guard, max 3 levels) | new |
Argument parsing borrows claude-code's tryParseShellCommand: first tokenize with shell-quote, and on failure fall back to whitespace split. Empty arguments return an empty list, and $0..$9 in the template resolve to empty strings.
Numeric named arguments (name: "0") are rejected at registration time, as they conflict with $0.
Security model for shell execution: disabled by default; you must set commands.allow_shell: true in the config before !`...` can run. All ! blocks are disabled in the MCP context.
4. Execution Modes#
Borrow claude-code's three states, plus opencode's subtask concept.
type: prompt (default)
render the template → inject it as a user message into the current session → run the normal agent loop
type: local
call the host-registered LocalCommandHandler; returns a LocalCommandResult
reserved for built-in commands: /compact /clear /new /web /model etc.
type: local-jsx
not implemented for now. The Web UI can render a React component as the command result (claude-code uses ink)
on our side we go through a server-pushed structured event; leave the interface open
context: inline (default)
the rendered prompt enters the current session context
context: fork
run in an agent sub-agent (uses existing functions/tools/agent)
the agent field determines subagent_type; allowed-tools determines the visible tool set
the sub-agent's final message is presented as a "command result" and does not pollute the main context
context: fork is equivalent to "typing /review automatically turns into a single call to agent(prompt=...)." This step grafts claude-code's fork mode directly onto our existing subagent mechanism.
5. Trigger Conditions (paths / requires)#
Borrow claude-code's paths and openclaw's requires: when conditions are not met, hide from the completion menu, but the user can still trigger it by manually typing the full command — at which point a hard validation runs again, with a clear error on failure.
paths: ["src/**/*.py"]: shown only when files recently touched in the current session (or explicitly referenced via @file) match the glob.
requires.any-bins: [git, rg]: a which check that at least one is available. On failure, hint "needs git / rg, please install first."
requires.config: [openai_api_key]: the current profile has this key configured.
requires.platform: [darwin, linux]: a platform filter borrowed from hermes.
6. Hook Binding#
A command can declare its own temporary hook (effective only during that command's execution). Event names reuse the bus event types in openprogram/events/registry.py (tool.before, tool.after, chat.before_send, ...).
hooks:
PreToolUse:
- matcher: Bash
command: !`echo "blocked by /review" >&2; exit 2`
PostToolUse:
- matcher: Edit
handler: built-in:auto-stage
Two handler forms:
!backtick block → run shell, stdout goes to the log, exit code decides allow/denybuilt-in:<id>→ call a host-registered named handler (not in the first release; interface left open)
This becomes fully usable once the hooks subsystem gains intercept and rewrite semantics. The schema is fixed here first so that adding it later is not a breaking change.
7. Authoritative Frontmatter Field Table#
| Field | Type | Default | Meaning | Borrowed from |
|---|---|---|---|---|
| name | string | file name stem | command name | common |
| aliases | string[] | [] | aliases, independently subject to the override table | claude-code |
| description | string | "" | one-line description, shown in the menu | common |
| when-to-use | string | md | "" | long description, detail panel | claude-code |
| hidden | bool | false | hide from completion | claude-code (isHidden) |
| arguments | list | [] | positional argument declarations | opencode |
| argument-hint | string | auto-generated | grayed-out menu hint | claude-code |
| type | enum | prompt | execution mode | claude-code |
| context | enum | inline | inline / fork | claude-code |
| agent | string | "general-purpose" | subagent_type when forking | claude-code |
| model | enum/string | inherit | model override | claude-code |
| effort | enum/int | inherit | reasoning effort | claude-code |
| allowed-tools | string[] | inherit | tool allowlist | claude-code |
| paths | string[] | null | glob-conditioned activation | claude-code |
| requires | object | {} | prerequisites | openclaw |
| hooks | object | {} | temporary hooks | claude-code |
| version | semver | null | for upgrade prompts | claude-code |
| user-invocable | bool | true | whether /name triggers it; when false, only the model can call it |
claude-code |
| shell | enum | inherit | bash / powershell, used by ! blocks |
claude-code |
Any undeclared fields are preserved into an extras dict without error (forward compatible).
8. UI#
The completion menu is grouped by source, alphabetically within a group, and across groups by source priority (project > user > skill > mcp > plugin > builtin). Each entry shows:
/review Review the current diff per team conventions [project]
[target]
Search is fuzzy (name + description + when-to-use).
Detail panel (expand with ⇥):
/review (project)
─────────────────────────────────────
Review the current diff per team conventions
Arguments: [target] (optional)
Mode: inline · model: inherit · effort: medium
Source: .openprogram/commands/review.md
Conflict state: the menu entry is tagged (+2 more) on the right; ⇥ switches between implementations from different sources.
Skills (L3) and MCP prompts (L2) are auto-injected; skill commands default to context: fork, agent: general-purpose, and MCP prompts default to type: prompt + inline (since they are prompt templates to begin with).
9. Security#
- Path loading: after
realpathresolution, the path must fall within trusted_roots (~/.openprogram/,$cwd/.openprogram/), otherwise rejected. - YAML parsing:
yaml.safe_load, with arbitrary-type construction disabled. - Glob:
fnmatchstyle, with..and absolute paths disabled. !shell blocks: disabled by default, 2s timeout, fork bombs forbidden, stdout capped at 64KB.@file references: must be inside trusted_roots or explicitly authorized via--allow-file <abs>.- The source label is always written by the loader; frontmatter is not allowed to self-report
source:.
10. Engineering Implementation#
10.1 Directory Layout#
openprogram/commands/
├── __init__.py # public API: list_commands / get / dispatch
├── loader.py # scan + parse + merge the five layers
├── frontmatter.py # YAML parsing + field validation
├── template.py # $ARGUMENTS / {{name}} / !`...` / @`...` rendering
├── conditions.py # paths / requires evaluation
├── registry.py # in-process merged table + conflict index
├── dispatch.py # type/context branching
├── watcher.py # inotify/fsevents watch for L3-L5
└── _ref.py # lightweight view projection for web/cli
10.2 Data Flow#
startup / reload
→ loader.scan_all_layers()
→ for each layer: read files / call provider (plugins, mcp.list_prompts)
→ frontmatter.parse + validate
→ registry.merge(layer, items) override + aliases + conflict index
user types /review xxx
→ cli or web forwards to dispatch.invoke(name, raw_args, session_ctx)
→ registry.resolve(name) → CommandSpec
→ conditions.check(spec, session_ctx) → ok / blocked-with-reason
→ template.render(spec.body, parsed_args, env)
→ dispatch by type:
prompt + inline → session.append_user_message(rendered)
prompt + fork → task.run(agent=spec.agent, prompt=rendered, tools=allowed)
local → handler(session_ctx, parsed_args)
10.3 API#
Backend:
GET /api/commands # the merged unified list (with source, metadata)
GET /api/commands/{name} # single-entry detail (with body template preview)
POST /api/commands/{name}/invoke # body: {session_id, raw_args}
POST /api/commands/reload # force a rescan
GET /api/commands/conflicts # conflict table (same name, multiple sources)
/api/plugins/commands is retained as a compatibility entry point, internally redirecting to /api/commands?source=plugin.
Frontend:
web/components/chat/composer/slash/use-slash-menu.ts is changed to read /api/commands, deleting the internal hardcoded list (keeping a dispatcher compatibility layer that maps client-side /compact, /clear, etc. to builtin local commands).
CLI:
openprogram/_cli_chat/handlers.py:_handle_slash resolves every /slash through the registry — the TUI and the WebUI share the same table, and no hardcoded command list remains. The Rich REPL's local actions live in the builtin layer: register_repl_builtins registers each one with handler set to the action name as a marker string, and _LOCAL_ACTIONS maps that marker to the local implementation. Existence, aliases, and /help all read from the registry (list_all()). Commands from the other layers (plugin / skill / user / project) render through dispatch.invoke; the rendered body is sent to the agent as the turn's message — the same expansion semantics as the Web composer.
The Ink TUI (cli/src/commands/registry.ts) hardcodes only its TUI-local actions (theme, pickers, export, ...). It fetches the unified registry from the worker over GET /api/commands — merged into completion, the ctrl+K palette, and /help — and expands registry commands via POST /api/commands/invoke, sending the rendered body as the chat turn.
10.4 Build Order#
The pieces are independently shippable and land in dependency order:
1 scanner + frontmatter + rendering + registry + /api/commands [foundation]
2 L4 (~/.openprogram/commands) + L5 (.openprogram/commands)
3 L3 skills auto-injection (skills/loader exposes to_command_spec())
4 L2 mcp prompts auto-injection (mcp/registry already has list_prompts)
5 L1 plugins onboarded onto the new table (plugins/loader already has _commands, plus an adapter layer)
6 context: fork wired to the agent tool
7 paths / requires trigger-condition evaluation
8 watcher hot reload
9 builtin commands migrated to type: local + frontmatter
10 hooks field enabled after the hooks subsystem is upgraded
Items 1-2 and 5 together deliver most of the user-visible value.
11. What We Don't Implement (Explicitly Dropped)#
| Source | Design | Reason for not copying |
|---|---|---|
| openclaw | i18n (descriptionLocalizations) | we have just English + Simplified Chinese, runtime switching adds no value |
| openclaw | provider routing (Slack vs Mattermost) | single host, no multi-provider naming |
| hermes | platforms filter (darwin/linux/win32) | replaced by requires.platform |
| hermes | prompt-injection 134-pattern detection | moved to a standalone prompt-injection scanner subsystem |
| claude-code | local-jsx React components | the Web UI uses structured events instead, CLI does not implement it |
| pi-mono | pure hardcoding | a counterexample |
12. Versioning and Upgrades#
The version: 1.0.0 field is stored in the registry together with the source repo's git hash (if any).
L1 (plugins) goes through the plugin autoupdate subsystem (already exists).
L3 (skills) goes through skills discovery diff (already exists).
L4 / L5 are user-written and do not auto-update.
L0 (builtin) tracks the OpenProgram version.
slash-commands-references.md records how the five reference projects implement slash commands and is rescanned periodically. New designs found there are added as fields in §2/§3 of this document, without breaking existing frontmatter (extra fields go into extras).