Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/adapters/copilot-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,19 @@ export class CopilotCliAdapter extends CopilotBaseAdapter {
// HOOK_TYPES so new events are picked up automatically.
for (const hookType of Object.values(HOOK_TYPES)) {
if (!HOOK_SCRIPTS[hookType]) continue;
const desired = [
{ type: "command", command: buildHookCommand(hookType, pluginRoot) },
];
const command = buildHookCommand(hookType, pluginRoot);
const existing = Array.isArray(hooks[hookType])
? hooks[hookType] as Array<Record<string, unknown>>
: [];
let found = false;
const desired = existing.map((entry) => {
if (typeof entry?.command !== "string" || entry.command.trim() !== command) {
return entry;
}
found = true;
return { ...entry, type: "command", command };
});
if (!found) desired.push({ type: "command", command });
// Only treat a hook as drift when it differs from desired, so repeated
// `context-mode upgrade` runs stay idempotent.
if (JSON.stringify(hooks[hookType]) !== JSON.stringify(desired)) {
Expand Down
47 changes: 47 additions & 0 deletions tests/adapters/copilot-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,53 @@ describe("CopilotCliAdapter", () => {
});

describe("hook config", () => {
it("preserves custom hooks and their ordering when refreshing context-mode", () => {
const before = { type: "command", command: "node policy-check.mjs", timeoutSec: 15 };
const after = { type: "command", command: "node audit-event.mjs", cwd: "/audit" };
adapter.writeSettings({
version: 1,
customSetting: { retained: true },
hooks: {
[HOOK_TYPES.PRE_TOOL_USE]: [before, {
type: "command",
command: buildHookCommand(HOOK_TYPES.PRE_TOOL_USE),
timeoutSec: 30,
}, after],
customEvent: [after],
},
});

adapter.configureAllHooks("/any/plugin/root");
const written = adapter.readSettings()!;
const hooks = written.hooks as Record<string, unknown[]>;
expect(hooks[HOOK_TYPES.PRE_TOOL_USE]).toEqual([before, {
type: "command",
command: buildHookCommand(HOOK_TYPES.PRE_TOOL_USE),
timeoutSec: 30,
}, after]);
expect(hooks.customEvent).toEqual([after]);
expect(written.customSetting).toEqual({ retained: true });
expect(adapter.configureAllHooks("/any/plugin/root")).toEqual([]);
});

it.each(Object.values(HOOK_TYPES))("adds %s alongside existing custom callbacks", (hookType) => {
const external = {
type: "command",
command: "node ./company-pretooluse.mjs",
bash: "./guard.sh",
powershell: ".\\guard.ps1",
timeoutSec: 15,
};
const http = { type: "http", url: "https://audit.invalid/events" };
adapter.writeSettings({ version: 1, hooks: { [hookType]: [external, http] } });
adapter.configureAllHooks("/any/plugin/root");
const hooks = adapter.readSettings()!.hooks as Record<string, unknown[]>;
expect(hooks[hookType]).toEqual([external, http, {
type: "command", command: buildHookCommand(hookType),
}]);
expect(adapter.configureAllHooks("/any/plugin/root")).toEqual([]);
});

it("buildHookCommand emits CLI dispatcher form", () => {
expect(buildHookCommand(HOOK_TYPES.PRE_TOOL_USE)).toBe("context-mode hook copilot-cli pretooluse");
});
Expand Down