Skip to content

Commit e65a7c8

Browse files
Merge pull request #741 from corbitsdev/cl-7296-hide-commands-for-disabled-skills
Enforce live plugin command activation
2 parents baeb100 + 16b5f4a commit e65a7c8

10 files changed

Lines changed: 314 additions & 50 deletions

File tree

src/plugins/register.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,20 @@ export function enablePluginConfig(
3939
return { ...config, [id]: { ...prev, enabled: true } };
4040
}
4141

42-
export function isEnabledCommandPlugin(
43-
mod: PluginModule,
44-
config: Record<string, PluginConfig>,
45-
): boolean {
42+
function isCommandPluginModule(mod: PluginModule): boolean {
4643
if (mod.commandPlugin === undefined) return false;
4744
const kind = mod.manifest?.kind;
4845
// command/workflow plugins own their slash commands; agent plugins may also
4946
// contribute commands (e.g. a Claude marketplace plugin's tagged skills), so
5047
// commands wire as an added surface without changing the plugin's primary kind.
51-
return (
52-
(kind === "command" || kind === "workflow" || kind === "agent") &&
53-
isPluginModuleEnabled(mod, config)
54-
);
48+
return kind === "command" || kind === "workflow" || kind === "agent";
49+
}
50+
51+
export function isEnabledCommandPlugin(
52+
mod: PluginModule,
53+
config: Record<string, PluginConfig>,
54+
): boolean {
55+
return isCommandPluginModule(mod) && isPluginModuleEnabled(mod, config);
5556
}
5657

5758
export function isEnabledWorkflowPlugin(
@@ -65,15 +66,27 @@ export function isEnabledWorkflowPlugin(
6566
);
6667
}
6768

69+
export function registerCommandPluginModule(
70+
mod: PluginModule,
71+
getConfig: () => Record<string, PluginConfig>,
72+
): boolean {
73+
if (!isCommandPluginModule(mod)) return false;
74+
const commandPlugin = mod.commandPlugin;
75+
if (commandPlugin === undefined) return false;
76+
registerCommandPlugin(commandPlugin, () => isPluginModuleEnabled(mod, getConfig()));
77+
return true;
78+
}
79+
6880
export function registerCommandPlugins(
6981
modules: PluginModule[],
70-
config: Record<string, PluginConfig>,
82+
config: Record<string, PluginConfig> | (() => Record<string, PluginConfig>),
7183
): string[] {
84+
const getConfig = typeof config === "function" ? config : () => config;
7285
const registered: string[] = [];
7386
for (const mod of modules) {
74-
if (!isEnabledCommandPlugin(mod, config)) continue;
75-
registerCommandPlugin(mod.commandPlugin!);
76-
registered.push(mod.manifest!.id);
87+
const id = mod.manifest?.id;
88+
if (id === undefined || !registerCommandPluginModule(mod, getConfig)) continue;
89+
if (isPluginModuleEnabled(mod, getConfig())) registered.push(id);
7790
}
7891
return registered;
7992
}

src/tui/command-catalog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Pure: host injects `listCommands()` results (or fixtures). No registry import
55
* here — avoids circular / heavy deps from `src/tui/commands`.
66
*
7-
* setPaletteCatalog(shell, commandItemsFromRegistry(listCommands()))
7+
* setPaletteCatalog(shell, () => commandItemsFromRegistry(listCommands()))
88
*/
99

1010
import { sliceToWidth, stringWidth } from "./view/height.js";

src/tui/command-registry-setup.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, test, expect } from "bun:test";
22
import { setUpCommandRegistry } from "./runner.js";
33
import { getCommand, listCommands } from "./commands/registry.js";
4+
import type { PluginConfig } from "../config/settings.js";
5+
import type { PluginModule } from "../plugins/loader.js";
46

