From afc6da398a4888896e4f076fb7ce6e693fd1da8d Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 20 Jun 2026 03:02:16 +0200 Subject: [PATCH 1/2] feat(codex): sync project plugin hooks --- README.md | 9 +- src/core/codex-hooks.ts | 435 +++++++++++++++++++++++++++ src/core/sync-state.ts | 3 + src/core/sync.ts | 25 +- src/models/sync-state.ts | 7 + src/utils/plugin-path.ts | 11 +- tests/unit/core/codex-hooks.test.ts | 314 +++++++++++++++++++ tests/unit/utils/plugin-path.test.ts | 12 + 8 files changed, 811 insertions(+), 5 deletions(-) create mode 100644 src/core/codex-hooks.ts create mode 100644 tests/unit/core/codex-hooks.test.ts diff --git a/README.md b/README.md index 44b55d4..5e481ff 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,18 @@ my-plugin/ │ └── SKILL.md ├── agents/ # Agent definitions ├── commands/ # Slash commands (Claude, OpenCode) -├── hooks/ # Lifecycle hooks (Claude, Factory, Copilot) +├── hooks/ # Lifecycle hooks (Claude, Factory, Copilot, Codex) +├── .codex-plugin/ # Codex plugin manifest and explicit hook paths ├── .github/ # Copilot/VSCode overrides └── .mcp.json # MCP server configs ``` +For Codex project sync, AllAgents copies skills into `.codex/skills/`, merges +plugin hooks into `.codex/hooks.json`, and preserves user-owned hooks already in +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`. + ## Documentation Full documentation at [allagents.dev](https://allagents.dev): diff --git a/src/core/codex-hooks.ts b/src/core/codex-hooks.ts new file mode 100644 index 0000000..11f121f --- /dev/null +++ b/src/core/codex-hooks.ts @@ -0,0 +1,435 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { basename, dirname, isAbsolute, join, normalize } from 'node:path'; +import type { SyncState } from '../models/sync-state.js'; +import type { ClientType } from '../models/workspace-config.js'; +import type { CopyResult } from './transform.js'; +import type { ValidatedPlugin } from './sync.js'; + +const CODEX_HOOKS_RELATIVE_PATH = '.codex/hooks.json'; +const CODEX_PLUGIN_MANIFEST_RELATIVE_PATH = '.codex-plugin/plugin.json'; +const DEFAULT_PLUGIN_HOOKS_RELATIVE_PATH = 'hooks/hooks.json'; + +const CODEX_HOOK_EVENT_ORDER = [ + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'PreCompact', + 'PostCompact', + 'SessionStart', + 'UserPromptSubmit', + 'SubagentStart', + 'SubagentStop', + 'Stop', +] as const; + +const CODEX_HOOK_EVENTS = new Set(CODEX_HOOK_EVENT_ORDER); + +type JsonRecord = Record; +interface NormalizeHooksOptions { + filterCodexEvents: boolean; + strictEventArrays?: boolean; +} + +export type CodexHooksFile = NonNullable; + +interface CodexHookSyncResult { + copyResults: CopyResult[]; + warnings: string[]; + managedHooks?: CodexHooksFile; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function hasHooks(file: CodexHooksFile | undefined): file is CodexHooksFile { + if (!file) return false; + return Object.values(file.hooks).some((groups) => groups.length > 0); +} + +function orderedHooks(hooks: Record): Record { + const ordered: Record = {}; + const remaining = new Set(Object.keys(hooks)); + + for (const event of CODEX_HOOK_EVENT_ORDER) { + if (remaining.has(event)) { + ordered[event] = hooks[event] ?? []; + remaining.delete(event); + } + } + + for (const event of [...remaining].sort()) { + ordered[event] = hooks[event] ?? []; + } + + return ordered; +} + +function normalizeHooksObject( + value: unknown, + source: string, + warnings: string[], + options: NormalizeHooksOptions, +): CodexHooksFile | null { + if (!isRecord(value) || !isRecord(value.hooks)) { + warnings.push(`Codex hooks: ${source} must contain a hooks object`); + return null; + } + + const hooks: Record = {}; + for (const [eventName, groups] of Object.entries(value.hooks)) { + if (options.filterCodexEvents && !CODEX_HOOK_EVENTS.has(eventName)) { + warnings.push(`Codex hooks: unsupported event '${eventName}' in ${source} was skipped`); + continue; + } + if (!Array.isArray(groups)) { + warnings.push(`Codex hooks: event '${eventName}' in ${source} must be an array`); + if (options.strictEventArrays) { + return null; + } + continue; + } + hooks[eventName] = cloneJson(groups); + } + + return { hooks: orderedHooks(hooks) }; +} + +function parseHooksJson( + content: string, + source: string, + warnings: string[], + options: NormalizeHooksOptions, +): CodexHooksFile | null { + try { + return normalizeHooksObject(JSON.parse(content), source, warnings, options); + } catch (error) { + warnings.push( + `Codex hooks: failed to parse ${source}: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +function readHooksJson( + path: string, + warnings: string[], + options: NormalizeHooksOptions, +): CodexHooksFile | null { + try { + return parseHooksJson(readFileSync(path, 'utf-8'), path, warnings, options); + } catch (error) { + warnings.push( + `Codex hooks: failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +function resolveManifestPath( + pluginPath: string, + field: string, + rawPath: string, + warnings: string[], +): string | null { + if (!rawPath.startsWith('./')) { + warnings.push(`Codex hooks: ignoring ${field}; path must start with './'`); + return null; + } + + const relativePath = rawPath.slice(2); + if (!relativePath) { + warnings.push(`Codex hooks: ignoring ${field}; path must not be './'`); + return null; + } + if (isAbsolute(relativePath)) { + warnings.push(`Codex hooks: ignoring ${field}; path must stay within the plugin root`); + return null; + } + + const normalized = normalize(relativePath).replace(/\\/g, '/'); + if (normalized === '..' || normalized.startsWith('../')) { + warnings.push(`Codex hooks: ignoring ${field}; path must not contain '..'`); + return null; + } + + return join(pluginPath, normalized); +} + +function substitutePluginEnv(value: unknown, env: Record): unknown { + if (typeof value === 'string') { + return Object.entries(env).reduce( + (current, [key, replacement]) => current.replaceAll(`\${${key}}`, replacement), + value, + ); + } + if (Array.isArray(value)) { + return value.map((entry) => substitutePluginEnv(entry, env)); + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, substitutePluginEnv(entry, env)]), + ); + } + return value; +} + +function withPluginEnv( + hooksFile: CodexHooksFile, + pluginPath: string, + pluginDataPath: string, +): CodexHooksFile { + const env = { + PLUGIN_ROOT: pluginPath, + CLAUDE_PLUGIN_ROOT: pluginPath, + PLUGIN_DATA: pluginDataPath, + CLAUDE_PLUGIN_DATA: pluginDataPath, + }; + return substitutePluginEnv(hooksFile, env) as CodexHooksFile; +} + +function pluginDataPath(workspacePath: string, plugin: ValidatedPlugin): string { + const rawName = plugin.pluginName ?? basename(plugin.resolved) ?? 'plugin'; + const safeName = rawName.replace(/[^a-zA-Z0-9_.-]/g, '-'); + return join(workspacePath, '.allagents', 'plugin-data', safeName); +} + +function readManifestHookDeclarations( + pluginPath: string, + manifestPath: string, + warnings: string[], +): Array<{ path?: string; inline?: CodexHooksFile }> | null { + let manifest: JsonRecord; + try { + const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8')); + if (!isRecord(parsed)) { + warnings.push(`Codex hooks: ${manifestPath} must contain a JSON object`); + return null; + } + manifest = parsed; + } catch (error) { + warnings.push( + `Codex hooks: failed to parse ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + + if (!('hooks' in manifest)) { + return null; + } + + const hooks = manifest.hooks; + if (typeof hooks === 'string') { + const path = resolveManifestPath(pluginPath, 'hooks', hooks, warnings); + return path ? [{ path }] : null; + } + + if (Array.isArray(hooks) && hooks.every((entry) => typeof entry === 'string')) { + const paths = hooks + .map((entry) => resolveManifestPath(pluginPath, 'hooks', entry, warnings)) + .filter((entry): entry is string => entry !== null); + return paths.length > 0 ? paths.map((path) => ({ path })) : null; + } + + if (Array.isArray(hooks) && hooks.every(isRecord)) { + const inline = hooks + .map((entry, index) => + normalizeHooksObject(entry, `${manifestPath}#hooks[${index}]`, warnings, { + filterCodexEvents: true, + }), + ) + .filter((entry): entry is CodexHooksFile => entry !== null && hasHooks(entry)); + return inline.length > 0 ? inline.map((entry) => ({ inline: entry })) : null; + } + + if (isRecord(hooks)) { + const inline = normalizeHooksObject(hooks, `${manifestPath}#hooks`, warnings, { + filterCodexEvents: true, + }); + return inline && hasHooks(inline) ? [{ inline }] : null; + } + + warnings.push( + `Codex hooks: ignoring hooks in ${manifestPath}; expected a string, string array, object, or object array`, + ); + return null; +} + +function collectPluginCodexHooks( + plugin: ValidatedPlugin, + workspacePath: string, + warnings: string[], +): CodexHooksFile[] { + const pluginPath = plugin.resolved; + const manifestPath = join(pluginPath, CODEX_PLUGIN_MANIFEST_RELATIVE_PATH); + const declarations = existsSync(manifestPath) + ? readManifestHookDeclarations(pluginPath, manifestPath, warnings) + : null; + const effectiveDeclarations = declarations ?? ( + existsSync(join(pluginPath, DEFAULT_PLUGIN_HOOKS_RELATIVE_PATH)) + ? [{ path: join(pluginPath, DEFAULT_PLUGIN_HOOKS_RELATIVE_PATH) }] + : [] + ); + + const dataPath = pluginDataPath(workspacePath, plugin); + const hooksFiles: CodexHooksFile[] = []; + + for (const declaration of effectiveDeclarations) { + const hooksFile = declaration.inline ?? ( + declaration.path + ? readHooksJson(declaration.path, warnings, { filterCodexEvents: true }) + : null + ); + if (hooksFile && hasHooks(hooksFile)) { + hooksFiles.push(withPluginEnv(hooksFile, pluginPath, dataPath)); + } + } + + return hooksFiles; +} + +function mergeHooks(files: CodexHooksFile[]): CodexHooksFile { + const merged: Record = {}; + for (const file of files) { + for (const [eventName, groups] of Object.entries(file.hooks)) { + if (groups.length === 0) continue; + merged[eventName] = [...(merged[eventName] ?? []), ...cloneJson(groups)]; + } + } + return { hooks: orderedHooks(merged) }; +} + +function removeManagedHooks( + existing: CodexHooksFile, + previousManaged: CodexHooksFile | undefined, +): CodexHooksFile { + if (!hasHooks(previousManaged)) return cloneJson(existing); + + const hooks = cloneJson(existing.hooks); + for (const [eventName, previousGroups] of Object.entries(previousManaged.hooks)) { + const groups = hooks[eventName]; + if (!groups || groups.length === 0) continue; + + for (const previousGroup of previousGroups) { + const previousKey = JSON.stringify(previousGroup); + const index = groups.findIndex((group) => JSON.stringify(group) === previousKey); + if (index !== -1) { + groups.splice(index, 1); + } + } + + if (groups.length === 0) { + delete hooks[eventName]; + } + } + + return { hooks: orderedHooks(hooks) }; +} + +function appendManagedHooks(base: CodexHooksFile, currentManaged: CodexHooksFile): CodexHooksFile { + const hooks = cloneJson(base.hooks); + for (const [eventName, groups] of Object.entries(currentManaged.hooks)) { + if (groups.length === 0) continue; + hooks[eventName] = [...(hooks[eventName] ?? []), ...cloneJson(groups)]; + } + return { hooks: orderedHooks(hooks) }; +} + +function readExistingProjectHooks( + hooksPath: string, + warnings: string[], +): { hooks: CodexHooksFile; valid: boolean } { + if (!existsSync(hooksPath)) { + return { hooks: { hooks: {} }, valid: true }; + } + + const parsed = readHooksJson(hooksPath, warnings, { + filterCodexEvents: false, + strictEventArrays: true, + }); + return parsed + ? { hooks: parsed, valid: true } + : { hooks: { hooks: {} }, valid: false }; +} + +function writeProjectHooks(hooksPath: string, hooksFile: CodexHooksFile): void { + mkdirSync(dirname(hooksPath), { recursive: true }); + writeFileSync(hooksPath, `${JSON.stringify(hooksFile, null, 2)}\n`, 'utf-8'); +} + +function pluginTargetsCodex(plugin: ValidatedPlugin): boolean { + return plugin.clients.includes('codex' as ClientType); +} + +export function syncCodexProjectHooks( + validatedPlugins: ValidatedPlugin[], + workspacePath: string, + previousManagedHooks: CodexHooksFile | undefined, + options: { dryRun?: boolean } = {}, +): CodexHookSyncResult { + const warnings: string[] = []; + const codexPlugins = validatedPlugins.filter((plugin) => plugin.success && pluginTargetsCodex(plugin)); + const currentManagedHooks = mergeHooks( + codexPlugins.flatMap((plugin) => collectPluginCodexHooks(plugin, workspacePath, warnings)), + ); + const hasCurrentManagedHooks = hasHooks(currentManagedHooks); + const hadPreviousManagedHooks = hasHooks(previousManagedHooks); + + if (!hasCurrentManagedHooks && !hadPreviousManagedHooks) { + return { copyResults: [], warnings }; + } + + const hooksPath = join(workspacePath, CODEX_HOOKS_RELATIVE_PATH); + const { hooks: existingHooks, valid } = readExistingProjectHooks(hooksPath, warnings); + if (!valid) { + warnings.push( + `Codex hooks: not updating ${CODEX_HOOKS_RELATIVE_PATH} because the existing file could not be parsed`, + ); + return { + copyResults: [], + warnings, + ...(hadPreviousManagedHooks && { managedHooks: previousManagedHooks }), + }; + } + + const withoutPreviousManaged = removeManagedHooks(existingHooks, previousManagedHooks); + const withoutManagedDuplicates = removeManagedHooks( + withoutPreviousManaged, + currentManagedHooks, + ); + const finalHooks = hasCurrentManagedHooks + ? appendManagedHooks(withoutManagedDuplicates, currentManagedHooks) + : withoutManagedDuplicates; + const hasFinalHooks = hasHooks(finalHooks); + + if (!options.dryRun) { + if (hasFinalHooks) { + writeProjectHooks(hooksPath, finalHooks); + } else if (existsSync(hooksPath)) { + unlinkSync(hooksPath); + } + + for (const plugin of codexPlugins) { + const dataPath = pluginDataPath(workspacePath, plugin); + if (hasCurrentManagedHooks) { + mkdirSync(dataPath, { recursive: true }); + } + } + } + + return { + copyResults: [ + { + source: 'codex-plugin-hooks', + destination: hooksPath, + action: 'generated', + }, + ], + warnings, + ...(hasCurrentManagedHooks && { managedHooks: currentManagedHooks }), + }; +} diff --git a/src/core/sync-state.ts b/src/core/sync-state.ts index 5200c4d..0624165 100644 --- a/src/core/sync-state.ts +++ b/src/core/sync-state.ts @@ -18,6 +18,7 @@ export type McpScope = 'vscode' | 'codex' | 'claude' | 'copilot'; */ export interface SyncStateData { files: Partial>; + codexHooks?: SyncState['codexHooks']; mcpServers?: Partial>; nativePlugins?: Partial>; vscodeWorkspaceHash?: string; @@ -85,6 +86,7 @@ export async function saveSyncState( version: 1, lastSync: new Date().toISOString(), files: normalizedData.files as Record, + ...(normalizedData.codexHooks && { codexHooks: normalizedData.codexHooks }), ...(normalizedData.mcpServers && { mcpServers: normalizedData.mcpServers }), ...(normalizedData.nativePlugins && { nativePlugins: normalizedData.nativePlugins }), ...(normalizedData.vscodeWorkspaceHash && { vscodeWorkspaceHash: normalizedData.vscodeWorkspaceHash }), @@ -161,6 +163,7 @@ export async function upsertSyncStateSource( await saveSyncState(workspacePath, { files: (existing?.files ?? {}) as Partial>, + ...(existing?.codexHooks && { codexHooks: existing.codexHooks }), ...(existing?.mcpServers && { mcpServers: existing.mcpServers as Partial>, }), diff --git a/src/core/sync.ts b/src/core/sync.ts index 0f1c4ae..0860d85 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -97,6 +97,7 @@ import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; import type { McpMergeResult } from './vscode-mcp.js'; import { applyMcpProxy } from './mcp-proxy.js'; import { syncCodexMcpServers } from './codex-mcp.js'; +import { syncCodexProjectHooks } from './codex-hooks.js'; import { syncClaudeMcpConfig, syncClaudeMcpServersViaCli, @@ -1615,6 +1616,9 @@ function countCopyResults( case 'copied': totalCopied++; break; + case 'generated': + totalGenerated++; + break; case 'failed': totalFailed++; break; @@ -1901,6 +1905,7 @@ async function persistSyncState( nativeResult: NativeSyncResult | undefined, extra?: { vscodeState?: { hash: string; repos: string[] }; + codexHooks?: SyncState['codexHooks']; mcpTrackedServers?: Partial>; clientMappings?: Record; skillsIndex?: string[]; @@ -1939,6 +1944,7 @@ async function persistSyncState( await saveSyncState(workspacePath, { files: syncedFiles, + ...(extra?.codexHooks && { codexHooks: extra.codexHooks }), ...(Object.keys(nativePluginsState).length > 0 && { nativePlugins: nativePluginsState, }), @@ -2192,10 +2198,20 @@ export async function syncWorkspace( ), ); + // Step 4c: Merge Codex plugin hooks into project-scoped .codex/hooks.json. + // This preserves user-owned hooks and replaces only the allagents-managed + // subset recorded in sync state. + const codexHookSync = await sw.measure('codex-hooks-sync', async () => + syncCodexProjectHooks(validPlugins, workspacePath, previousState?.codexHooks, { + dryRun, + }), + ); + warnings.push(...codexHookSync.warnings); + // Step 5: Copy workspace files if configured // Supports both workspace.source (default base) and file-level sources // Skip when workspace.source was configured but validation failed (plugins still synced above) - let workspaceFileResults: CopyResult[] = []; + const workspaceFileResults: CopyResult[] = [...codexHookSync.copyResults]; let writtenSkillsIndexFiles: string[] = []; const skipWorkspaceFiles = !!config.workspace?.source && !validatedWorkspaceSource; @@ -2269,7 +2285,7 @@ export async function syncWorkspace( // Step 5d: Copy workspace files with GitHub cache // Pass repositories and skillsIndexRefs so conditional links are embedded in WORKSPACE-RULES - workspaceFileResults = await copyWorkspaceFiles( + workspaceFileResults.push(...(await copyWorkspaceFiles( sourcePath, workspacePath, filesToCopy, @@ -2279,7 +2295,7 @@ export async function syncWorkspace( repositories: config.repositories, skillsIndexRefs, }, - ); + ))); // If claude is a client and CLAUDE.md doesn't exist, copy AGENTS.md to CLAUDE.md // Skip when repositories is empty (no agent files should be created) @@ -2392,6 +2408,9 @@ export async function syncWorkspace( nativeResult, { ...(vscodeState && { vscodeState }), + ...(codexHookSync.managedHooks && { + codexHooks: codexHookSync.managedHooks, + }), ...(Object.keys(mcpResults).length > 0 && { mcpTrackedServers: Object.fromEntries( Object.entries(mcpResults).map(([scope, r]) => [ diff --git a/src/models/sync-state.ts b/src/models/sync-state.ts index 8510abc..69dc33a 100644 --- a/src/models/sync-state.ts +++ b/src/models/sync-state.ts @@ -32,6 +32,13 @@ export const SyncStateSchema = z.object({ version: z.literal(1), lastSync: z.string(), // ISO timestamp files: z.record(ClientTypeSchema, z.array(z.string())), + // Project-scoped Codex hooks managed inside .codex/hooks.json. This stores + // only the allagents-owned portion so sync can preserve user hooks. + codexHooks: z + .object({ + hooks: z.record(z.string(), z.array(z.unknown())), + }) + .optional(), // MCP servers tracked per scope (e.g., "vscode" for user-level mcp.json) mcpServers: z.record(z.string(), z.array(z.string())).optional(), // Native plugins tracked per client type (e.g., "claude" for claude plugin install) diff --git a/src/utils/plugin-path.ts b/src/utils/plugin-path.ts index c03d0c5..3ceac3a 100644 --- a/src/utils/plugin-path.ts +++ b/src/utils/plugin-path.ts @@ -174,7 +174,7 @@ export function parseGitHubUrl( if (treeMatch) { const owner = treeMatch[1]; const repo = treeMatch[2]?.replace(/\.git$/, ''); - const afterTree = treeMatch[3]; + const afterTree = decodeGitHubPath(treeMatch[3]); if (owner && repo && afterTree) { // afterTree is everything after /tree/, e.g., "feat/my-feature/path" or "main" @@ -243,6 +243,15 @@ export function parseGitHubUrl( return null; } +function decodeGitHubPath(path: string | undefined): string | undefined { + if (!path) return path; + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Normalize plugin source path * Converts relative paths to absolute, leaves GitHub URLs as-is diff --git a/tests/unit/core/codex-hooks.test.ts b/tests/unit/core/codex-hooks.test.ts new file mode 100644 index 0000000..6918679 --- /dev/null +++ b/tests/unit/core/codex-hooks.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { syncCodexProjectHooks } from '../../../src/core/codex-hooks.js'; +import { syncWorkspace } from '../../../src/core/sync.js'; +import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; +import type { SyncState } from '../../../src/models/sync-state.js'; +import type { ValidatedPlugin } from '../../../src/core/sync.js'; + +function validatedCodexPlugin(path: string, plugin: string): ValidatedPlugin { + return { + plugin, + resolved: path, + success: true, + clients: ['codex'], + nativeClients: [], + }; +} + +async function writeSkill(pluginDir: string, name: string): Promise { + await mkdir(join(pluginDir, 'skills', name), { recursive: true }); + await writeFile( + join(pluginDir, 'skills', name, 'SKILL.md'), + `--- +name: ${name} +description: ${name} description +--- +`, + 'utf-8', + ); +} + +async function readHooks(workspaceDir: string): Promise> { + const content = await readFile(join(workspaceDir, '.codex', 'hooks.json'), 'utf-8'); + return (JSON.parse(content) as { hooks: Record }).hooks; +} + +function commandFrom(group: unknown): string | undefined { + const hooks = (group as { hooks?: Array<{ command?: string }> }).hooks; + return hooks?.[0]?.command; +} + +describe('syncCodexProjectHooks', () => { + let testDir: string; + let workspaceDir: string; + let pluginA: string; + let pluginB: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-codex-hooks-')); + workspaceDir = join(testDir, 'workspace'); + pluginA = join(testDir, 'plugin-a'); + pluginB = join(testDir, 'plugin-b'); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(join(pluginA, '.codex-plugin', 'hooks'), { recursive: true }); + await mkdir(join(pluginB, 'hooks'), { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('merges multiple plugin hooks into existing project hooks without duplicating on update', async () => { + await mkdir(join(workspaceDir, '.codex'), { recursive: true }); + await writeFile( + join(workspaceDir, '.codex', 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo user' }] }], + }, + }), + 'utf-8', + ); + + await writeFile( + join(pluginA, '.codex-plugin', 'plugin.json'), + JSON.stringify({ + name: 'plugin-a', + hooks: './.codex-plugin/hooks/hooks.json', + }), + 'utf-8', + ); + await writeFile( + join(pluginA, '.codex-plugin', 'hooks', 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '"${PLUGIN_ROOT}/hooks/run-hook.cmd" session-start', + }, + ], + }, + ], + }, + }), + 'utf-8', + ); + await writeFile( + join(pluginB, 'hooks', 'hooks.json'), + JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo b' }] }], + }, + }), + 'utf-8', + ); + + const plugins = [ + validatedCodexPlugin(pluginA, './plugin-a'), + validatedCodexPlugin(pluginB, './plugin-b'), + ]; + const first = syncCodexProjectHooks(plugins, workspaceDir, undefined); + expect(first.warnings).toEqual([]); + + let hooks = await readHooks(workspaceDir); + expect(hooks.SessionStart).toHaveLength(2); + expect(commandFrom(hooks.SessionStart![0])).toBe('echo user'); + expect(commandFrom(hooks.SessionStart![1])).toContain(`${pluginA}/hooks/run-hook.cmd`); + expect(hooks.UserPromptSubmit).toHaveLength(1); + + const withoutState = syncCodexProjectHooks(plugins, workspaceDir, undefined); + expect(withoutState.warnings).toEqual([]); + + hooks = await readHooks(workspaceDir); + expect(hooks.SessionStart).toHaveLength(2); + expect(commandFrom(hooks.SessionStart![0])).toBe('echo user'); + expect(commandFrom(hooks.SessionStart![1])).toContain(`${pluginA}/hooks/run-hook.cmd`); + expect(hooks.UserPromptSubmit).toHaveLength(1); + + const second = syncCodexProjectHooks(plugins, workspaceDir, first.managedHooks); + expect(second.warnings).toEqual([]); + + hooks = await readHooks(workspaceDir); + expect(hooks.SessionStart).toHaveLength(2); + expect(commandFrom(hooks.SessionStart![0])).toBe('echo user'); + expect(commandFrom(hooks.SessionStart![1])).toContain(`${pluginA}/hooks/run-hook.cmd`); + expect(hooks.UserPromptSubmit).toHaveLength(1); + }); + + it('removes only the previously managed hooks when plugins stop providing hooks', async () => { + await mkdir(join(workspaceDir, '.codex'), { recursive: true }); + await writeFile( + join(workspaceDir, '.codex', 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'echo user' }] }, + { hooks: [{ type: 'command', command: 'echo managed' }] }, + ], + }, + }), + 'utf-8', + ); + const previousManaged: NonNullable = { + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo managed' }] }], + }, + }; + + syncCodexProjectHooks([], workspaceDir, previousManaged); + + const hooks = await readHooks(workspaceDir); + expect(hooks.SessionStart).toHaveLength(1); + expect(commandFrom(hooks.SessionStart![0])).toBe('echo user'); + }); + + it('deletes hooks.json when the only hooks were previously managed', async () => { + await mkdir(join(workspaceDir, '.codex'), { recursive: true }); + await writeFile( + join(workspaceDir, '.codex', 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo managed' }] }], + }, + }), + 'utf-8', + ); + const previousManaged: NonNullable = { + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo managed' }] }], + }, + }; + + syncCodexProjectHooks([], workspaceDir, previousManaged); + + expect(existsSync(join(workspaceDir, '.codex', 'hooks.json'))).toBe(false); + }); + + it('does not overwrite an existing project hooks file with invalid hook arrays', async () => { + await mkdir(join(workspaceDir, '.codex'), { recursive: true }); + const hooksPath = join(workspaceDir, '.codex', 'hooks.json'); + const originalContent = JSON.stringify({ + hooks: { + SessionStart: { hooks: [{ type: 'command', command: 'echo invalid' }] }, + }, + }); + await writeFile(hooksPath, originalContent, 'utf-8'); + await writeFile( + join(pluginB, 'hooks', 'hooks.json'), + JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo b' }] }], + }, + }), + 'utf-8', + ); + + const result = syncCodexProjectHooks( + [validatedCodexPlugin(pluginB, './plugin-b')], + workspaceDir, + undefined, + ); + + expect(result.copyResults).toEqual([]); + expect(result.warnings.some((warning) => warning.includes('not updating'))).toBe(true); + expect(await readFile(hooksPath, 'utf-8')).toBe(originalContent); + }); +}); + +describe('syncWorkspace Codex hooks', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-sync-codex-hooks-')); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('syncs project-scoped Codex skills and multiple hooks while preserving existing hooks', async () => { + const pluginA = join(testDir, 'plugin-a'); + const pluginB = join(testDir, 'plugin-b'); + await writeSkill(pluginA, 'a'); + await writeSkill(pluginB, 'b'); + await mkdir(join(pluginA, '.codex-plugin', 'hooks'), { recursive: true }); + await mkdir(join(pluginB, 'hooks'), { recursive: true }); + await writeFile( + join(pluginA, '.codex-plugin', 'plugin.json'), + JSON.stringify({ name: 'plugin-a', hooks: './.codex-plugin/hooks/hooks.json' }), + 'utf-8', + ); + await writeFile( + join(pluginA, '.codex-plugin', 'hooks', 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo a' }] }], + }, + }), + 'utf-8', + ); + await writeFile( + join(pluginB, 'hooks', 'hooks.json'), + JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo b' }] }], + }, + }), + 'utf-8', + ); + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await mkdir(join(testDir, '.codex'), { recursive: true }); + await writeFile( + join(testDir, '.codex', 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo user' }] }], + }, + }), + 'utf-8', + ); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ./plugin-a + - ./plugin-b +clients: + - codex +syncMode: copy +`, + 'utf-8', + ); + + const first = await syncWorkspace(testDir); + expect(first.success).toBe(true); + expect(first.totalGenerated).toBe(1); + expect(existsSync(join(testDir, '.codex', 'skills', 'a'))).toBe(true); + expect(existsSync(join(testDir, '.codex', 'skills', 'b'))).toBe(true); + + let hooks = await readHooks(testDir); + expect(hooks.SessionStart).toHaveLength(2); + expect(hooks.UserPromptSubmit).toHaveLength(1); + + const stateContent = await readFile(join(testDir, CONFIG_DIR, 'sync-state.json'), 'utf-8'); + const state = JSON.parse(stateContent) as SyncState; + expect(state.codexHooks?.hooks.SessionStart).toHaveLength(1); + expect(state.codexHooks?.hooks.UserPromptSubmit).toHaveLength(1); + + const second = await syncWorkspace(testDir); + expect(second.success).toBe(true); + expect(second.totalGenerated).toBe(1); + + hooks = await readHooks(testDir); + expect(hooks.SessionStart).toHaveLength(2); + expect(hooks.UserPromptSubmit).toHaveLength(1); + }); +}); diff --git a/tests/unit/utils/plugin-path.test.ts b/tests/unit/utils/plugin-path.test.ts index 14b883b..2a140a5 100644 --- a/tests/unit/utils/plugin-path.test.ts +++ b/tests/unit/utils/plugin-path.test.ts @@ -162,6 +162,18 @@ describe('parseGitHubUrl', () => { }); }); + it('should decode URL-encoded slashes in tree subpaths', () => { + const result = parseGitHubUrl( + 'https://github.com/gastownhall/beads/tree/main/plugins%2Fbeads', + ); + expect(result).toEqual({ + owner: 'gastownhall', + repo: 'beads', + branch: 'main', + subpath: 'plugins/beads', + }); + }); + it('should parse URLs with /blob/ the same as /tree/', () => { const result = parseGitHubUrl( 'https://github.com/WiseTechGlobal/WTG.AI.Prompts/blob/main/scripts/allagents-setup/cargowise' From 8785847accf2823e520f2645deeed51fd817fc5f Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 20 Jun 2026 03:21:57 +0200 Subject: [PATCH 2/2] docs(examples): add Codex plugin stack workspace --- .../.allagents/workspace.yaml | 37 ++++++++++++++ .../workspaces/codex-plugin-stack/README.md | 49 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 examples/workspaces/codex-plugin-stack/.allagents/workspace.yaml create mode 100644 examples/workspaces/codex-plugin-stack/README.md diff --git a/examples/workspaces/codex-plugin-stack/.allagents/workspace.yaml b/examples/workspaces/codex-plugin-stack/.allagents/workspace.yaml new file mode 100644 index 0000000..9938b6f --- /dev/null +++ b/examples/workspaces/codex-plugin-stack/.allagents/workspace.yaml @@ -0,0 +1,37 @@ +version: 2 + +# Example: Project-scoped Codex plugin stack +# +# This workspace demonstrates installing a Codex-focused agent workflow stack +# from a single declarative workspace file. AllAgents syncs these plugins into +# project-scoped Codex files, so users do not need to run separate global +# Codex plugin install commands on each machine. +# +# What gets generated: +# - .codex/skills/ Skills from workmux, agent-tui, Beads, and Compound Engineering +# - .codex/hooks.json Beads hooks, merged with any existing user-owned hooks +# +# Requirements for using every synced skill/hook: +# - agent-tui on PATH for the agent-tui skills +# - bd on PATH for Beads workflows/hooks + +repositories: [] + +plugins: + # Worktree/tmux workflow skills. + - https://github.com/raine/workmux/ + + # Beads project-management skills and Codex hooks. + - https://github.com/gastownhall/beads/tree/main/plugins/beads + + # Compound Engineering skills. This is project-scoped skill sync; it does not + # require `bunx @every-env/compound-plugin install compound-engineering --to codex`. + - https://github.com/EveryInc/compound-engineering-plugin/tree/main/plugins/compound-engineering + + # Agent TUI skills. The `agent-tui` binary is still installed separately. + - https://github.com/pproenca/agent-tui/tree/master/skills + +clients: + - codex + +syncMode: copy diff --git a/examples/workspaces/codex-plugin-stack/README.md b/examples/workspaces/codex-plugin-stack/README.md new file mode 100644 index 0000000..26fb420 --- /dev/null +++ b/examples/workspaces/codex-plugin-stack/README.md @@ -0,0 +1,49 @@ +# Codex Plugin Stack Example + +A copy-and-run workspace for installing a project-scoped Codex agent workflow +stack with AllAgents. + +It includes: + +- `workmux` for worktree/tmux workflow skills +- `agent-tui` skills for controlling interactive terminal sessions +- `beads` skills and Codex hooks +- `compound-engineering` skills without requiring the global Codex plugin install + +## Running It + +Scaffold a fresh copy anywhere: + +```bash +allagents workspace init ./codex-plugin-stack-demo \ + --from EntityProcess/allagents/examples/workspaces/codex-plugin-stack +cd ./codex-plugin-stack-demo +``` + +Or run it in-place from this repository checkout: + +```bash +cd examples/workspaces/codex-plugin-stack +allagents update +``` + +After sync, inspect: + +```bash +find .codex/skills -maxdepth 2 -name SKILL.md +cat .codex/hooks.json +``` + +## Notes + +AllAgents syncs project-local Codex skills and hooks. It does not write Codex's +global plugin registry, so the Compound Engineering command below is not needed +for this project-scoped setup: + +```bash +bunx @every-env/compound-plugin install compound-engineering --to codex +``` + +Runtime binaries are still separate from plugin artifact sync. Install +`agent-tui` before using the agent-tui skills, and install `bd` before relying +on Beads commands or hooks.