Skip to content

Commit fae9d70

Browse files
committed
Enforce live plugin command activation
1 parent 711e3f4 commit fae9d70

8 files changed

Lines changed: 248 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-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: 89 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,83 @@ 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(getCommand("live-plugin-cmd")).toBeDefined();
145+
});
146+
147+
it("lets an enabled plugin claim a name ahead of a disabled candidate", () => {
148+
registerCommandPlugin(
149+
{
150+
commands: [
151+
{
152+
name: "plugin-candidate-collision",
153+
description: "disabled candidate",
154+
handler: () => ({ type: "noop" }),
155+
},
156+
],
157+
},
158+
() => false,
159+
);
160+
registerCommandPlugin(
161+
{
162+
commands: [
163+
{
164+
name: "plugin-candidate-collision",
165+
description: "enabled candidate",
166+
handler: () => ({ type: "noop" }),
167+
},
168+
],
169+
},
170+
() => true,
171+
);
172+
173+
expect(getCommand("plugin-candidate-collision")?.description).toBe("enabled candidate");
174+
});
175+
176+
it("never lets a plugin collision replace a built-in command", () => {
177+
registerCommand({
178+
name: "built-in-plugin-collision",
179+
description: "built-in",
180+
handler: () => ({ type: "noop" }),
181+
});
182+
registerCommandPlugin({
183+
commands: [
184+
{
185+
name: "built-in-plugin-collision",
186+
description: "plugin",
187+
handler: () => ({ type: "noop" }),
188+
},
189+
],
190+
});
191+
192+
expect(getCommand("built-in-plugin-collision")?.description).toBe("built-in");
193+
});
105194
});
106195

107196
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) {

src/tui/runner-host.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import type { KeyEvent } from "@opentui/core";
66
import type { CostSummary } from "../cost/cost-summary.js";
77
import type { SubAgentSession } from "../subagent/session-store.js";
88
import { createHarness } from "./harness.js";
9-
import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction } from "./shell.js";
9+
import {
10+
acceptOverlaySelection,
11+
closeInsetOverlay,
12+
resolvePaletteCatalog,
13+
runOverlayAction,
14+
} from "./shell.js";
1015
import {
1116
mountRunnerHost,
1217
observeSessionFromSubAgents,
@@ -130,6 +135,34 @@ describe("observeSessionFromSubAgents", () => {
130135
});
131136

132137
describe("mountRunnerHost chrome wiring", () => {
138+
test("reads the current command catalog on every palette access", async () => {
139+
const harness = await createHarness({ width: 80, height: 24 });
140+
let commands = [{ name: "first", description: "First command" }];
141+
const host = await mountRunnerHost({
142+
title: "test",
143+
eventEmitter: new EventEmitter(),
144+
send: () => {},
145+
interrupt: () => {},
146+
providers: {},
147+
onModelSelect: () => {},
148+
commands: () => commands,
149+
onCommand: () => {},
150+
chrome: () => ({ agents: [] }),
151+
subscribeChrome: () => () => {},
152+
subAgentSessions: () => [],
153+
createRenderer: async () => harness.renderer,
154+
});
155+
try {
156+
expect(resolvePaletteCatalog(host.shell).map((command) => command.id)).toEqual(["first"]);
157+
158+
commands = [{ name: "second", description: "Second command" }];
159+
expect(resolvePaletteCatalog(host.shell).map((command) => command.id)).toEqual(["second"]);
160+
} finally {
161+
host.dispose();
162+
harness.destroy();
163+
}
164+
});
165+
133166
// CL-5731: subscribeChrome must stay wired end-to-end. formatChromeZones
134167
// now parks both chrome strips (always null), so a tasks push must not
135168
// paint the checklist — this test asserts the notify path still runs and

src/tui/runner-host.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export interface RunnerHostDeps {
115115
* then hidden. Defaults to false (off) when omitted.
116116
*/
117117
readonly showPromptCost?: () => boolean;
118-
readonly commands: readonly RegistryCommandSource[];
118+
readonly commands: readonly RegistryCommandSource[] | (() => readonly RegistryCommandSource[]);
119119
readonly onCommand: (name: string) => void;
120120
/** Live chrome snapshot source, read on mount and on every notify. */
121121
readonly chrome: () => ChromeSessionInput;
@@ -271,7 +271,10 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
271271
},
272272
onModelSelect,
273273
describeModel,
274-
commands: commandItemsFromRegistry(deps.commands),
274+
commands: () =>
275+
commandItemsFromRegistry(
276+
typeof deps.commands === "function" ? deps.commands() : deps.commands,
277+
),
275278
onCommand: deps.onCommand,
276279
chrome: chromeFromSession(deps.chrome()),
277280
onObserveRequest: () => observeSessionFromSubAgents(deps.subAgentSessions()),

0 commit comments

Comments
 (0)