Skip to content
Merged
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
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,22 @@ that file. Codex plugins can declare hooks in `.codex-plugin/plugin.json` with a
`hooks` path, path array, inline object, or inline object array; otherwise
AllAgents falls back to `hooks/hooks.json`.

For Copilot, root `hooks/` can sync to either project or user hook directories.
Repository hooks under `.github/hooks/` remain project-scoped and are never
promoted into the user-global `~/.copilot/hooks/` directory. Potential copies
from older versions are left untouched and reported for manual review because
their ownership was not tracked.
For Copilot project sync, AllAgents combines plugin hook declarations from
`hooks.json` or `hooks/hooks.json` in `.github/hooks/allagents.json` and binds
`COPILOT_PLUGIN_ROOT` for each plugin. Other repository hooks under
`.github/hooks/` remain project-scoped and are never promoted into the
user-global `~/.copilot/hooks/` directory. Copilot package metadata under
`.github/plugin/` is not copied into the project overlay. Potential user-scope
copies from older versions are left untouched and reported for manual review
because their ownership was not tracked.

Marketplace registration and `plugin.json` are not required for direct plugin
sources. A plugin without a supported hook declaration continues syncing its
other artifacts. AllAgents warns and omits a declaration that cannot be read or
parsed as JSON or lacks the version-1 `hooks` object envelope. A declaration
with `disableAllHooks: true` adds no entries; otherwise, a non-array event value
also omits the declaration. Skipping a declaration does not reject the plugin
or itself suppress other eligible artifacts, including repository hook files.

## Documentation

Expand Down
81 changes: 81 additions & 0 deletions docs/decisions/0001-project-plugin-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# ADR 0001: Materialize project plugin hooks as one managed repository hook file

- Status: Accepted
- Date: 2026-07-30

## Context

GitHub Copilot discovers repository hooks from `.github/hooks/*.json`, while
native plugin hooks may be declared in a plugin's `hooks.json` or
`hooks/hooks.json`. A plugin installed through an AllAgents project overlay is
not loaded by Copilot as a native plugin, so copying its scripts alone does not
activate its hook declaration. See the
[GitHub Copilot hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference).

This translation is independent of marketplace registration and package
metadata. AllAgents continues to support direct local and GitHub plugin sources
without a marketplace manifest or `plugin.json`. A plugin with no `hooks.json`
or `hooks/hooks.json` simply declares no Copilot hooks; its other artifacts
continue to sync normally.

AllAgents also needs to remove one plugin's hooks without disturbing another
plugin or user-owned repository configuration.

## Decision

For project-scoped Copilot plugins, AllAgents materializes native plugin hook
declarations into one AllAgents-owned repository hook file:
`.github/hooks/allagents.json`.

One aggregate file is used instead of one file per plugin because it gives the
sync engine a single ownership boundary and one reconciliation target.
Install, update, and uninstall can regenerate the complete desired state
without inventing stable filenames, leaving stale per-plugin files, or touching
sibling user-owned hook files.

When at least one managed hook entry remains, AllAgents creates the aggregate if
the path is absent. It updates an existing aggregate only when sync state
records the file as managed. An existing unowned
`.github/hooks/allagents.json` is preserved and reported as a warning. When no
managed plugin hooks remain, AllAgents removes only its managed aggregate.

Each hook declaration is checked independently. If it cannot be read or parsed
as JSON or lacks the version-1 `hooks` object envelope, AllAgents warns and
omits every entry from that declaration from the managed aggregate. A valid
envelope with `disableAllHooks: true` adds no entries. Otherwise, a non-array
event value also warns and omits the declaration. AllAgents does not fully
validate each hook entry. Skipping a declaration does not itself suppress the
plugin's other eligible artifacts, including repository hook files, and valid
hook declarations from other plugins still aggregate. Rejecting the
declaration as a unit avoids partial managed activation when its enabled event
structure is invalid.

For every command entry, AllAgents preserves declared environment variables but
overrides any declared `COPILOT_PLUGIN_ROOT` with that plugin's resolved
installation path. References through that variable therefore resolve to the
plugin's actual root. Excluded declarations or hook payloads are not activated.

## Consequences

- Project-installed plugin hooks execute through Copilot's native repository
hook discovery.
- Marketplace registration and `plugin.json` remain optional for direct plugin
sources; hook materialization runs only when a supported hook declaration is
present.
- User-owned hook files remain independent from AllAgents reconciliation.
- Skipping a hook declaration does not itself suppress that plugin's other
eligible artifacts or valid declarations from other plugins, but none of its
entries enter the managed aggregate.
- The generated file contains absolute plugin paths. It is machine-local and
should not be treated as portable configuration for cloud agents or another
checkout where those paths do not exist.
- A user who already owns `.github/hooks/allagents.json` must rename it or choose
another ownership arrangement before AllAgents can activate project hooks.

