Improve built-in command management
List bundled slash commands in their own section and create same-name project or global overrides when users edit them. Remove the Commands sidebar shortcut and redundant scope banner, then simplify command cards by dropping badges and argument hints while tightening description typography. Add regression coverage for built-in listing and override precedence.
Alessandro committed
Jul 10, 2026 at 17:50 UTC
56608489401f2842f98c529e60c58f58b6d4b191
9 files changed
+137
-118
plugins/_commands/AGENTS.md
+3
-1
@@ -12,7 +12,7 @@
12
- `api/commands.py` owns the Commands API actions used by the WebUI.
13
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
14
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`.
15
-- `extensions/` owns the chat composer picker and sidebar quick-action entry.
15
+- `extensions/` owns the chat composer slash picker.
16
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
17
- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
18
- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
@@ -23,6 +23,7 @@
23
- Each command is one `.command.yaml` config plus one same-directory `.txt` text template or `.py` script hook.
24
- Project commands override global commands, global commands override bundled `_commands/commands/` defaults, and bundled defaults override other plugin-distributed commands with the same name.
25
- Bundled `_commands/commands/` definitions and commands contributed by other plugins are read-only from this manager.
26
+- The manager lists bundled commands separately; editing one copies it unchanged into the selected project or global scope under the same name, then edits that higher-precedence override.
27
- Bundled command files use canonical command names only; do not ship alias-only built-ins such as `/img` for `/attach`.
28
- Command configs may set `webui_hidden: true` to stay resolvable but be omitted from the chat composer picker.
29
- Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
@@ -34,6 +35,7 @@
35
36
- Keep the command storage and route namespace aligned with `_commands`.
37
- Preserve unknown command config keys when editing commands.
38
+- Keep built-in source files immutable; user edits must be same-name scope overrides.
39
- Keep WebUI paths pointed at `/plugins/_commands/...`.
40
41
## Verification
plugins/_commands/README.md
+1
-2
@@ -137,8 +137,7 @@ When the built-in `_commands` plugin starts, it migrates files from the older co
137
138
## UI Surfaces
139
140
-- Plugin modal: open the Commands manager from the Plugins dialog
141
-- Sidebar quick action: terminal icon next to the Plugins button
140
+- Plugin modal: manage project/global commands and create editable same-name overrides of bundled commands
141
- Chat composer: type `/` at the start of the inline input to browse commands
142
143
## Agent Skill
plugins/_commands/api/commands.py
+1
@@ -51,6 +51,7 @@ class Commands(ApiHandler):
51
return {
52
"ok": True,
53
"commands": commands,
54
+ "builtin_commands": commands_helper.list_builtin_commands(),
55
"scope": scope,
56
}
57
plugins/_commands/extensions/webui/sidebar-quick-actions-main-start/commands-entry.html
deleted
-9
@@ -1,9 +0,0 @@
1
-<div x-data>
2
- <button x-move-after=".config-button#plugins"
3
- class="config-button"
4
- id="commands-plugin"
5
- title="Commands"
6
- @click="import('/plugins/_commands/webui/commands-store.js').then(({ store }) => store.openManager())">
7
- <span class="material-symbols-outlined">terminal</span>
8
- </button>
9
-</div>
plugins/_commands/helpers/commands.py
+11
-2
@@ -336,6 +336,11 @@ def list_effective_commands(
336
return effective, strip_private_scope(resolved_scope)
337
338
339
+def list_builtin_commands() -> list[dict[str, Any]]:
340
+ """Return bundled commands for display in the command manager."""
341
+ return sorted(_discover_builtin_commands(), key=lambda item: item["name"])
342
+
343
+
344
def get_command(
345
path: str,
346
project_name: str = "",
@@ -489,7 +494,7 @@ def duplicate_command(
494
project_name: str = "",
495
agent_profile: str = "",
496
) -> dict[str, Any]:
492
- """Duplicate an existing command, assigning it a unique ``-copy`` suffixed name.
497
+ """Duplicate a command, preserving built-in names so the copy overrides the default.
498
499
Returns the newly created command dict.
500
@@ -499,7 +504,11 @@ def duplicate_command(
504
505
"""
506
command = get_command(path, project_name, "")
502
- duplicated_name = _generate_duplicate_name(command["name"], project_name=project_name)
507
+ duplicated_name = (
508
+ command["name"]
509
+ if command.get("scope_key") == "builtin"
510
+ else _generate_duplicate_name(command["name"], project_name=project_name)
511
+ )
512
return save_command(
513
project_name=project_name,
514
name=duplicated_name,
plugins/_commands/tests/test_commands_plugin.py
+26
@@ -190,6 +190,32 @@ def test_list_effective_commands_project_overrides_global(
190
assert scoped_command["override_scopes"] == ["Global"]
191
192
193
+def test_duplicate_builtin_creates_same_name_project_override(
194
+ scope_fixture: ScopeFixture,
195
+) -> None:
196
+ builtin = next(
197
+ command
198
+ for command in commands_helper.list_builtin_commands()
199
+ if command["name"] == "new"
200
+ )
201
+
202
+ override = _track_paths(
203
+ scope_fixture,
204
+ commands_helper.duplicate_command(
205
+ builtin["path"],
206
+ project_name=scope_fixture.project_name,
207
+ ),
208
+ )
209
+
210
+ assert override["name"] == builtin["name"]
211
+ assert override["scope_label"] == "Project"
212
+ assert override["body"] == builtin["body"]
213
+
214
+ effective, _ = commands_helper.list_effective_commands(scope_fixture.project_name)
215
+ resolved = next(command for command in effective if command["name"] == "new")
216
+ assert resolved["path"] == override["path"]
217
+
218
+
219
def test_models_command_always_opens_modal():
220
result = connector_commands.run(
221
{
plugins/_commands/tests/test_plugin_command_discovery.py
+3
@@ -147,6 +147,9 @@ def test_discover_builtin_commands_marks_own_commands_read_only():
147
with pytest.raises(ValueError, match="Built-in commands are read-only"):
148
commands_helper.delete_command(command["path"])
149
150
+ response = object.__new__(Commands)._list_scope({"project_name": ""})
151
+ assert "new" in {item["name"] for item in response["builtin_commands"]}
152
+
153
154
def test_builtin_commands_use_canonical_names_only():
155
discovered = commands_helper._discover_builtin_commands()
plugins/_commands/webui/commands-store.js
+31
-10
@@ -81,6 +81,7 @@ const model = {
81
scope: null,
82
contextScope: { project_name: "" },
83
commands: [],
84
+ builtinCommands: [],
85
pendingScope: null,
86
pendingCreate: null,
87
editor: createEmptyEditor(),
@@ -90,10 +91,6 @@ const model = {
91
return this.scope?.scope_label || "Global";
92
},
93
93
- get selectedScopeDirectory() {
94
- return this.scope?.directory_path || "";
95
- },
96
-
94
get hasCommands() {
95
return (this.commands || []).length > 0;
96
},
@@ -139,6 +136,7 @@ const model = {
136
console.error("Failed to initialize commands manager:", error);
137
this.scope = null;
138
this.commands = [];
139
+ this.builtinCommands = [];
140
notifyError(error?.message || "Failed to open the commands manager.");
141
}
142
@@ -157,6 +155,7 @@ const model = {
155
this.scope = null;
156
this.contextScope = { project_name: "" };
157
this.commands = [];
158
+ this.builtinCommands = [];
159
this.pendingScope = null;
160
this.pendingCreate = null;
161
this.resetEditor();
@@ -205,10 +204,14 @@ const model = {
204
});
205
206
this.commands = Array.isArray(response?.commands) ? response.commands : [];
207
+ this.builtinCommands = Array.isArray(response?.builtin_commands)
208
+ ? response.builtin_commands
209
+ : [];
210
this.scope = response?.scope || null;
211
} catch (error) {
212
console.error("Failed to load commands:", error);
213
this.commands = [];
214
+ this.builtinCommands = [];
215
this.scope = null;
216
notifyError(error?.message || "Failed to load commands.");
217
} finally {
@@ -225,13 +228,31 @@ const model = {
228
await this.loadCommands();
229
},
230
228
- overrideBadgeLabel(command) {
229
- const count = Number(command?.override_count || 0);
230
- if (!count) return "";
231
- if (count === 1) {
232
- return `Overrides ${command.override_scopes[0]}`;
231
+ scopedOverride(command) {
232
+ return (this.commands || []).find((item) => item.name === command?.name);
233
+ },
234
+
235
+ async editBuiltinCommand(command) {
236
+ const existing = this.scopedOverride(command);
237
+ if (existing) {
238
+ await this.openEditCommand(existing);
239
+ return;
240
+ }
241
+
242
+ try {
243
+ const response = await callJsonApi(COMMANDS_API_PATH, {
244
+ action: "duplicate",
245
+ path: command.path,
246
+ project_name: this.projectName || "",
247
+ });
248
+ await this.loadCommands();
249
+ emitCommandsUpdated();
250
+ notifySuccess(`Created ${this.selectedScopeLabel} override for /${command.name}`);
251
+ if (response?.command) await this.openEditCommand(response.command);
252
+ } catch (error) {
253
+ console.error("Failed to create command override:", error);
254
+ notifyError(error?.message || "Failed to create command override.");
255
}
234
- return `Overrides ${count} lower scopes`;
256
},
257
258
async browseScopeFolder() {
plugins/_commands/webui/main.html
+61
-94
@@ -45,20 +45,17 @@
45
</div>
46
</div>
47
48
- <div class="commands-scope-banner" x-show="$store.commandsManager.scope">
49
- <div class="commands-scope-banner-label">
50
- <span class="material-symbols-outlined">target</span>
51
- <span x-text="$store.commandsManager.selectedScopeLabel"></span>
52
- </div>
53
- <div class="commands-scope-banner-path" x-text="$store.commandsManager.selectedScopeDirectory"></div>
54
- </div>
55
-
48
<div class="commands-loading" x-show="$store.commandsManager.loading">
49
<span class="material-symbols-outlined spinning">progress_activity</span>
50
<span>Loading commands...</span>
51
</div>
52
53
<div class="commands-list" x-show="!$store.commandsManager.loading">
54
+ <div class="commands-section-heading">
55
+ <div class="commands-section-title" x-text="`${$store.commandsManager.selectedScopeLabel} commands`"></div>
56
+ <div class="commands-section-copy">Editable commands saved in the selected scope.</div>
57
+ </div>
58
+
59
<template x-if="$store.commandsManager.hasCommands">
60
<div class="commands-grid">
61
<template x-for="command in $store.commandsManager.commands" :key="command.path">
@@ -84,21 +81,6 @@
81
</div>
82
</div>
83
87
- <div class="commands-card-badges">
88
- <span class="commands-badge scope" x-text="command.scope_label"></span>
89
- <span class="commands-badge type" x-text="command.command_type === 'script' ? 'Python Hook' : 'Text Template'"></span>
90
- <template x-if="command.override_count">
91
- <span class="commands-badge override" x-text="$store.commandsManager.overrideBadgeLabel(command)"></span>
92
- </template>
93
- </div>
94
-
95
- <template x-if="command.argument_hint">
96
- <div class="commands-card-hint">
97
- <span class="material-symbols-outlined">subdirectory_arrow_right</span>
98
- <span x-text="command.argument_hint"></span>
99
- </div>
100
- </template>
101
-
84
<div class="commands-card-path" x-text="command.path"></div>
85
</article>
86
</template>
@@ -117,6 +99,41 @@
99
</button>
100
</div>
101
</template>
102
+
103
+ <template x-if="$store.commandsManager.builtinCommands.length">
104
+ <section class="commands-section">
105
+ <div class="commands-section-heading">
106
+ <div class="commands-section-title">Built-in commands</div>
107
+ <div class="commands-section-copy">
108
+ Editing creates an override in <span x-text="$store.commandsManager.selectedScopeLabel"></span>; core files stay unchanged.
109
+ </div>
110
+ </div>
111
+
112
+ <div class="commands-grid">
113
+ <template x-for="command in $store.commandsManager.builtinCommands" :key="command.path">
114
+ <article class="commands-card">
115
+ <div class="commands-card-header">
116
+ <div class="commands-card-copy">
117
+ <div class="commands-card-title">
118
+ <span class="commands-slash">/</span><span x-text="command.name"></span>
119
+ </div>
120
+ <div class="commands-card-description" x-text="command.description"></div>
121
+ </div>
122
+
123
+ <button type="button"
124
+ class="button icon"
125
+ :title="$store.commandsManager.scopedOverride(command) ? 'Edit override' : 'Create editable override'"
126
+ :aria-label="$store.commandsManager.scopedOverride(command) ? `Edit /${command.name} override` : `Create editable /${command.name} override`"
127
+ @click="$store.commandsManager.editBuiltinCommand(command)">
128
+ <span class="material-symbols-outlined">edit</span>
129
+ </button>
130
+ </div>
131
+
132
+ </article>
133
+ </template>
134
+ </div>
135
+ </section>
136
+ </template>
137
</div>
138
</div>
139
</template>
@@ -184,31 +201,6 @@
201
margin-left: auto;
202
}
203
187
- .commands-scope-banner {
188
- display: flex;
189
- flex-wrap: wrap;
190
- gap: 0.75rem;
191
- align-items: center;
192
- padding: 0.85rem 1rem;
193
- border: 1px solid var(--color-border);
194
- border-radius: 12px;
195
- background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
196
- }
197
-
198
- .commands-scope-banner-label {
199
- display: inline-flex;
200
- align-items: center;
201
- gap: 0.45rem;
202
- font-weight: 600;
203
- }
204
-
205
- .commands-scope-banner-path {
206
- color: var(--color-text-secondary);
207
- font-family: "Roboto Mono", monospace;
208
- font-size: 0.82rem;
209
- word-break: break-all;
210
- }
211
-
204
.commands-loading {
205
display: flex;
206
align-items: center;
@@ -228,6 +220,25 @@
220
gap: 0.9rem;
221
}
222
223
+ .commands-section {
224
+ margin-top: 1.25rem;
225
+ }
226
+
227
+ .commands-section-heading {
228
+ margin-bottom: 0.75rem;
229
+ }
230
+
231
+ .commands-section-title {
232
+ font-size: 1rem;
233
+ font-weight: 600;
234
+ }
235
+
236
+ .commands-section-copy {
237
+ margin-top: 0.2rem;
238
+ color: var(--color-text-secondary);
239
+ font-size: 0.86rem;
240
+ }
241
+
242
.commands-card {
243
display: flex;
244
flex-direction: column;
@@ -262,8 +273,8 @@
273
.commands-card-description {
274
margin-top: 0.25rem;
275
color: var(--color-text-secondary);
265
- font-size: 0.9rem;
266
- line-height: 1.45;
276
+ font-size: 0.82rem;
277
+ line-height: 1.35;
278
}
279
280
.commands-card-actions {
@@ -272,50 +283,6 @@
283
flex-shrink: 0;
284
}
285
275
- .commands-card-badges {
276
- display: flex;
277
- flex-wrap: wrap;
278
- gap: 0.45rem;
279
- }
280
-
281
- .commands-badge {
282
- display: inline-flex;
283
- align-items: center;
284
- padding: 0.22rem 0.5rem;
285
- border-radius: 999px;
286
- font-size: 0.74rem;
287
- font-weight: 600;
288
- border: 1px solid transparent;
289
- }
290
-
291
- .commands-badge.scope {
292
- background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
293
- border-color: color-mix(in srgb, var(--color-highlight) 24%, transparent);
294
- }
295
-
296
- .commands-badge.override {
297
- background: color-mix(in srgb, #f39c12 12%, transparent);
298
- border-color: color-mix(in srgb, #f39c12 28%, transparent);
299
- }
300
-
301
- .commands-badge.type {
302
- background: color-mix(in srgb, #5b8def 12%, transparent);
303
- border-color: color-mix(in srgb, #5b8def 30%, transparent);
304
- }
305
-
306
- .commands-card-hint {
307
- display: inline-flex;
308
- align-items: flex-start;
309
- gap: 0.35rem;
310
- color: var(--color-text-secondary);
311
- font-size: 0.86rem;
312
- }
313
-
314
- .commands-card-hint .material-symbols-outlined {
315
- font-size: 1rem;
316
- margin-top: 0.05rem;
317
- }
318
-
286
.commands-card-path {
287
color: var(--color-text-secondary);
288
font-family: "Roboto Mono", monospace;