57
// Built-in registration once rode on an import side effect; deleting its only
68
// importer emptied the registry with no type error and no failing test.
@@ -21,4 +23,60 @@ describe("session command registry setup", () => {
2123
expect(listCommands().map((c) => c.name)).not.toContain("help");
2224
expect(getCommand("help")).toBeDefined();
2325
});
26+
27+
test("resolves plugin command candidates against live canonical config", () => {
28+
const plugin = (
29+
id: string,
30+
description: string,
31+
name = "live-config-command",
32+
): PluginModule => ({
33+
manifest: { id, name: id, kind: "command" },
34+
origin: "user",
35+
commandPlugin: {
36+
commands: [
37+
{
38+
name,
39+
description,
40+
handler: () => ({ type: "message", text: description }),
41+
},
42+
],
43+
},
44+
});
45+
let config: Record<string, PluginConfig> = {
46+
"disabled-command-plugin": { enabled: false },
47+
"enabled-command-plugin": { enabled: true },
48+
"help-collision-plugin": { enabled: true },
49+
};
50+
51+
setUpCommandRegistry(
52+
{ providers: {}, plugins: config },
53+
[
54+
plugin("disabled-command-plugin", "disabled"),
55+
plugin("enabled-command-plugin", "enabled"),
56+
plugin("help-collision-plugin", "plugin help", "help"),
57+
],
58+
() => config,
59+
);
60+
61+
expect(getCommand("live-config-command")?.description).toBe("enabled");
62+
expect(getCommand("live-config-command")?.handler("", { signalClear: () => {} })).toEqual({
63+
type: "message",
64+
text: "enabled",
65+
});
66+
expect(getCommand("help")?.description).not.toBe("plugin help");
67+
68+
config = {
69+
...config,
70+
"disabled-command-plugin": { enabled: true },
71+
"enabled-command-plugin": { enabled: false },
72+
};
73+
expect(getCommand("live-config-command")?.description).toBe("disabled");
74+
75+
config = {
76+
...config,
77+
"disabled-command-plugin": { enabled: false },
78+
};
79+
expect(getCommand("live-config-command")).toBeUndefined();
80+
expect(listCommands().map((command) => command.name)).not.toContain("live-config-command");
81+
});
2482
});

src/tui/commands/registry.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ describe("command registry", () => {
6969
expect(def?.handler("", ctx)).toEqual({ type: "message", text: "built-in" });
7070
});
7171

72+
it("keeps command-specific availability visibility-only", () => {
73+
registerCommand({
74+
name: "unavailable-but-callable",
75+
description: "visibility gated",
76+
available: () => false,
77+
handler: () => ({ type: "noop" }),
78+
});
79+
80+
expect(listCommands().map((command) => command.name)).not.toContain("unavailable-but-callable");
81+
expect(getCommand("unavailable-but-callable")).toBeDefined();
82+
});
83+
7284
it("invokes handler with args and context", () => {
7385
let receivedArgs = "";
7486
let clearCalled = false;
@@ -102,6 +114,141 @@ describe("registerCommandPlugin", () => {
102114
expect(getCommand("plugin-cmd-a")).toBeDefined();
103115
expect(getCommand("plugin-cmd-b")).toBeDefined();
104116
});
117+
118+
it("re-resolves activation for discovery and execution", () => {
119+
let active = true;
120+
registerCommandPlugin(
121+
{
122+
commands: [
123+
{
124+
name: "live-plugin-cmd",
125+
description: "live plugin",
126+
handler: () => ({ type: "message", text: "ran" }),
127+
},
128+
],
129+
},
130+
() => active,
131+
);
132+
133+
expect(listCommands().map((command) => command.name)).toContain("live-plugin-cmd");
134+
expect(getCommand("live-plugin-cmd")?.handler("", ctx)).toEqual({
135+
type: "message",
136+
text: "ran",
137+
});
138+
139+
active = false;
140+
expect(listCommands().map((command) => command.name)).not.toContain("live-plugin-cmd");
141+
expect(getCommand("live-plugin-cmd")).toBeUndefined();
142+
143+
active = true;
144+
expect(listCommands().map((command) => command.name)).toContain("live-plugin-cmd");
145+
expect(getCommand("live-plugin-cmd")).toBeDefined();
146+
});
147+
148+
it("serves the next candidate when the first plugin deactivates", () => {
149+
let firstActive = true;
150+
registerCommandPlugin(
151+
{
152+
commands: [
153+
{
154+
name: "plugin-live-fallback",
155+
description: "first",
156+
handler: () => ({ type: "noop" }),
157+
},
158+
],
159+
},
160+
() => firstActive,
161+
);
162+
registerCommandPlugin({
163+
commands: [
164+
{
165+
name: "plugin-live-fallback",
166+
description: "second",
167+
handler: () => ({ type: "noop" }),
168+
},
169+
],
170+
});
171+
172+
expect(getCommand("plugin-live-fallback")?.description).toBe("first");
173+
expect(
174+
listCommands().find((command) => command.name === "plugin-live-fallback")?.description,
175+
).toBe("first");
176+
177+
firstActive = false;
178+
expect(getCommand("plugin-live-fallback")?.description).toBe("second");
179+
expect(
180+
listCommands().find((command) => command.name === "plugin-live-fallback")?.description,
181+
).toBe("second");
182+
});
183+
184+
it("does not execute a typed slash name after the plugin deactivates", () => {
185+
let active = true;
186+
registerCommandPlugin(
187+
{
188+
commands: [
189+
{
190+
name: "typed-after-disable",
191+
description: "typed",
192+
handler: () => ({ type: "message", text: "ran" }),
193+
},
194+
],
195+
},
196+
() => active,
197+
);
198+
199+
const stalePaletteRow = "typed-after-disable";
200+
expect(getCommand(stalePaletteRow)).toBeDefined();
201+
active = false;
202+
expect(getCommand(stalePaletteRow)).toBeUndefined();
203+
});
204+
205+
it("lets an enabled plugin claim a name ahead of a disabled candidate", () => {
206+
registerCommandPlugin(
207+
{
208+
commands: [
209+
{
210+
name: "plugin-candidate-collision",
211+
description: "disabled candidate",
212+
handler: () => ({ type: "noop" }),
213+
},
214+
],
215+
},
216+
() => false,
217+
);
218+
registerCommandPlugin(
219+
{
220+
commands: [
221+
{
222+
name: "plugin-candidate-collision",
223+
description: "enabled candidate",
224+
handler: () => ({ type: "noop" }),
225+
},
226+
],
227+
},
228+
() => true,
229+
);
230+
231+
expect(getCommand("plugin-candidate-collision")?.description).toBe("enabled candidate");
232+
});
233+
234+
it("never lets a plugin collision replace a built-in command", () => {
235+
registerCommand({
236+
name: "built-in-plugin-collision",
237+
description: "built-in",
238+
handler: () => ({ type: "noop" }),
239+
});
240+
registerCommandPlugin({
241+
commands: [
242+
{
243+
name: "built-in-plugin-collision",
244+
description: "plugin",
245+
handler: () => ({ type: "noop" }),
246+
},
247+
],
248+
});
249+
250+
expect(getCommand("built-in-plugin-collision")?.description).toBe("built-in");
251+
});
105252
});
106253