## Reconsider when

Revisit this decision if Copilot gains native project-overlay plugin loading,
provides a portable plugin-root binding for repository hooks, or remote/cloud
execution becomes a supported project-plugin target. Also reconsider the single
aggregate if plugins need independent trust, enablement, or failure policies that
cannot be represented safely in one managed file.
21 changes: 21 additions & 0 deletions docs/src/content/docs/docs/guides/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ my-plugin/
├── skills/ # Cross-client skills
├── agents/ # Agent definitions (Claude, Copilot, Factory)
├── hooks/ # Hook definitions (Claude, Copilot, Factory)
├── hooks.json # Copilot plugin hook declaration (optional)
├── commands/ # Commands (Claude, OpenCode)
├── .github/ # Project-scoped GitHub overrides (Copilot, VSCode)
│ ├── copilot-instructions.md
Expand All @@ -23,6 +24,26 @@ my-plugin/
```

:::note
At project scope, Copilot plugin hook declarations from `hooks.json` or
`hooks/hooks.json` are combined in the AllAgents-owned
`.github/hooks/allagents.json` file. Each entry receives its plugin-specific
`COPILOT_PLUGIN_ROOT`, so declarations continue to resolve files from the
plugin package. The generated file is tracked independently from other
repository hook files. Package metadata under `.github/plugin/` is not copied
into the project overlay.

This does not require a marketplace manifest or `plugin.json`. Direct local and
GitHub plugin sources continue to use conventional directory discovery. If a
plugin has no supported hook declaration, its other artifacts still sync and it
adds no entries to `allagents.json`. AllAgents warns and omits a declaration
that cannot be read or parsed as JSON or lacks the version-1 `hooks` object
envelope. A declaration with `disableAllHooks: true` adds no entries; otherwise,
a non-array event value also warns and omits the declaration. AllAgents does not
fully validate each hook entry. Skipping a declaration does not reject the
plugin or itself suppress other eligible artifacts—including repository hook
files—or valid declarations from other plugins. Ordinary client selection and
exclusion settings still apply.

At user scope, root `hooks/` entries sync to the client's user hook directory.
Repository hooks under `.github/hooks/` stay project-scoped and are not copied
to `~/.copilot/hooks/`. Potential copies from older versions are left untouched
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/docs/reference/clients.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ These clients use their own skills directory:
| Kiro | `.kiro/skills/` | `AGENTS.md` | No | No |

:::note
Skills are the cross-client way to share reusable prompts. GitHub overrides (`.github/prompts/`, `.github/agents/`, `.github/hooks/`, `copilot-instructions.md`) are copied to the workspace's `.github/` folder for Copilot/VSCode. Root `agents/` and `hooks/` also map to `.github/agents/` and `.github/hooks/` for Copilot. At user scope, root `hooks/` maps to `~/.copilot/hooks/`, while repository `.github/hooks/` remains project-scoped.
Skills are the cross-client way to share reusable prompts. GitHub overrides (`.github/prompts/`, `.github/agents/`, `.github/hooks/`, `copilot-instructions.md`) are copied to the workspace's `.github/` folder for Copilot/VSCode; package-only `.github/plugin/` metadata is omitted. Root `agents/` and `hooks/` also map to `.github/agents/` and `.github/hooks/` for Copilot. At project scope, plugin hook declarations from `hooks.json` or `hooks/hooks.json` are combined in `.github/hooks/allagents.json`. This translation does not require marketplace registration or `plugin.json`; a missing or disabled declaration adds no managed entries. AllAgents warns and omits a declaration that cannot be read or parsed, lacks the version-1 `hooks` object envelope, or—unless disabled—has a non-array event value. Skipping a declaration does not itself suppress other eligible plugin artifacts or valid declarations from other plugins. At user scope, root `hooks/` maps to `~/.copilot/hooks/`, while repository `.github/hooks/` remains project-scoped.
:::

### VSCode
Expand Down
208 changes: 208 additions & 0 deletions src/core/copilot-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { existsSync } from 'node:fs';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { ValidatedPlugin } from './sync.js';
import { isExcluded, type CopyResult } from './transform.js';

export const COPILOT_MANAGED_HOOKS_RELATIVE_PATH =
'.github/hooks/allagents.json';

const PLUGIN_HOOK_PATHS = ['hooks.json', 'hooks/hooks.json'] as const;

type JsonRecord = Record<string, unknown>;

interface CopilotHooksFile {
version: 1;
hooks: Record<string, unknown[]>;
}

export interface CopilotHookSyncResult {
copyResults: CopyResult[];
warnings: string[];
}

function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function parseHooksFile(
content: string,
source: string,
warnings: string[],
): CopilotHooksFile | null {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch (error) {
warnings.push(
`Copilot hooks: failed to parse ${source}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}

if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.hooks)) {
warnings.push(
`Copilot hooks: ${source} must contain version 1 and a hooks object`,
);
return null;
}

if (parsed.disableAllHooks === true) {
return { version: 1, hooks: {} };
}

const hooks: Record<string, unknown[]> = {};
for (const [eventName, entries] of Object.entries(parsed.hooks)) {
if (!Array.isArray(entries)) {
warnings.push(
`Copilot hooks: event '${eventName}' in ${source} must be an array`,
);
return null;
}
hooks[eventName] = entries;
}

return { version: 1, hooks };
}

function withPluginRoot(
hooksFile: CopilotHooksFile,
pluginRoot: string,
): CopilotHooksFile {
const hooks = Object.fromEntries(
Object.entries(hooksFile.hooks).map(([eventName, entries]) => [
eventName,
entries.map((entry) => {
if (!isRecord(entry)) return entry;
const existingEnv = isRecord(entry.env) ? entry.env : {};
return {
...entry,
env: {
...existingEnv,
COPILOT_PLUGIN_ROOT: pluginRoot,
},
};
}),
]),
);
return { version: 1, hooks };
}

async function collectPluginHooks(
plugin: ValidatedPlugin,
warnings: string[],
): Promise<CopilotHooksFile | null> {
const relativePath = PLUGIN_HOOK_PATHS.find((candidate) =>
existsSync(join(plugin.resolved, candidate)),
);
if (!relativePath) return null;

const hooksPath = join(plugin.resolved, relativePath);
if (isExcluded(plugin.resolved, hooksPath, plugin.exclude)) return null;

let hooksFile: CopilotHooksFile | null;
try {
hooksFile = parseHooksFile(
await readFile(hooksPath, 'utf-8'),
hooksPath,
warnings,
);
} catch (error) {
warnings.push(
`Copilot hooks: failed to read ${hooksPath}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}

if (!hooksFile) return null;

// The declaration executes payloads from the installed plugin root. If a
// user excluded any root hooks/ payload, activating the declaration would
// bypass that exclusion even though the file was not copied to the project.
// Skip the plugin's generated declaration rather than execute excluded code.
if (await hasExcludedHookPayload(plugin)) return null;

return withPluginRoot(hooksFile, plugin.resolved);
}

async function hasExcludedHookPayload(plugin: ValidatedPlugin): Promise<boolean> {
if (!plugin.exclude || plugin.exclude.length === 0) return false;

const hooksDir = join(plugin.resolved, 'hooks');
if (!existsSync(hooksDir)) return false;

async function visit(directory: string): Promise<boolean> {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = join(directory, entry.name);
if (isExcluded(plugin.resolved, sourcePath, plugin.exclude)) return true;
if (entry.isDirectory() && (await visit(sourcePath))) return true;
}
return false;
}

return visit(hooksDir);
}

function pluginTargetsCopilot(plugin: ValidatedPlugin): boolean {
return (
plugin.success &&
plugin.clients.includes('copilot') &&
plugin.fileArtifacts?.hooks !== false
);
}

function mergeHooks(files: CopilotHooksFile[]): CopilotHooksFile {
const hooks: Record<string, unknown[]> = {};
for (const file of files) {
for (const [eventName, entries] of Object.entries(file.hooks)) {
if (entries.length === 0) continue;
hooks[eventName] = [...(hooks[eventName] ?? []), ...entries];
}
}
return { version: 1, hooks };
}

export async function syncCopilotProjectHooks(
validatedPlugins: ValidatedPlugin[],
workspacePath: string,
options: { dryRun?: boolean; previouslyManaged?: boolean } = {},
): Promise<CopilotHookSyncResult> {
const warnings: string[] = [];
const hookFiles = await Promise.all(
validatedPlugins
.filter(pluginTargetsCopilot)
.map((plugin) => collectPluginHooks(plugin, warnings)),
);
const merged = mergeHooks(
hookFiles.filter((file): file is CopilotHooksFile => file !== null),
);

if (Object.keys(merged.hooks).length === 0) {
return { copyResults: [], warnings };
}

const hooksPath = join(workspacePath, COPILOT_MANAGED_HOOKS_RELATIVE_PATH);
if (existsSync(hooksPath) && !options.previouslyManaged) {
warnings.push(
`Copilot hooks: not updating ${COPILOT_MANAGED_HOOKS_RELATIVE_PATH} because the existing file is not owned by AllAgents`,
);
return { copyResults: [], warnings };
}

if (!options.dryRun) {
await mkdir(dirname(hooksPath), { recursive: true });
await writeFile(hooksPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf-8');
}

return {
copyResults: [
{
source: 'copilot-plugin-hooks',
destination: hooksPath,
action: 'generated',
},
],
warnings,
};
}
Loading