107254
describe("setHiddenCommands", () => {

src/tui/commands/registry.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ export interface CommandPlugin {
6565
commands: CommandDefinition[];
6666
}
6767

68+
interface PluginCommandCandidate {
69+
command: CommandDefinition;
70+
isActive: () => boolean;
71+
}
72+
6873
const registry = new Map<string, CommandDefinition>();
74+
const pluginCandidates = new Map<string, PluginCommandCandidate[]>();
6975
const hidden = new Set<string>();
7076

7177
export function registerCommand(def: CommandDefinition): void {
@@ -75,9 +81,14 @@ export function registerCommand(def: CommandDefinition): void {
7581
registry.set(def.name, def);
7682
}
7783

78-
export function registerCommandPlugin(plugin: CommandPlugin): void {
84+
export function registerCommandPlugin(
85+
plugin: CommandPlugin,
86+
isActive: () => boolean = () => true,
87+
): void {
7988
for (const cmd of plugin.commands) {
80-
registerCommand(cmd);
89+
const candidates = pluginCandidates.get(cmd.name) ?? [];
90+
candidates.push({ command: cmd, isActive });
91+
pluginCandidates.set(cmd.name, candidates);
8192
}
8293
}
8394

@@ -87,11 +98,19 @@ export function setHiddenCommands(names: string[]): void {
8798
}
8899

89100
export function getCommand(name: string): CommandDefinition | undefined {
90-
return registry.get(name);
101+
const registered = registry.get(name);
102+
if (registered !== undefined) return registered;
103+
return pluginCandidates.get(name)?.find((candidate) => candidate.isActive())?.command;
91104
}
92105

93106
export function listCommands(): CommandDefinition[] {
94-
return [...registry.values()]
107+
const commands = [...registry.values()];
108+
for (const name of pluginCandidates.keys()) {
109+
if (registry.has(name)) continue;
110+
const command = getCommand(name);
111+
if (command !== undefined) commands.push(command);
112+
}
113+
return commands
95114
.filter((c) => !hidden.has(c.name) && (c.available === undefined || c.available()))
96115
.sort((a, b) => a.name.localeCompare(b.name));
97116
}

src/tui/product-host.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export interface ProductHostConfig {
149149
*/
150150
readonly addProviderChoices?: () => readonly ProductHostAddProviderChoice[];
151151
/** Command palette catalog (registry-backed). */
152-
readonly commands?: readonly PaletteCommand[];
152+
readonly commands?: readonly PaletteCommand[] | (() => readonly PaletteCommand[]);
153153
readonly onCommand?: (name: string) => void;
154154
/** Optional initial chrome snapshot. */
155155
readonly chrome?: ChromeLiveState | null;
@@ -326,7 +326,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
326326
// while still opting this host into the quota-retry / stall timers.
327327
const bridge = attachSessionBridge(shell, port, config.turnMonitor ?? {});
328328

329-
if (config.commands !== undefined && config.commands.length > 0) {
329+
if (config.commands !== undefined) {
330330
setPaletteCatalog(shell, config.commands);
331331
}
332332
if (config.onCommand) {

0 commit comments

Comments
 (0)