From 7e4fade9283864036a70c812af6f292b3769190f Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 30 Jul 2026 08:59:23 +1000 Subject: [PATCH 1/8] fix(copilot): keep repository hooks project-scoped (#442) * fix(copilot): keep repository hooks project-scoped * test(e2e): isolate plugin skill home --- README.md | 8 +- docs/src/content/docs/docs/guides/plugins.mdx | 9 +- .../content/docs/docs/reference/clients.mdx | 2 +- src/core/sync.ts | 24 ++++ src/core/transform.ts | 120 +++++++++++++++++- tests/e2e/plugin-skills.test.ts | 10 +- tests/unit/core/sync-user.test.ts | 118 +++++++++++++++++ 7 files changed, 281 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5e481ff9..fc343379 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ my-plugin/ ├── commands/ # Slash commands (Claude, OpenCode) ├── hooks/ # Lifecycle hooks (Claude, Factory, Copilot, Codex) ├── .codex-plugin/ # Codex plugin manifest and explicit hook paths -├── .github/ # Copilot/VSCode overrides +├── .github/ # Copilot/VSCode project overrides └── .mcp.json # MCP server configs ``` @@ -135,6 +135,12 @@ 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. + ## Documentation Full documentation at [allagents.dev](https://allagents.dev): diff --git a/docs/src/content/docs/docs/guides/plugins.mdx b/docs/src/content/docs/docs/guides/plugins.mdx index f4f9c54a..729eda5b 100644 --- a/docs/src/content/docs/docs/guides/plugins.mdx +++ b/docs/src/content/docs/docs/guides/plugins.mdx @@ -13,7 +13,7 @@ my-plugin/ ├── agents/ # Agent definitions (Claude, Copilot, Factory) ├── hooks/ # Hook definitions (Claude, Copilot, Factory) ├── commands/ # Commands (Claude, OpenCode) -├── .github/ # GitHub overrides (Copilot, VSCode) +├── .github/ # Project-scoped GitHub overrides (Copilot, VSCode) │ ├── copilot-instructions.md │ ├── instructions/ # Pattern-based instructions │ ├── prompts/ # Prompt files @@ -22,6 +22,13 @@ my-plugin/ └── AGENTS.md ``` +:::note +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 +and reported for manual review because their ownership was not tracked. +::: + ## Duplicate Skill Handling When multiple plugins define skills with the same folder name, AllAgents automatically resolves naming conflicts: diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index 62df43e9..78d11721 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -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. +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. ::: ### VSCode diff --git a/src/core/sync.ts b/src/core/sync.ts index bbf2d191..8bda6245 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -39,6 +39,7 @@ import { copyWorkspaceFiles, collectPluginSkills, type CopyResult, + findRelocatedGitHubHooks, } from './transform.js'; import { updateAgentFiles } from './workspace-repo.js'; import { @@ -2579,6 +2580,29 @@ export async function syncUserWorkspace( await sw.measure('selective-purge', () => selectivePurgeWorkspace(homeDir, previousState, syncClients), ); + + const relocatedHooks = await sw.measure( + 'legacy-copilot-hook-scan', + () => + findRelocatedGitHubHooks( + validPlugins + .filter((plugin) => plugin.clients.includes('copilot')) + .map((plugin) => ({ + pluginPath: plugin.resolved, + ...(plugin.exclude && { exclude: plugin.exclude }), + })), + homeDir, + 'copilot', + { clientMappings: USER_CLIENT_MAPPINGS }, + ), + ); + + for (const filePath of relocatedHooks.found) { + const displayPath = relative(homeDir, filePath).replace(/\\/g, '/'); + warnings.push( + `Copilot user hook '${displayPath}' shares a path with a repository .github/hooks artifact. Repository hooks are no longer synced at user scope; review this file manually if an older AllAgents version installed it. A root hooks/ artifact may still manage the same path.`, + ); + } } // Two-pass skill name resolution (excluding disabled/non-enabled skills) diff --git a/src/core/transform.ts b/src/core/transform.ts index 39bb3732..fd3fd7e0 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -1,5 +1,12 @@ -import { existsSync } from 'node:fs'; -import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { existsSync, type Dirent } from 'node:fs'; +import { + access, + cp, + mkdir, + readFile, + readdir, + writeFile, +} from 'node:fs/promises'; import { basename, dirname, join, relative } from 'node:path'; import micromatch from 'micromatch'; import { @@ -730,6 +737,107 @@ export interface GitHubCopyOptions extends CopyOptions { skillNameMap?: Map; } +function relocatesGitHubContent(mapping: ClientMapping): boolean { + return mapping.githubPath !== '.github/'; +} + +function githubContentExcludes( + mapping: ClientMapping, + exclude?: string[], +): string[] | undefined { + if (!relocatesGitHubContent(mapping)) return exclude; + + // .github/hooks is repository-owned. Root hooks/ remains the portable + // plugin artifact that can be installed into a client's user hook path. + return [...(exclude ?? []), '.github/hooks']; +} + +export interface RelocatedGitHubHooksSource { + pluginPath: string; + exclude?: string[]; +} + +export interface RelocatedGitHubHooksResult { + found: string[]; +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} + +/** + * Find files that older versions may have mirrored from repository + * .github/hooks into a relocated user directory. Historical sync state did not + * record ownership of these files, so callers must leave them in place and + * report them for manual review. + */ +export async function findRelocatedGitHubHooks( + sources: RelocatedGitHubHooksSource[], + workspacePath: string, + client: ClientType, + options: Pick = {}, +): Promise { + const emptyResult: RelocatedGitHubHooksResult = { found: [] }; + const mapping = getMapping(client, options); + if ( + options.dryRun || + !mapping.githubPath || + !relocatesGitHubContent(mapping) + ) { + return emptyResult; + } + + const destDir = join(workspacePath, mapping.githubPath, 'hooks'); + const candidates = new Set(); + await Promise.all( + sources.map(async ({ pluginPath, exclude }) => { + const sourceRoot = join(pluginPath, '.github', 'hooks'); + + async function collect(sourceDir: string): Promise { + let entries: Dirent[]; + try { + entries = await readdir(sourceDir, { withFileTypes: true }); + } catch (error) { + if (isNotFoundError(error)) return; + throw error; + } + + await Promise.all( + entries.map(async (entry) => { + const sourcePath = join(sourceDir, entry.name); + if (isExcluded(pluginPath, sourcePath, exclude)) return; + if (entry.isDirectory()) return collect(sourcePath); + + const relativePath = relative(sourceRoot, sourcePath); + candidates.add(relativePath); + }), + ); + } + + await collect(sourceRoot); + }), + ); + + const found = await Promise.all( + [...candidates].sort().map(async (relativePath) => { + const destPath = join(destDir, relativePath); + try { + await access(destPath); + return destPath; + } catch (error) { + return isNotFoundError(error) ? undefined : destPath; + } + }), + ); + + return { found: found.filter((path) => path !== undefined) }; +} + /** * Recursively process a directory, copying files and adjusting links in markdown. * Single-pass approach: read source → transform if markdown → write to dest. @@ -819,6 +927,7 @@ export async function copyGitHubContent( } const destDir = join(workspacePath, mapping.githubPath); + const effectiveExclude = githubContentExcludes(mapping, options.exclude); if (dryRun) { results.push({ source: sourceDir, destination: destDir, action: 'copied' }); @@ -827,7 +936,10 @@ export async function copyGitHubContent( try { // Single-pass: copy files and adjust markdown links in one traversal - if (mapping.skillsPath || (options.exclude && options.exclude.length > 0)) { + if ( + mapping.skillsPath || + (effectiveExclude && effectiveExclude.length > 0) + ) { await copyAndAdjustDirectory( sourceDir, destDir, @@ -835,7 +947,7 @@ export async function copyGitHubContent( pluginPath, mapping.skillsPath ?? '', skillNameMap, - options.exclude, + effectiveExclude, ); } else { // No skills path and no excludes - just copy without adjustment diff --git a/tests/e2e/plugin-skills.test.ts b/tests/e2e/plugin-skills.test.ts index cb724935..959d7a69 100644 --- a/tests/e2e/plugin-skills.test.ts +++ b/tests/e2e/plugin-skills.test.ts @@ -190,14 +190,14 @@ description: Test skill }); it('promoted GitHub skill sources stay coherent for listing and sync', async () => { - const originalHome = process.env.HOME; + const originalTestHome = process.env.ALLAGENTS_TEST_HOME; const fakeHome = join(tmpDir, 'home'); const cacheDir = join( fakeHome, '.allagents/plugins/marketplaces/NousResearch-hermes-agent@main/skills/research', ); - process.env.HOME = fakeHome; + process.env.ALLAGENTS_TEST_HOME = fakeHome; resetFetchCache(); try { @@ -246,7 +246,11 @@ description: Blog watcher expect(existsSync(join(tmpDir, '.claude/skills/blogwatcher'))).toBe(true); } finally { resetFetchCache(); - process.env.HOME = originalHome; + if (originalTestHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalTestHome; + } } }); }); diff --git a/tests/unit/core/sync-user.test.ts b/tests/unit/core/sync-user.test.ts index ba29d3a9..81fab25b 100644 --- a/tests/unit/core/sync-user.test.ts +++ b/tests/unit/core/sync-user.test.ts @@ -276,4 +276,122 @@ describe('syncUserWorkspace', () => { expect(stateContent.files.copilot).toContain('.copilot/skills/my-skill/'); expect(stateContent.files.codex).toContain('.codex/skills/my-skill/'); }); + + it('keeps repository hooks project-scoped during user Copilot sync', async () => { + const pluginDir = join(testDir, 'plugins', 'copilot-hooks'); + await mkdir(join(pluginDir, 'hooks'), { recursive: true }); + await mkdir(join(pluginDir, '.github', 'hooks', 'scripts'), { + recursive: true, + }); + await mkdir(join(pluginDir, '.github', 'prompts'), { recursive: true }); + await writeFile( + join(pluginDir, 'hooks', 'global.json'), + '{"hooks":{}}', + ); + await writeFile( + join(pluginDir, '.github', 'hooks', 'repository.json'), + '{"hooks":{}}', + ); + await writeFile( + join(pluginDir, '.github', 'hooks', 'scripts', 'repository.mjs'), + 'console.log("repository hook")', + ); + await writeFile( + join(pluginDir, '.github', 'prompts', 'review.prompt.md'), + '# Review', + ); + + await writeUserConfig({ + repositories: [], + plugins: [pluginDir], + clients: ['copilot'], + syncMode: 'copy', + }); + + // Simulate an upgrade from a version that promoted .github/hooks into + // the user-global Copilot directory. Ownership was not tracked, so the + // next sync must leave the files in place and warn instead of deleting + // potentially user-owned hooks. + const globalHooksDir = join(testDir, '.copilot', 'hooks'); + await mkdir(join(globalHooksDir, 'scripts'), { recursive: true }); + await writeFile( + join(globalHooksDir, 'repository.json'), + '{"hooks":{}}', + ); + await writeFile( + join(globalHooksDir, 'scripts', 'repository.mjs'), + 'console.log("repository hook")', + ); + await writeFile( + join(globalHooksDir, 'user-owned.json'), + '{"hooks":{"UserPromptSubmit":[]}}', + ); + await writeFile( + join(testDir, '.allagents', 'sync-state.json'), + JSON.stringify({ + version: 1, + lastSync: new Date().toISOString(), + files: { copilot: [] }, + }), + ); + + const result = await syncUserWorkspace(); + + expect(result.success).toBe(true); + expect(existsSync(join(globalHooksDir, 'global.json'))).toBe(true); + expect(existsSync(join(globalHooksDir, 'repository.json'))).toBe(true); + expect( + existsSync(join(globalHooksDir, 'scripts', 'repository.mjs')), + ).toBe(true); + expect(existsSync(join(globalHooksDir, 'user-owned.json'))).toBe(true); + expect( + existsSync( + join(testDir, '.copilot', 'prompts', 'review.prompt.md'), + ), + ).toBe(true); + expect(result.warnings).toContain( + "Copilot user hook '.copilot/hooks/repository.json' shares a path with a repository .github/hooks artifact. Repository hooks are no longer synced at user scope; review this file manually if an older AllAgents version installed it. A root hooks/ artifact may still manage the same path.", + ); + expect(result.warnings).toContain( + "Copilot user hook '.copilot/hooks/scripts/repository.mjs' shares a path with a repository .github/hooks artifact. Repository hooks are no longer synced at user scope; review this file manually if an older AllAgents version installed it. A root hooks/ artifact may still manage the same path.", + ); + }); + + it('reports a legacy path accurately when a root hook overwrites it', async () => { + const pluginDir = join(testDir, 'plugins', 'copilot-hooks'); + await mkdir(join(pluginDir, 'hooks'), { recursive: true }); + await mkdir(join(pluginDir, '.github', 'hooks'), { recursive: true }); + await writeFile( + join(pluginDir, 'hooks', 'repository.json'), + '{"hooks":{"global":true}}', + ); + await writeFile( + join(pluginDir, '.github', 'hooks', 'repository.json'), + '{"hooks":{"source":true}}', + ); + await writeUserConfig({ + repositories: [], + plugins: [pluginDir], + clients: ['copilot'], + syncMode: 'copy', + }); + + const legacyHookPath = join( + testDir, + '.copilot', + 'hooks', + 'repository.json', + ); + await mkdir(join(testDir, '.copilot', 'hooks'), { recursive: true }); + await writeFile(legacyHookPath, '{"hooks":{"userModified":true}}'); + + const result = await syncUserWorkspace(); + + expect(await readFile(legacyHookPath, 'utf-8')).toBe( + '{"hooks":{"global":true}}', + ); + expect(result.warnings).toContain( + "Copilot user hook '.copilot/hooks/repository.json' shares a path with a repository .github/hooks artifact. Repository hooks are no longer synced at user scope; review this file manually if an older AllAgents version installed it. A root hooks/ artifact may still manage the same path.", + ); + }); }); From fa26133d2637067eaf8cb9a698032803f4e09ad7 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 30 Jul 2026 09:53:44 +1000 Subject: [PATCH 2/8] fix(plugin): respect marketplace component boundaries (#443) --- docs/src/content/docs/docs/guides/plugins.mdx | 26 ++++ src/core/codex-hooks.ts | 5 +- src/core/marketplace.ts | 43 +++++- src/core/sync.ts | 34 ++++- src/core/transform.ts | 58 +++++--- src/core/vscode-mcp.ts | 1 + src/models/marketplace-manifest.ts | 42 +++++- src/utils/marketplace-manifest-parser.ts | 77 ++++++++-- tests/e2e/plugin-skills.test.ts | 91 ++++++++++++ tests/unit/core/codex-hooks.test.ts | 28 ++++ tests/unit/core/copilot-artifacts.test.ts | 31 +++++ tests/unit/core/marketplace.test.ts | 131 ++++++++++++++++++ tests/unit/core/vscode-mcp.test.ts | 25 ++++ .../utils/marketplace-manifest-parser.test.ts | 57 ++++++++ 14 files changed, 613 insertions(+), 36 deletions(-) diff --git a/docs/src/content/docs/docs/guides/plugins.mdx b/docs/src/content/docs/docs/guides/plugins.mdx index 729eda5b..cb6050d6 100644 --- a/docs/src/content/docs/docs/guides/plugins.mdx +++ b/docs/src/content/docs/docs/guides/plugins.mdx @@ -29,6 +29,32 @@ to `~/.copilot/hooks/`. Potential copies from older versions are left untouched and reported for manual review because their ownership was not tracked. ::: +## Marketplace Component Boundaries + +AllAgents recognizes marketplace manifests at both +`.claude-plugin/marketplace.json` and `.github/plugin/marketplace.json`. The +GitHub Copilot location takes precedence when a repository contains both. + +A marketplace entry with `strict: false` defines the complete set of plugin +components. For example, this entry syncs the repository's top-level skills +without treating its top-level hooks or `.github/` development configuration as +plugin artifacts: + +```json +{ + "name": "skills-only", + "source": "./", + "strict": false, + "skills": ["./skills/"] +} +``` + +Without `strict: false`, AllAgents retains conventional plugin-directory +discovery for compatibility. Top-level `hooks/` remains the portable hook +location; `.github/` represents repository-scoped GitHub content. Component +kind filtering currently uses AllAgents' conventional root directories, so +custom marketplace component paths are not yet remapped. + ## Duplicate Skill Handling When multiple plugins define skills with the same folder name, AllAgents automatically resolves naming conflicts: diff --git a/src/core/codex-hooks.ts b/src/core/codex-hooks.ts index 11f121f5..efde8ebc 100644 --- a/src/core/codex-hooks.ts +++ b/src/core/codex-hooks.ts @@ -362,7 +362,10 @@ function writeProjectHooks(hooksPath: string, hooksFile: CodexHooksFile): void { } function pluginTargetsCodex(plugin: ValidatedPlugin): boolean { - return plugin.clients.includes('codex' as ClientType); + return ( + plugin.clients.includes('codex' as ClientType) && + plugin.fileArtifacts?.hooks !== false + ); } export function syncCodexProjectHooks( diff --git a/src/core/marketplace.ts b/src/core/marketplace.ts index 2f1872c3..68da5374 100644 --- a/src/core/marketplace.ts +++ b/src/core/marketplace.ts @@ -4,6 +4,11 @@ import { basename, dirname, join, resolve } from 'node:path'; import simpleGit from 'simple-git'; import { getHomeDir } from '../constants.js'; import { + type MarketplaceFileArtifacts, + getMarketplaceFileArtifacts, +} from '../models/marketplace-manifest.js'; +import { + getEmbeddedMarketplaceFileArtifacts, parseMarketplaceManifest, resolvePluginSourcePath, } from '../utils/marketplace-manifest-parser.js'; @@ -749,6 +754,13 @@ export interface MarketplacePluginInfo { skills?: string[]; } +function normalizeComponentPaths( + paths: string | string[] | undefined, +): string[] | undefined { + if (paths === undefined) return undefined; + return Array.isArray(paths) ? paths : [paths]; +} + /** * Result of listing marketplace plugins, including any warnings * from lenient manifest parsing. @@ -787,7 +799,8 @@ export async function getMarketplacePluginsFromManifest( }; if (plugin.category) info.category = plugin.category; if (plugin.homepage) info.homepage = plugin.homepage; - if (plugin.skills) info.skills = plugin.skills; + const skills = normalizeComponentPaths(plugin.skills); + if (skills) info.skills = skills; return info; }); @@ -916,7 +929,12 @@ export async function resolvePluginSpec( fetchFn?: (url: string) => Promise; workspacePath?: string; } = {}, -): Promise<{ path: string; marketplace: string; plugin: string } | null> { +): Promise<{ + path: string; + marketplace: string; + plugin: string; + fileArtifacts?: MarketplaceFileArtifacts; +} | null> { const parsed = parsePluginSpec(spec); if (!parsed) { return null; @@ -943,6 +961,7 @@ export async function resolvePluginSpec( (p) => p.name === parsed.plugin, ); if (pluginEntry) { + const declaredFileArtifacts = getMarketplaceFileArtifacts(pluginEntry); if (typeof pluginEntry.source === 'string') { // Local path source - resolve relative to marketplace const resolvedPath = resolve(marketplacePath, pluginEntry.source); @@ -951,6 +970,9 @@ export async function resolvePluginSpec( path: resolvedPath, marketplace: marketplaceName, plugin: parsed.plugin, + ...(declaredFileArtifacts && { + fileArtifacts: declaredFileArtifacts, + }), }; } } else { @@ -963,10 +985,17 @@ export async function resolvePluginSpec( parsedUrl.repo, ); if (existsSync(cachePath)) { + const fileArtifacts = + declaredFileArtifacts ?? + (await getEmbeddedMarketplaceFileArtifacts( + cachePath, + parsed.plugin, + )); return { path: cachePath, marketplace: marketplaceName, plugin: parsed.plugin, + ...(fileArtifacts && { fileArtifacts }), }; } } @@ -976,10 +1005,17 @@ export async function resolvePluginSpec( const fetchFn = options.fetchFn ?? fetchPlugin; const fetchResult = await fetchFn(pluginEntry.source.url); if (fetchResult.success && fetchResult.cachePath) { + const fileArtifacts = + declaredFileArtifacts ?? + (await getEmbeddedMarketplaceFileArtifacts( + fetchResult.cachePath, + parsed.plugin, + )); return { path: fetchResult.cachePath, marketplace: marketplaceName, plugin: parsed.plugin, + ...(fileArtifacts && { fileArtifacts }), }; } } @@ -1011,6 +1047,8 @@ export interface ResolvePluginSpecResult { registeredAs?: string; /** GitHub marketplace source (owner/repo) for native CLI registration */ marketplaceSource?: string; + /** File artifacts declared by a non-strict marketplace entry. */ + fileArtifacts?: MarketplaceFileArtifacts; error?: string; } @@ -1171,6 +1209,7 @@ export async function resolvePluginSpecWithAutoRegister( pluginName: resolved.plugin, ...(shouldReturnRegisteredAs && { registeredAs: marketplace.name }), ...(marketplaceSource && { marketplaceSource }), + ...(resolved.fileArtifacts && { fileArtifacts: resolved.fileArtifacts }), }; } diff --git a/src/core/sync.ts b/src/core/sync.ts index 8bda6245..a1a5d254 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -56,6 +56,8 @@ import { resolveClientMappings, } from '../models/client-mapping.js'; import type { ClientMapping } from '../models/client-mapping.js'; +import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; +import { getEmbeddedMarketplaceFileArtifacts } from '../utils/marketplace-manifest-parser.js'; import { resolveSkillNames, getSkillKey, @@ -337,6 +339,8 @@ export interface ValidatedPlugin { registeredAs?: string; /** GitHub marketplace source (owner/repo) for native CLI registration */ marketplaceSource?: string; + /** File artifacts declared by a non-strict marketplace entry. */ + fileArtifacts?: MarketplaceFileArtifacts; /** Glob patterns of files to exclude when syncing (from workspace.yaml) */ exclude?: string[]; /** Inline skill selection config from plugin entry (v2+) */ @@ -1103,6 +1107,7 @@ async function collectAvailableSkillNames( ): Promise> { const names = new Set(); for (const plugin of validPlugins) { + if (plugin.fileArtifacts && !plugin.fileArtifacts.skills) continue; const skills = await collectPluginSkills( plugin.resolved, plugin.plugin, @@ -1157,6 +1162,9 @@ async function validatePlugin( ...(resolved.marketplaceSource && { marketplaceSource: resolved.marketplaceSource, }), + ...(resolved.fileArtifacts && { + fileArtifacts: resolved.fileArtifacts, + }), }; } @@ -1183,12 +1191,17 @@ async function validatePlugin( const resolvedPath = parsed?.subpath ? join(fetchResult.cachePath, parsed.subpath) : fetchResult.cachePath; + const fileArtifacts = await getEmbeddedMarketplaceFileArtifacts( + resolvedPath, + parsed?.repo, + ); return { plugin: pluginSource, resolved: resolvedPath, success: true, clients: [], nativeClients: [], + ...(fileArtifacts && { fileArtifacts }), }; } @@ -1204,12 +1217,17 @@ async function validatePlugin( error: `Plugin not found at ${resolvedPath}`, }; } + const fileArtifacts = await getEmbeddedMarketplaceFileArtifacts( + resolvedPath, + getPluginName(resolvedPath), + ); return { plugin: pluginSource, resolved: resolvedPath, success: true, clients: [], nativeClients: [], + ...(fileArtifacts && { fileArtifacts }), }; } @@ -1387,6 +1405,9 @@ async function copyValidatedPlugin( clientMappings: mappings, syncMode: 'copy', ...(exclude && { exclude }), + ...(validatedPlugin.fileArtifacts && { + fileArtifacts: validatedPlugin.fileArtifacts, + }), }, ); copyResults.push(...results); @@ -1403,6 +1424,9 @@ async function copyValidatedPlugin( syncMode: 'symlink', canonicalSkillsPath: CANONICAL_SKILLS_PATH, ...(exclude && { exclude }), + ...(validatedPlugin.fileArtifacts && { + fileArtifacts: validatedPlugin.fileArtifacts, + }), }, ); copyResults.push(...results); @@ -1426,6 +1450,9 @@ async function copyValidatedPlugin( clientMappings: mappings, syncMode: 'copy', ...(exclude && { exclude }), + ...(validatedPlugin.fileArtifacts && { + fileArtifacts: validatedPlugin.fileArtifacts, + }), }, ); copyResults.push(...results); @@ -1473,6 +1500,7 @@ async function collectAllSkills( const allSkills: CollectedSkillEntry[] = []; for (const plugin of validatedPlugins) { + if (plugin.fileArtifacts && !plugin.fileArtifacts.skills) continue; const pluginName = plugin.pluginName ?? getPluginName(plugin.resolved); const skills = await collectPluginSkills( plugin.resolved, @@ -2586,7 +2614,11 @@ export async function syncUserWorkspace( () => findRelocatedGitHubHooks( validPlugins - .filter((plugin) => plugin.clients.includes('copilot')) + .filter( + (plugin) => + plugin.clients.includes('copilot') && + plugin.fileArtifacts?.github !== false, + ) .map((plugin) => ({ pluginPath: plugin.resolved, ...(plugin.exclude && { exclude: plugin.exclude }), diff --git a/src/core/transform.ts b/src/core/transform.ts index fd3fd7e0..10269424 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -19,6 +19,7 @@ import { isUniversalClient, } from '../models/client-mapping.js'; import type { ClientMapping } from '../models/client-mapping.js'; +import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; import type { ClientType, PluginSkillsConfig, @@ -970,6 +971,11 @@ export async function copyGitHubContent( * Options for copying a plugin to workspace */ export interface PluginCopyOptions extends CopyOptions { + /** + * File artifacts explicitly exposed by a non-strict marketplace entry. + * Undefined preserves conventional plugin-directory discovery. + */ + fileArtifacts?: MarketplaceFileArtifacts; /** * Map of skill folder name to resolved name for this specific plugin. * When provided, skills will be copied using the resolved name instead of folder name. @@ -1003,33 +1009,45 @@ export async function copyPluginToWorkspace( client: ClientType, options: PluginCopyOptions = {}, ): Promise { - const { skillNameMap, syncMode, canonicalSkillsPath, ...baseOptions } = - options; + const { + skillNameMap, + syncMode, + canonicalSkillsPath, + fileArtifacts, + ...baseOptions + } = options; + const shouldCopy = (artifact: keyof MarketplaceFileArtifacts): boolean => + fileArtifacts?.[artifact] ?? true; // Phase 1: Copy root-level artifacts in parallel const [commandResults, skillResults, hookResults, agentResults] = await Promise.all([ - copyCommands(pluginPath, workspacePath, client, baseOptions), - copySkills(pluginPath, workspacePath, client, { - ...baseOptions, - ...(skillNameMap && { skillNameMap }), - ...(syncMode && { syncMode }), - ...(canonicalSkillsPath && { canonicalSkillsPath }), - }), - copyHooks(pluginPath, workspacePath, client, baseOptions), - copyAgents(pluginPath, workspacePath, client, baseOptions), + shouldCopy('commands') + ? copyCommands(pluginPath, workspacePath, client, baseOptions) + : [], + shouldCopy('skills') + ? copySkills(pluginPath, workspacePath, client, { + ...baseOptions, + ...(skillNameMap && { skillNameMap }), + ...(syncMode && { syncMode }), + ...(canonicalSkillsPath && { canonicalSkillsPath }), + }) + : [], + shouldCopy('hooks') + ? copyHooks(pluginPath, workspacePath, client, baseOptions) + : [], + shouldCopy('agents') + ? copyAgents(pluginPath, workspacePath, client, baseOptions) + : [], ]); // Phase 2: Copy .github/ content — overrides root-level on name conflicts - const githubResults = await copyGitHubContent( - pluginPath, - workspacePath, - client, - { - ...baseOptions, - ...(skillNameMap && { skillNameMap }), - }, - ); + const githubResults = shouldCopy('github') + ? await copyGitHubContent(pluginPath, workspacePath, client, { + ...baseOptions, + ...(skillNameMap && { skillNameMap }), + }) + : []; return [ ...commandResults, diff --git a/src/core/vscode-mcp.ts b/src/core/vscode-mcp.ts index 7460d4c1..d94e74c7 100644 --- a/src/core/vscode-mcp.ts +++ b/src/core/vscode-mcp.ts @@ -120,6 +120,7 @@ export function collectMcpServers( const warnings: string[] = []; for (const plugin of validatedPlugins) { + if (plugin.fileArtifacts?.mcpServers === false) continue; const mcpServers = readPluginMcpConfig(plugin.resolved); if (!mcpServers) continue; diff --git a/src/models/marketplace-manifest.ts b/src/models/marketplace-manifest.ts index 50cf483d..2d852140 100644 --- a/src/models/marketplace-manifest.ts +++ b/src/models/marketplace-manifest.ts @@ -32,6 +32,12 @@ export const PluginSourceRefSchema = z.union([z.string(), UrlSourceSchema, GitHu export type PluginSourceRef = z.infer; +/** A plugin component may be declared as one path or several paths. */ +export const ComponentPathSchema = z.union([ + z.string(), + z.array(z.string()), +]); + /** * Author/owner contact info */ @@ -67,12 +73,46 @@ export const MarketplacePluginEntrySchema = z.object({ homepage: z.string().optional(), strict: z.boolean().optional(), tags: z.array(z.string()).optional(), - skills: z.array(z.string()).optional(), + skills: ComponentPathSchema.optional(), + commands: ComponentPathSchema.optional(), + agents: ComponentPathSchema.optional(), + hooks: z.union([z.string(), z.record(z.unknown())]).optional(), + mcpServers: z.union([z.string(), z.record(z.unknown())]).optional(), lspServers: z.record(LspServerSchema).optional(), }); export type MarketplacePluginEntry = z.infer; +/** File artifact kinds AllAgents can copy from a plugin source. */ +export interface MarketplaceFileArtifacts { + commands: boolean; + skills: boolean; + hooks: boolean; + agents: boolean; + mcpServers: boolean; + github: boolean; +} + +/** + * In non-strict mode the marketplace entry is the complete component + * definition. Omitted components must not be inferred from repository files. + */ +export function getMarketplaceFileArtifacts( + entry: MarketplacePluginEntry, +): MarketplaceFileArtifacts | undefined { + if (entry.strict !== false) return undefined; + + return { + commands: entry.commands !== undefined, + skills: entry.skills !== undefined, + hooks: entry.hooks !== undefined, + agents: entry.agents !== undefined, + mcpServers: entry.mcpServers !== undefined, + // .github is repository configuration, not a marketplace component kind. + github: false, + }; +} + /** * Top-level marketplace.json schema */ diff --git a/src/utils/marketplace-manifest-parser.ts b/src/utils/marketplace-manifest-parser.ts index 55e6fbc1..e620c5cf 100644 --- a/src/utils/marketplace-manifest-parser.ts +++ b/src/utils/marketplace-manifest-parser.ts @@ -1,17 +1,22 @@ -import { readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { - MarketplaceManifestSchema, - MarketplaceManifestLenientSchema, - MarketplacePluginEntrySchema, - PluginSourceRefSchema, + type MarketplaceFileArtifacts, type MarketplaceManifest, + MarketplaceManifestLenientSchema, + MarketplaceManifestSchema, type MarketplacePluginEntry, + MarketplacePluginEntrySchema, type PluginSourceRef, + PluginSourceRefSchema, + getMarketplaceFileArtifacts, } from '../models/marketplace-manifest.js'; -const MANIFEST_PATH = '.claude-plugin/marketplace.json'; +const MANIFEST_PATHS = [ + '.github/plugin/marketplace.json', + '.claude-plugin/marketplace.json', +] as const; export type ParseResult = | { success: true; data: MarketplaceManifest; warnings: string[] } @@ -19,7 +24,7 @@ export type ParseResult = /** * Parse and validate a marketplace.json from a marketplace directory. - * Looks for .claude-plugin/marketplace.json within the given path. + * Prefers GitHub Copilot's marketplace location and falls back to Claude's. * * Uses a two-tier approach: * 1. Try strict validation first — if it passes, return with no warnings @@ -29,12 +34,14 @@ export type ParseResult = export async function parseMarketplaceManifest( marketplacePath: string, ): Promise { - const manifestPath = join(marketplacePath, MANIFEST_PATH); + const manifestPath = MANIFEST_PATHS + .map((path) => join(marketplacePath, path)) + .find((path) => existsSync(path)); - if (!existsSync(manifestPath)) { + if (!manifestPath) { return { success: false, - error: `Marketplace manifest not found: ${manifestPath}`, + error: `Marketplace manifest not found (checked ${MANIFEST_PATHS.join(', ')})`, }; } @@ -68,6 +75,34 @@ export async function parseMarketplaceManifest( return parseLeniently(json); } +/** + * Read a plugin repository's own marketplace manifest and return the file + * artifact boundary for the entry whose source is the repository root. + * + * A preferred name disambiguates repositories that expose multiple root + * entries. When there is only one root entry, it is safe to use for direct + * repository sources whose directory name differs from the plugin name. + */ +export async function getEmbeddedMarketplaceFileArtifacts( + pluginPath: string, + preferredName?: string, +): Promise { + const manifestResult = await parseMarketplaceManifest(pluginPath); + if (!manifestResult.success) return undefined; + + const pluginRoot = resolve(pluginPath); + const rootEntries = manifestResult.data.plugins.filter( + (entry) => + typeof entry.source === 'string' && + resolve(pluginPath, entry.source) === pluginRoot, + ); + const entry = + rootEntries.find((candidate) => candidate.name === preferredName) ?? + (rootEntries.length === 1 ? rootEntries[0] : undefined); + + return entry ? getMarketplaceFileArtifacts(entry) : undefined; +} + /** * Attempt lenient parsing of a marketplace manifest. * Requires at minimum a `plugins` array in the JSON. @@ -169,10 +204,30 @@ function extractPluginEntry( ...(typeof obj.version === 'string' && { version: obj.version }), ...(typeof obj.category === 'string' && { category: obj.category }), ...(typeof obj.homepage === 'string' && { homepage: obj.homepage }), - ...(Array.isArray(obj.skills) && obj.skills.every((s: unknown) => typeof s === 'string') && { skills: obj.skills as string[] }), + ...(isComponentPath(obj.skills) && { skills: obj.skills }), + ...(isComponentPath(obj.commands) && { commands: obj.commands }), + ...(isComponentPath(obj.agents) && { agents: obj.agents }), + ...((typeof obj.hooks === 'string' || isRecord(obj.hooks)) && { + hooks: obj.hooks, + }), + ...((typeof obj.mcpServers === 'string' || isRecord(obj.mcpServers)) && { + mcpServers: obj.mcpServers, + }), + ...(typeof obj.strict === 'boolean' && { strict: obj.strict }), }; } +function isComponentPath(value: unknown): value is string | string[] { + return ( + typeof value === 'string' || + (Array.isArray(value) && value.every((item) => typeof item === 'string')) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + /** * Resolve a plugin source reference to a usable path. * diff --git a/tests/e2e/plugin-skills.test.ts b/tests/e2e/plugin-skills.test.ts index 959d7a69..10335f97 100644 --- a/tests/e2e/plugin-skills.test.ts +++ b/tests/e2e/plugin-skills.test.ts @@ -253,4 +253,95 @@ description: Blog watcher } } }); + + it('honors skills-only Copilot marketplace entries without copying repository config', async () => { + const originalTestHome = process.env.ALLAGENTS_TEST_HOME; + const fakeHome = join(tmpDir, 'home'); + const marketplaceDir = join(tmpDir, 'ediprod-marketplace'); + + process.env.ALLAGENTS_TEST_HOME = fakeHome; + try { + await mkdir(join(marketplaceDir, '.github', 'plugin'), { + recursive: true, + }); + await mkdir(join(marketplaceDir, '.github', 'hooks'), { + recursive: true, + }); + await mkdir(join(marketplaceDir, 'hooks'), { recursive: true }); + await mkdir(join(marketplaceDir, 'skills', 'ediprod'), { + recursive: true, + }); + await writeFile( + join(marketplaceDir, '.github', 'plugin', 'marketplace.json'), + JSON.stringify({ + name: 'ediprod-plugins', + description: 'ediProd plugins', + plugins: [ + { + name: 'ediprod', + description: 'ediProd skills', + source: './', + strict: false, + skills: ['./skills/'], + }, + ], + }), + ); + await writeFile( + join(marketplaceDir, 'skills', 'ediprod', 'SKILL.md'), + '---\nname: ediprod\ndescription: ediProd\n---\n', + ); + await writeFile( + join(marketplaceDir, 'hooks', 'portable.json'), + '{"hooks":{}}', + ); + await writeFile( + join(marketplaceDir, '.github', 'hooks', 'repository.json'), + '{"hooks":{}}', + ); + await writeFile( + join(marketplaceDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + undeclared: { + type: 'http', + url: 'https://undeclared.test', + }, + }, + }), + ); + + const config: WorkspaceConfig = { + repositories: [], + // Direct repository sources should honor their embedded marketplace + // boundary instead of treating repository configuration as plugin data. + plugins: [marketplaceDir], + clients: ['copilot'], + syncMode: 'copy', + version: 2, + }; + await writeFile( + join(tmpDir, '.allagents/workspace.yaml'), + dump(config), + 'utf-8', + ); + + const result = await syncWorkspace(tmpDir); + + expect(result.success).toBe(true); + expect( + existsSync(join(tmpDir, '.github', 'skills', 'ediprod', 'SKILL.md')), + ).toBe(true); + expect(existsSync(join(tmpDir, '.github', 'hooks'))).toBe(false); + expect(existsSync(join(tmpDir, '.copilot', 'mcp-config.json'))).toBe( + false, + ); + } finally { + if (originalTestHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalTestHome; + } + } + }); }); diff --git a/tests/unit/core/codex-hooks.test.ts b/tests/unit/core/codex-hooks.test.ts index 69186791..3778ba45 100644 --- a/tests/unit/core/codex-hooks.test.ts +++ b/tests/unit/core/codex-hooks.test.ts @@ -220,6 +220,34 @@ describe('syncCodexProjectHooks', () => { expect(result.warnings.some((warning) => warning.includes('not updating'))).toBe(true); expect(await readFile(hooksPath, 'utf-8')).toBe(originalContent); }); + + it('does not import hooks omitted by a non-strict marketplace entry', async () => { + await writeFile( + join(pluginB, 'hooks', 'hooks.json'), + JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'echo undeclared' }] }, + ], + }, + }), + 'utf-8', + ); + const plugin = validatedCodexPlugin(pluginB, './plugin-b'); + plugin.fileArtifacts = { + agents: false, + commands: false, + github: false, + hooks: false, + mcpServers: false, + skills: true, + }; + + const result = syncCodexProjectHooks([plugin], workspaceDir, undefined); + + expect(result.managedHooks).toBeUndefined(); + expect(existsSync(join(workspaceDir, '.codex', 'hooks.json'))).toBe(false); + }); }); describe('syncWorkspace Codex hooks', () => { diff --git a/tests/unit/core/copilot-artifacts.test.ts b/tests/unit/core/copilot-artifacts.test.ts index fa3b7531..4a2a67c5 100644 --- a/tests/unit/core/copilot-artifacts.test.ts +++ b/tests/unit/core/copilot-artifacts.test.ts @@ -65,6 +65,37 @@ describe('copilot agents and hooks sync', () => { }); describe('github override precedence', () => { + it('copies only declared artifacts for a non-strict marketplace entry', async () => { + await mkdir(join(pluginDir, 'skills', 'public-skill'), { recursive: true }); + await writeFile( + join(pluginDir, 'skills', 'public-skill', 'SKILL.md'), + '---\nname: public-skill\n---\n', + ); + await mkdir(join(pluginDir, 'hooks'), { recursive: true }); + await writeFile(join(pluginDir, 'hooks', 'portable.json'), '{"hooks":[]}'); + await mkdir(join(pluginDir, '.github', 'hooks'), { recursive: true }); + await writeFile( + join(pluginDir, '.github', 'hooks', 'repository.json'), + '{"hooks":[]}', + ); + + await copyPluginToWorkspace(pluginDir, workspaceDir, 'copilot', { + fileArtifacts: { + agents: false, + commands: false, + github: false, + hooks: false, + mcpServers: false, + skills: true, + }, + }); + + expect( + existsSync(join(workspaceDir, '.github', 'skills', 'public-skill', 'SKILL.md')), + ).toBe(true); + expect(existsSync(join(workspaceDir, '.github', 'hooks'))).toBe(false); + }); + it('.github/agents/ overrides root agents/ on name conflict', async () => { // Root agent await mkdir(join(pluginDir, 'agents'), { recursive: true }); diff --git a/tests/unit/core/marketplace.test.ts b/tests/unit/core/marketplace.test.ts index 45224a7e..237caa0f 100644 --- a/tests/unit/core/marketplace.test.ts +++ b/tests/unit/core/marketplace.test.ts @@ -203,6 +203,77 @@ describe('getMarketplacePluginsFromManifest', () => { expect(result!.plugin).toBe('external'); }); + it('should expose only declared artifacts for a strict-false skills-only plugin', async () => { + mkdirSync(join(testDir, 'skills', 'skill-a'), { recursive: true }); + mkdirSync(join(testDir, '.github', 'hooks'), { recursive: true }); + writeFileSync( + join(testDir, 'skills', 'skill-a', 'SKILL.md'), + '---\nname: skill-a\n---\n', + ); + writeFileSync( + join(testDir, '.github', 'hooks', 'post-edit.json'), + '{}', + ); + writeFileSync( + join(testDir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'test', + description: 'Test', + plugins: [ + { + name: 'skills-only', + description: 'Skills only', + source: './', + strict: false, + skills: ['./skills/'], + }, + ], + }), + ); + + const result = await resolvePluginSpec('skills-only@test-marketplace', { + marketplacePathOverride: testDir, + }); + + expect(result).not.toBeNull(); + expect(result!.path).toBe(testDir); + expect(result!.fileArtifacts).toEqual({ + agents: false, + commands: false, + github: false, + hooks: false, + mcpServers: false, + skills: true, + }); + }); + + it('should keep the plugin root when strict mode can merge other components', async () => { + mkdirSync(join(testDir, 'skills', 'skill-a'), { recursive: true }); + writeFileSync( + join(testDir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'test', + description: 'Test', + plugins: [ + { + name: 'full-plugin', + description: 'Full plugin', + source: './', + skills: ['./skills/'], + }, + ], + }), + ); + + const result = await resolvePluginSpec('full-plugin@test-marketplace', { + marketplacePathOverride: testDir, + }); + + expect(result).not.toBeNull(); + expect(result!.path).toBe(testDir); + expect(result!.fileArtifacts).toBeUndefined(); + }); + it('should return null for URL source plugins when fetch fails', async () => { const manifest = { name: 'test', @@ -281,6 +352,66 @@ describe('getMarketplacePluginsFromManifest', () => { expect(fetchedUrl).toBe('https://github.com/WiseTechGlobal/mcp-ediprod'); }); + it('should honor an external plugin repository embedded marketplace boundary', async () => { + const cachedPluginDir = join(testDir, 'cached-ediprod'); + mkdirSync(join(cachedPluginDir, '.github', 'plugin'), { + recursive: true, + }); + writeFileSync( + join(cachedPluginDir, '.github', 'plugin', 'marketplace.json'), + JSON.stringify({ + name: 'ediprod-plugins', + description: 'ediProd plugins', + plugins: [ + { + name: 'ediprod', + description: 'ediProd skills', + source: './', + strict: false, + skills: ['./skills/'], + }, + ], + }), + ); + writeFileSync( + join(testDir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'wtg-ai-prompts', + description: 'WTG plugins', + plugins: [ + { + name: 'ediprod', + description: 'ediProd', + source: { + source: 'github', + repo: 'WiseTechGlobal/mcp-ediprod', + }, + }, + ], + }), + ); + + const result = await resolvePluginSpec('ediprod@test-marketplace', { + marketplacePathOverride: testDir, + fetchFn: async () => ({ + success: true, + action: 'cloned' as const, + cachePath: cachedPluginDir, + }), + }); + + expect(result).not.toBeNull(); + expect(result!.path).toBe(cachedPluginDir); + expect(result!.fileArtifacts).toEqual({ + agents: false, + commands: false, + github: false, + hooks: false, + mcpServers: false, + skills: true, + }); + }); + it('should handle GitHub source plugins in plugin listing', async () => { const manifest = { name: 'test', diff --git a/tests/unit/core/vscode-mcp.test.ts b/tests/unit/core/vscode-mcp.test.ts index b26a567c..9d05216d 100644 --- a/tests/unit/core/vscode-mcp.test.ts +++ b/tests/unit/core/vscode-mcp.test.ts @@ -125,6 +125,31 @@ describe('collectMcpServers', () => { expect(warnings[0]).toContain('plugin-b'); expect(warnings[0]).toContain('dup'); }); + + test('skips MCP servers omitted by a non-strict marketplace entry', () => { + writeFileSync( + join(tempDir1, '.mcp.json'), + JSON.stringify({ + mcpServers: { + undeclared: { type: 'http', url: 'https://undeclared.test' }, + }, + }), + ); + const plugin = makePlugin(tempDir1); + plugin.fileArtifacts = { + agents: false, + commands: false, + github: false, + hooks: false, + mcpServers: false, + skills: true, + }; + + const { servers, warnings } = collectMcpServers([plugin]); + + expect(servers.size).toBe(0); + expect(warnings).toEqual([]); + }); }); describe('syncVscodeMcpConfig', () => { diff --git a/tests/unit/utils/marketplace-manifest-parser.test.ts b/tests/unit/utils/marketplace-manifest-parser.test.ts index e14bdbdd..6c075327 100644 --- a/tests/unit/utils/marketplace-manifest-parser.test.ts +++ b/tests/unit/utils/marketplace-manifest-parser.test.ts @@ -41,6 +41,63 @@ describe('parseMarketplaceManifest', () => { } }); + it('should fall back to the GitHub Copilot marketplace location', async () => { + rmSync(join(testDir, '.claude-plugin'), { recursive: true, force: true }); + mkdirSync(join(testDir, '.github', 'plugin'), { recursive: true }); + writeFileSync( + join(testDir, '.github', 'plugin', 'marketplace.json'), + JSON.stringify({ + name: 'copilot-marketplace', + description: 'Copilot marketplace', + plugins: [ + { + name: 'skills-only', + description: 'Skills only', + source: './', + strict: false, + skills: './skills/', + }, + ], + }), + ); + + const result = await parseMarketplaceManifest(testDir); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('copilot-marketplace'); + expect(result.data.plugins[0].strict).toBe(false); + expect(result.data.plugins[0].skills).toBe('./skills/'); + } + }); + + it('should prefer the GitHub Copilot marketplace location', async () => { + writeFileSync( + join(testDir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'claude-marketplace', + description: 'Claude marketplace', + plugins: [], + }), + ); + mkdirSync(join(testDir, '.github', 'plugin'), { recursive: true }); + writeFileSync( + join(testDir, '.github', 'plugin', 'marketplace.json'), + JSON.stringify({ + name: 'copilot-marketplace', + description: 'Copilot marketplace', + plugins: [], + }), + ); + + const result = await parseMarketplaceManifest(testDir); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('copilot-marketplace'); + } + }); + it('should return error when marketplace.json does not exist', async () => { rmSync(join(testDir, '.claude-plugin'), { recursive: true, force: true }); const result = await parseMarketplaceManifest(testDir); From 5e41ad4db8f6fa992c061f0d394d926d46864756 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 30 Jul 2026 09:54:17 +1000 Subject: [PATCH 3/8] chore(beads): remove Beads issue tracking integration (#441) The project no longer uses Beads for issue tracking, so drop the repo-local Dolt state, gitignore entries, and contributor docs that reference it. --- .beads/.gitignore | 83 -------------------------------------------- .beads/config.yaml | 68 ------------------------------------ .beads/metadata.json | 7 ---- .gitignore | 6 ---- AGENTS.md | 4 --- CONTRIBUTING.md | 7 ---- 6 files changed, 175 deletions(-) delete mode 100644 .beads/.gitignore delete mode 100644 .beads/config.yaml delete mode 100644 .beads/metadata.json diff --git a/.beads/.gitignore b/.beads/.gitignore deleted file mode 100644 index 18f762aa..00000000 --- a/.beads/.gitignore +++ /dev/null @@ -1,83 +0,0 @@ -# Dolt database (managed by Dolt, not git) -dolt/ -embeddeddolt/ -proxieddb/ - -# Generated local docs/export snapshots -README.md -issues.jsonl -events.jsonl -interactions.jsonl - -# Runtime files -bd.sock -bd.sock.startlock -sync-state.json -last-touched -.exclusive-lock - -# Daemon runtime (lock, log, pid) -daemon.* - -# Push state (runtime, per-machine) -push-state.json - -# Lock files (various runtime locks) -*.lock - -# Credential key (encryption key for federation peer auth — never commit) -.beads-credential-key - -# Local version tracking (prevents upgrade notification spam after git ops) -.local_version - -proxied_server_client_info.json - -# Worktree redirect file (contains relative path to main repo's .beads/) -# Must not be committed as paths would be wrong in other clones -redirect - -# Sync state (local-only, per-machine) -# These files are machine-specific and should not be shared across clones -.sync.lock -export-state/ -export-state.json -last_pull - -# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) -ephemeral.sqlite3 -ephemeral.sqlite3-journal -ephemeral.sqlite3-wal -ephemeral.sqlite3-shm - -# Dolt server management (auto-started by bd) -dolt-server.pid -dolt-server.log -dolt-server.lock -dolt-server.port -dolt-server.activity - -# Debug-mode pprof artifacts (written when dolt.debug: true in config.yaml) -dolt-pprof/ - -# Corrupt backup directories (created by bd doctor --fix recovery) -*.corrupt.backup/ - -# Backup data (auto-exported JSONL, local-only) -backup/ - -# Per-project environment file (Dolt connection config, GH#2520) -.env - -# Legacy files (from pre-Dolt versions) -*.db -*.db?* -*.db-journal -*.db-wal -*.db-shm -db.sqlite -bd.db -# NOTE: Do NOT add negation patterns here. -# They would override fork protection in .git/info/exclude. -# Config files (metadata.json, config.yaml) are tracked by git by default -# since no pattern above ignores them. diff --git a/.beads/config.yaml b/.beads/config.yaml deleted file mode 100644 index 861325dc..00000000 --- a/.beads/config.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# Beads Configuration File -# This file configures default behavior for all bd commands in this repository -# All settings can also be set via environment variables (BD_* prefix) -# or overridden with command-line flags - -# Issue prefix for this repository (used by bd init) -# If not set, bd init will auto-detect from directory name -# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" - -# Use no-db mode: JSONL-only, no Dolt database -# When true, .beads/issues.jsonl is the only local store -# no-db: false - -# Enable JSON output by default -# json: false - -# Feedback title formatting for mutating commands (create/update/close/dep/edit) -# 0 = hide titles, N > 0 = truncate to N characters -# output: -# title-length: 255 - -# Default actor for audit trails (overridden by BEADS_ACTOR or --actor) -# actor: "" - -# Export events (audit trail) to .beads/events.jsonl on each flush/sync -# When enabled, new events are appended incrementally using a high-water mark. -# Use 'bd export --events' to trigger manually regardless of this setting. -# events-export: false - -# Multi-repo configuration (experimental - bd-307) -# Allows hydrating from multiple repositories and routing writes to the correct database -# repos: -# primary: "." # Primary repo (where this database lives) -# additional: # Additional repos to hydrate from (read-only) -# - ~/beads-planning # Personal planning repo -# - ~/work-planning # Work planning repo - -# Dolt-native backup (periodic backup for off-machine recovery) -# This is full database backup only. Cross-machine sync uses Dolt remotes. -# backup: -# enabled: false # Disable auto-backup entirely -# interval: 15m # Minimum time between auto-backups -# git-push: false # Disable git push (backup locally only) -# git-repo: "" # Separate git repo for backups (default: project repo) - -# Optional JSONL auto-export for viewers, interchange, and issue-level migration. -# Disabled by default; enable only when an integration needs fresh .beads/issues.jsonl. -# Use relative paths under .beads/ for JSONL import/export filenames. -# export: -# auto: false -# path: issues.jsonl -# interval: 60s -# git-add: false -# import: -# path: issues.jsonl - -# Integration settings (access with 'bd config get/set') -# Non-secret keys (stored in the database): -# - jira.url, jira.project -# - linear.team_id -# - github.org, github.repo -# -# Secret keys (stored in this file but prefer env vars to avoid git exposure): -# - linear.api_key → use LINEAR_API_KEY env var instead -# - github.token → use GITHUB_TOKEN env var instead - -sync.remote: "git+https://github.com/EntityProcess/allagents" \ No newline at end of file diff --git a/.beads/metadata.json b/.beads/metadata.json deleted file mode 100644 index 8ee7f186..00000000 --- a/.beads/metadata.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "database": "dolt", - "backend": "dolt", - "dolt_mode": "embedded", - "dolt_database": "allagents", - "project_id": "82b512b7-0232-45ba-94c5-448de92d1afc" -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 68956767..770a78fe 100644 --- a/.gitignore +++ b/.gitignore @@ -41,12 +41,6 @@ temp/ .worktrees/ allagents.worktrees/ -# Beads / Dolt files -.dolt/ -*.db -.beads-credential-key -.beads/proxieddb/ - # Ralph runtime files (not config) .ralph/.call_count .ralph/.circuit_breaker_history diff --git a/AGENTS.md b/AGENTS.md index be0bb62c..6e47df4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,10 +57,6 @@ bun install - Temporary plans and design notes may live under `.claude/plans/` while work is in progress. - Once implementation is complete, delete stale plan files and update durable docs for any user-facing behavior changes. -### Beads -- Run `bd bootstrap` after cloning if the local issue database is missing; do not re-run `bd init` for this already-initialized repository. -- Beads syncs through the public AllAgents GitHub repository. Treat all issue content as public. - ## PR & Commit Titles - Prefer conventional commit style for branch-facing titles: `type(scope): summary`. - Use the repository's normal types where they fit, such as `feat`, `fix`, `docs`, `style`, `refactor`, `test`, and `chore`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51549fb2..2768a086 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,13 +27,6 @@ bun run dev workspace init test-ws bun run dev update ``` -## Issue Tracking - -AllAgents uses repo-local Beads state synced through this public GitHub -repository. After cloning, run `bd bootstrap` to download the issue database; -do not run `bd init`, because the project identity is already tracked. Treat -issue titles, descriptions, comments, and attachments as public data. - ## Before Submitting a PR - PR explains what changed and why From 50ba5f6ee572e4e33a3721d365db84a4a9c3fe54 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 30 Jul 2026 13:23:43 +1000 Subject: [PATCH 4/8] test(cli): isolate skill removal cache fixtures (#444) --- tests/unit/cli/skill-removal.test.ts | 18 ++++++++++++++---- tests/unit/core/skills.test.ts | 24 ++++++++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/tests/unit/cli/skill-removal.test.ts b/tests/unit/cli/skill-removal.test.ts index ec8be609..68069af1 100644 --- a/tests/unit/cli/skill-removal.test.ts +++ b/tests/unit/cli/skill-removal.test.ts @@ -5,8 +5,12 @@ import { join } from 'node:path'; import { load, dump } from 'js-yaml'; import { removeInstalledSkill } from '../../../src/cli/skill-removal.js'; import type { WorkspaceConfig } from '../../../src/models/workspace-config.js'; -import type { SkillInfo } from '../../../src/core/skills.js'; +import { + getAllSkillsFromPlugins, + type SkillInfo, +} from '../../../src/core/skills.js'; import { resetFetchCache } from '../../../src/core/plugin.js'; +import { stubHomeDir } from '../../helpers/env.js'; describe('removeInstalledSkill', () => { let tmpDir: string; @@ -164,8 +168,8 @@ describe('removeInstalledSkill', () => { }); it('removes a single-skill GitHub source instead of leaving an empty allowlist', async () => { - const originalHome = process.env.HOME; const fakeHome = join(tmpDir, 'home'); + const restoreHomeDir = stubHomeDir(fakeHome); const pluginDir = join( fakeHome, '.allagents/plugins/marketplaces/NousResearch-hermes-agent@main/skills/research/llm-wiki', @@ -185,10 +189,16 @@ describe('removeInstalledSkill', () => { }; await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config), 'utf-8'); - process.env.HOME = fakeHome; resetFetchCache(); try { + const discoveredSkills = await getAllSkillsFromPlugins(tmpDir); + expect( + discoveredSkills.map(({ name, pluginSource, path }) => ({ name, pluginSource, path })), + ).toEqual([ + { name: 'llm-wiki', pluginSource: source, path: pluginDir }, + ]); + const result = await removeInstalledSkill({ targetSkill: { name: 'llm-wiki', @@ -206,7 +216,7 @@ describe('removeInstalledSkill', () => { const updated = load(content) as WorkspaceConfig; expect(updated.plugins).toEqual([]); } finally { - process.env.HOME = originalHome; + restoreHomeDir(); resetFetchCache(); } }); diff --git a/tests/unit/core/skills.test.ts b/tests/unit/core/skills.test.ts index 5b4868d0..23240e60 100644 --- a/tests/unit/core/skills.test.ts +++ b/tests/unit/core/skills.test.ts @@ -3,11 +3,13 @@ import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { dump } from 'js-yaml'; +import { stubHomeDir } from '../../helpers/env.js'; import { getAllSkillsFromPlugins, discoverNestedSkillEntries, type SkillInfo, } from '../../../src/core/skills.js'; +import { resetFetchCache } from '../../../src/core/plugin.js'; import { getPluginCachePath } from '../../../src/utils/plugin-path.js'; describe('getAllSkillsFromPlugins', () => { @@ -224,23 +226,33 @@ describe('getAllSkillsFromPlugins', () => { }); it('skips GitHub URL entries whose subpath no longer exists in cache', async () => { - const originalHome = process.env.HOME; - process.env.HOME = tmpDir; + const restoreHomeDir = stubHomeDir(tmpDir); + resetFetchCache(); try { const cachePath = getPluginCachePath('owner', 'repo', 'main'); - await mkdir(cachePath, { recursive: true }); + const siblingPath = join(cachePath, 'available'); + await mkdir(siblingPath, { recursive: true }); + await writeFile(join(siblingPath, 'SKILL.md'), '# Cached sibling'); + + const missingSource = 'https://github.com/owner/repo/tree/main/missing/path'; + const siblingSource = 'https://github.com/owner/repo/tree/main/available'; const config = { repositories: [], - plugins: ['https://github.com/owner/repo/tree/main/missing/path'], + plugins: [missingSource, siblingSource], clients: ['claude'], }; await writeFile(join(tmpDir, '.allagents/workspace.yaml'), dump(config)); const skills = await getAllSkillsFromPlugins(tmpDir); - expect(skills).toEqual([]); + expect( + skills.map(({ name, pluginSource, path }) => ({ name, pluginSource, path })), + ).toEqual([ + { name: 'available', pluginSource: siblingSource, path: siblingPath }, + ]); } finally { - process.env.HOME = originalHome; + restoreHomeDir(); + resetFetchCache(); } }); }); From 5c5734f88b1498706101614eeeb09c983dbeb1b0 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 30 Jul 2026 13:27:26 +1000 Subject: [PATCH 5/8] fix(copilot): activate project plugin hooks (#445) * fix(copilot): activate project plugin hooks * fix(copilot): reject malformed hook declarations * docs(copilot): record project hook ownership decision * docs(copilot): clarify hook declaration boundary (#445) --- README.md | 21 +- docs/decisions/0001-project-plugin-hooks.md | 81 +++++ docs/src/content/docs/docs/guides/plugins.mdx | 21 ++ .../content/docs/docs/reference/clients.mdx | 2 +- src/core/copilot-hooks.ts | 208 ++++++++++++ src/core/sync.ts | 23 +- src/core/transform.ts | 30 +- tests/unit/core/copilot-hooks.test.ts | 305 ++++++++++++++++++ tests/unit/core/github-content.test.ts | 20 ++ 9 files changed, 699 insertions(+), 12 deletions(-) create mode 100644 docs/decisions/0001-project-plugin-hooks.md create mode 100644 src/core/copilot-hooks.ts create mode 100644 tests/unit/core/copilot-hooks.test.ts diff --git a/README.md b/README.md index fc343379..f64ae903 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/decisions/0001-project-plugin-hooks.md b/docs/decisions/0001-project-plugin-hooks.md new file mode 100644 index 00000000..b89e3a81 --- /dev/null +++ b/docs/decisions/0001-project-plugin-hooks.md @@ -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. diff --git a/docs/src/content/docs/docs/guides/plugins.mdx b/docs/src/content/docs/docs/guides/plugins.mdx index cb6050d6..111259c7 100644 --- a/docs/src/content/docs/docs/guides/plugins.mdx +++ b/docs/src/content/docs/docs/guides/plugins.mdx @@ -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 @@ -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 diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index 78d11721..f942ae7d 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -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 diff --git a/src/core/copilot-hooks.ts b/src/core/copilot-hooks.ts new file mode 100644 index 00000000..99734677 --- /dev/null +++ b/src/core/copilot-hooks.ts @@ -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; + +interface CopilotHooksFile { + version: 1; + hooks: Record; +} + +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 = {}; + 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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, + }; +} diff --git a/src/core/sync.ts b/src/core/sync.ts index a1a5d254..24636505 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -101,6 +101,10 @@ 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 { + COPILOT_MANAGED_HOOKS_RELATIVE_PATH, + syncCopilotProjectHooks, +} from './copilot-hooks.js'; import { syncClaudeMcpConfig, syncClaudeMcpServersViaCli, @@ -2248,10 +2252,27 @@ export async function syncWorkspace( ); warnings.push(...codexHookSync.warnings); + // Step 4d: Materialize Copilot plugin hook declarations as a repository hook + // file. Copilot discovers project hooks only from .github/hooks/*.json; + // copying a plugin's hook scripts there does not activate its root hooks.json. + const copilotHookSync = await sw.measure('copilot-hooks-sync', () => + syncCopilotProjectHooks(validPlugins, workspacePath, { + dryRun, + previouslyManaged: getPreviouslySyncedFiles( + previousState, + 'copilot', + ).includes(COPILOT_MANAGED_HOOKS_RELATIVE_PATH), + }), + ); + warnings.push(...copilotHookSync.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) - const workspaceFileResults: CopyResult[] = [...codexHookSync.copyResults]; + const workspaceFileResults: CopyResult[] = [ + ...codexHookSync.copyResults, + ...copilotHookSync.copyResults, + ]; let writtenSkillsIndexFiles: string[] = []; const skipWorkspaceFiles = !!config.workspace?.source && !validatedWorkspaceSource; diff --git a/src/core/transform.ts b/src/core/transform.ts index 10269424..77974d17 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -105,7 +105,7 @@ export interface CopyOptions { /** * Check if a file path (relative to plugin root) matches any exclude pattern. */ -function isExcluded( +export function isExcluded( pluginPath: string, filePath: string, exclude?: string[], @@ -629,6 +629,16 @@ export async function copyHooks( const destDir = join(workspacePath, mapping.hooksPath); + // hooks/hooks.json is a plugin declaration, not a repository hook payload. + // Project Copilot sync materializes it separately with COPILOT_PLUGIN_ROOT + // bound to the plugin installation path; copying it verbatim would register + // the same hooks twice and leave the plugin-root variable unresolved. + const effectiveExclude = + mapping.hooksPath === '.github/hooks/' && + existsSync(join(sourceDir, 'hooks.json')) + ? [...(options.exclude ?? []), 'hooks/hooks.json'] + : options.exclude; + if (dryRun) { results.push({ source: sourceDir, destination: destDir, action: 'copied' }); return results; @@ -637,12 +647,12 @@ export async function copyHooks( await mkdir(destDir, { recursive: true }); try { - if (options.exclude && options.exclude.length > 0) { + if (effectiveExclude && effectiveExclude.length > 0) { await copyDirectoryWithExclusions( sourceDir, destDir, pluginPath, - options.exclude, + effectiveExclude, ); } else { await cp(sourceDir, destDir, { recursive: true }); @@ -746,11 +756,21 @@ function githubContentExcludes( mapping: ClientMapping, exclude?: string[], ): string[] | undefined { - if (!relocatesGitHubContent(mapping)) return exclude; + const effectiveExclude = [...(exclude ?? [])]; + + // Copilot plugin package metadata is used to discover a native plugin, but + // it has no runtime role after file-mode content is overlaid into a project. + if (!relocatesGitHubContent(mapping)) { + effectiveExclude.push('.github/plugin'); + } // .github/hooks is repository-owned. Root hooks/ remains the portable // plugin artifact that can be installed into a client's user hook path. - return [...(exclude ?? []), '.github/hooks']; + if (relocatesGitHubContent(mapping)) { + effectiveExclude.push('.github/hooks'); + } + + return effectiveExclude.length > 0 ? effectiveExclude : undefined; } export interface RelocatedGitHubHooksSource { diff --git a/tests/unit/core/copilot-hooks.test.ts b/tests/unit/core/copilot-hooks.test.ts new file mode 100644 index 00000000..3dd1c236 --- /dev/null +++ b/tests/unit/core/copilot-hooks.test.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; +import { syncWorkspace } from '../../../src/core/sync.js'; + +describe('syncWorkspace Copilot hooks', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-sync-copilot-hooks-')); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + async function writeWorkspace(plugins: string[]): Promise { + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +${plugins.length > 0 ? `plugins:\n${plugins.map((plugin) => ` - ./${plugin}`).join('\n')}` : 'plugins: []'} +clients: + - copilot +syncMode: copy +`, + ); + } + + async function writePlugin( + name: string, + event: string, + options: { nestedDeclaration?: boolean } = {}, + ): Promise { + const pluginDir = join(testDir, name); + await mkdir(join(pluginDir, 'hooks'), { recursive: true }); + await writeFile(join(pluginDir, 'hooks', `${name}.sh`), '#!/bin/sh\n'); + const declarationPath = options.nestedDeclaration + ? join(pluginDir, 'hooks', 'hooks.json') + : join(pluginDir, 'hooks.json'); + await writeFile( + declarationPath, + JSON.stringify({ + version: 1, + hooks: { + [event]: [ + { + type: 'command', + bash: `bash "\${COPILOT_PLUGIN_ROOT}/hooks/${name}.sh"`, + }, + ], + }, + }), + ); + return pluginDir; + } + + it('materializes root plugin hook declarations as executable repository hooks', async () => { + const pluginDir = join(testDir, 'org'); + await mkdir(join(pluginDir, 'hooks'), { recursive: true }); + await writeFile(join(pluginDir, 'hooks', 'session-start.sh'), '#!/bin/sh\n'); + await writeFile( + join(pluginDir, 'hooks.json'), + JSON.stringify({ + version: 1, + hooks: { + SessionStart: [ + { + type: 'command', + bash: 'bash "${COPILOT_PLUGIN_ROOT}/hooks/session-start.sh"', + }, + ], + }, + }), + ); + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - ./org +clients: + - copilot +syncMode: copy +`, + ); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + const hooks = JSON.parse( + await readFile(join(testDir, '.github', 'hooks', 'allagents.json'), 'utf-8'), + ) as { + hooks: Record }>>; + }; + expect(hooks.hooks.SessionStart).toHaveLength(1); + expect(hooks.hooks.SessionStart?.[0]?.env?.COPILOT_PLUGIN_ROOT).toBe(pluginDir); + }); + + it('syncs a direct manifest-less plugin that has no hook declaration', async () => { + await mkdir(join(testDir, 'direct-plugin', 'skills', 'direct-skill'), { + recursive: true, + }); + await writeFile( + join(testDir, 'direct-plugin', 'skills', 'direct-skill', 'SKILL.md'), + '---\nname: direct-skill\ndescription: Direct plugin skill\n---\n# Direct skill\n', + ); + await writeWorkspace(['direct-plugin']); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect( + existsSync(join(testDir, '.github', 'skills', 'direct-skill', 'SKILL.md')), + ).toBe(true); + expect(existsSync(join(testDir, '.github', 'hooks', 'allagents.json'))).toBe( + false, + ); + expect( + (result.warnings ?? []).some((warning) => warning.startsWith('Copilot hooks:')), + ).toBe(false); + }); + + it('merges multiple plugins and reconciles only the AllAgents-owned hook file', async () => { + const pluginA = await writePlugin('plugin-a', 'SessionStart'); + const pluginB = await writePlugin('plugin-b', 'PostToolUse'); + await writeWorkspace(['plugin-a', 'plugin-b']); + await mkdir(join(testDir, '.github', 'hooks'), { recursive: true }); + await writeFile( + join(testDir, '.github', 'hooks', 'user.json'), + '{"version":1,"hooks":{}}', + ); + + const first = await syncWorkspace(testDir); + + expect(first.success).toBe(true); + let hooks = JSON.parse( + await readFile(join(testDir, '.github', 'hooks', 'allagents.json'), 'utf-8'), + ) as { + hooks: Record }>>; + }; + expect(hooks.hooks.SessionStart?.[0]?.env.COPILOT_PLUGIN_ROOT).toBe(pluginA); + expect(hooks.hooks.PostToolUse?.[0]?.env.COPILOT_PLUGIN_ROOT).toBe(pluginB); + expect(existsSync(join(testDir, '.github', 'hooks', 'user.json'))).toBe(true); + + await writeWorkspace(['plugin-b']); + const second = await syncWorkspace(testDir); + + expect(second.success).toBe(true); + hooks = JSON.parse( + await readFile(join(testDir, '.github', 'hooks', 'allagents.json'), 'utf-8'), + ) as typeof hooks; + expect(hooks.hooks.SessionStart).toBeUndefined(); + expect(hooks.hooks.PostToolUse).toHaveLength(1); + expect(existsSync(join(testDir, '.github', 'hooks', 'user.json'))).toBe(true); + + await writeWorkspace([]); + const third = await syncWorkspace(testDir); + + expect(third.success).toBe(true); + expect(existsSync(join(testDir, '.github', 'hooks', 'allagents.json'))).toBe( + false, + ); + expect(existsSync(join(testDir, '.github', 'hooks', 'user.json'))).toBe(true); + }); + + it('skips an entire hook declaration when any event is not an array', async () => { + const invalidPlugin = await writePlugin('invalid-plugin', 'SessionStart'); + const validPlugin = await writePlugin('valid-plugin', 'PostToolUse'); + await mkdir(join(invalidPlugin, 'skills', 'invalid-plugin-skill'), { + recursive: true, + }); + await writeFile( + join(invalidPlugin, 'skills', 'invalid-plugin-skill', 'SKILL.md'), + '---\nname: invalid-plugin-skill\ndescription: Still syncs\n---\n# Skill\n', + ); + await writeFile( + join(invalidPlugin, 'hooks.json'), + JSON.stringify({ + version: 1, + hooks: { + SessionStart: [ + { + type: 'command', + bash: 'bash "${COPILOT_PLUGIN_ROOT}/hooks/invalid-plugin.sh"', + }, + ], + UserPromptSubmit: { type: 'command', bash: 'echo invalid' }, + }, + }), + ); + await writeWorkspace(['invalid-plugin', 'valid-plugin']); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect(result.warnings).toContain( + `Copilot hooks: event 'UserPromptSubmit' in ${join(invalidPlugin, 'hooks.json')} must be an array`, + ); + const hooks = JSON.parse( + await readFile(join(testDir, '.github', 'hooks', 'allagents.json'), 'utf-8'), + ) as { + hooks: Record }>>; + }; + expect(hooks.hooks.SessionStart).toBeUndefined(); + expect(hooks.hooks.PostToolUse).toHaveLength(1); + expect(hooks.hooks.PostToolUse?.[0]?.env.COPILOT_PLUGIN_ROOT).toBe(validPlugin); + expect( + existsSync( + join(testDir, '.github', 'skills', 'invalid-plugin-skill', 'SKILL.md'), + ), + ).toBe(true); + }); + + it('does not overwrite an existing unowned allagents hook file', async () => { + await writePlugin('plugin-a', 'SessionStart'); + await writeWorkspace(['plugin-a']); + await mkdir(join(testDir, '.github', 'hooks'), { recursive: true }); + const original = '{"version":1,"hooks":{"SessionStart":[]}}'; + await writeFile(join(testDir, '.github', 'hooks', 'allagents.json'), original); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect(result.warnings).toContain( + 'Copilot hooks: not updating .github/hooks/allagents.json because the existing file is not owned by AllAgents', + ); + expect( + await readFile(join(testDir, '.github', 'hooks', 'allagents.json'), 'utf-8'), + ).toBe(original); + }); + + it('materializes hooks/hooks.json once instead of copying an unresolved duplicate', async () => { + await writePlugin('plugin-a', 'SessionStart', { nestedDeclaration: true }); + await writeWorkspace(['plugin-a']); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect(existsSync(join(testDir, '.github', 'hooks', 'allagents.json'))).toBe( + true, + ); + expect(existsSync(join(testDir, '.github', 'hooks', 'hooks.json'))).toBe( + false, + ); + }); + + it('does not activate a root declaration when its hook payload is excluded', async () => { + await writePlugin('plugin-a', 'SessionStart'); + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - source: ./plugin-a + exclude: + - hooks/plugin-a.sh +clients: + - copilot +syncMode: copy +`, + ); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect(existsSync(join(testDir, '.github', 'hooks', 'allagents.json'))).toBe( + false, + ); + expect(existsSync(join(testDir, '.github', 'hooks', 'plugin-a.sh'))).toBe( + false, + ); + }); + + it('does not activate an excluded nested declaration', async () => { + await writePlugin('plugin-a', 'SessionStart', { nestedDeclaration: true }); + await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); + await writeFile( + join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), + ` +repositories: [] +plugins: + - source: ./plugin-a + exclude: + - hooks/hooks.json +clients: + - copilot +syncMode: copy +`, + ); + + const result = await syncWorkspace(testDir); + + expect(result.success).toBe(true); + expect(existsSync(join(testDir, '.github', 'hooks', 'allagents.json'))).toBe( + false, + ); + }); +}); diff --git a/tests/unit/core/github-content.test.ts b/tests/unit/core/github-content.test.ts index 535f46fb..e480ee14 100644 --- a/tests/unit/core/github-content.test.ts +++ b/tests/unit/core/github-content.test.ts @@ -67,6 +67,26 @@ describe('copyGitHubContent', () => { expect(content).toBe('{"hooks":[]}'); }); + it('does not copy Copilot package metadata into the project overlay', async () => { + await mkdir(join(pluginDir, '.github', 'plugin'), { recursive: true }); + await mkdir(join(pluginDir, '.github', 'prompts'), { recursive: true }); + await writeFile( + join(pluginDir, '.github', 'plugin', 'plugin.json'), + '{"name":"package-only"}', + ); + await writeFile(join(pluginDir, '.github', 'prompts', 'keep.md'), '# Keep'); + + const results = await copyGitHubContent(pluginDir, workspaceDir, 'copilot'); + + expect(results).toHaveLength(1); + expect( + existsSync(join(workspaceDir, '.github', 'plugin', 'plugin.json')), + ).toBe(false); + expect(existsSync(join(workspaceDir, '.github', 'prompts', 'keep.md'))).toBe( + true, + ); + }); + it('skips clients without githubPath', async () => { await mkdir(join(pluginDir, '.github', 'prompts'), { recursive: true }); await writeFile(join(pluginDir, '.github', 'prompts', 'test.md'), '# Test'); From e509233a910525a181d9bbcc3a7543883c5379ec Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 30 Jul 2026 13:27:50 +1000 Subject: [PATCH 6/8] fix(plugin): update project-scoped marketplaces (#446) --- src/cli/commands/plugin.ts | 83 +++++++------ src/cli/tui/actions/plugins.ts | 20 +-- tests/e2e/plugin-update.test.ts | 208 ++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 44 deletions(-) create mode 100644 tests/e2e/plugin-update.test.ts diff --git a/src/cli/commands/plugin.ts b/src/cli/commands/plugin.ts index e4289178..d5303fac 100644 --- a/src/cli/commands/plugin.ts +++ b/src/cli/commands/plugin.ts @@ -1307,22 +1307,26 @@ const pluginUpdateCmd = command({ const updateProject = scope === 'project' || (!scope && !updateAll) || updateAll; // Collect installed plugins based on scope - const pluginsToUpdate: string[] = []; + const pluginsToUpdate: Array<{ spec: string; scope: 'project' | 'user' }> = []; + const addPluginToUpdate = (spec: string, pluginScope: 'project' | 'user') => { + if (!pluginsToUpdate.some((entry) => + entry.spec === spec && entry.scope === pluginScope + )) { + pluginsToUpdate.push({ spec, scope: pluginScope }); + } + }; if (updateProject && !isUserConfigPath(process.cwd())) { const projectPlugins = await getInstalledProjectPlugins(process.cwd()); for (const p of projectPlugins) { - pluginsToUpdate.push(p.spec); + addPluginToUpdate(p.spec, 'project'); } } if (updateUser) { const userPlugins = await getInstalledUserPlugins(); for (const p of userPlugins) { - // Avoid duplicates if same plugin is in both scopes - if (!pluginsToUpdate.includes(p.spec)) { - pluginsToUpdate.push(p.spec); - } + addPluginToUpdate(p.spec, 'user'); } } @@ -1339,9 +1343,7 @@ const pluginUpdateCmd = command({ const config = load(content) as { plugins?: PluginEntry[] }; for (const entry of config.plugins ?? []) { const p = getPluginSource(entry); - if (!pluginsToUpdate.includes(p)) { - pluginsToUpdate.push(p); - } + addPluginToUpdate(p, 'project'); } } } @@ -1351,20 +1353,18 @@ const pluginUpdateCmd = command({ if (userConfig) { for (const entry of userConfig.plugins ?? []) { const p = getPluginSource(entry); - if (!pluginsToUpdate.includes(p)) { - pluginsToUpdate.push(p); - } + addPluginToUpdate(p, 'user'); } } } // Filter to specific plugin if provided const toUpdate = plugin - ? pluginsToUpdate.filter((p) => { + ? pluginsToUpdate.filter(({ spec }) => { // Match by full spec or just plugin name - if (p === plugin) return true; - const parsed = parsePluginSpec(p); - return parsed?.plugin === plugin || p.endsWith(`/${plugin}`); + if (spec === plugin) return true; + const parsed = parsePluginSpec(spec); + return parsed?.plugin === plugin || spec.endsWith(`/${plugin}`); }) : pluginsToUpdate; @@ -1398,28 +1398,39 @@ const pluginUpdateCmd = command({ // Update each plugin const results: InstalledPluginUpdateResult[] = []; - const updatedMarketplaces = new Set(); - - // Dependencies for updatePlugin (avoid circular imports) - const deps = { - parsePluginSpec, - getMarketplace: (name: string, sourceLocation?: string) => findMarketplace(name, sourceLocation), - parseMarketplaceManifest, - updateMarketplace: async (name: string) => { - // Skip if already updated in this run - if (updatedMarketplaces.has(name)) { - return [{ name, success: true }]; - } - const result = await updateMarketplace(name); - if (result[0]?.success) { - updatedMarketplaces.add(name); - } - return result; - }, + const updatedMarketplaces = { + project: new Set(), + user: new Set(), + }; + const createUpdateDeps = (pluginScope: 'project' | 'user') => { + const workspacePath = pluginScope === 'project' ? process.cwd() : undefined; + const updatedForScope = updatedMarketplaces[pluginScope]; + + return { + parsePluginSpec, + getMarketplace: (name: string, sourceLocation?: string) => + findMarketplace(name, sourceLocation, workspacePath), + parseMarketplaceManifest, + updateMarketplace: async (name: string) => { + // Skip if already updated in this scope during this run + if (updatedForScope.has(name)) { + return [{ name, success: true }]; + } + const result = await updateMarketplace(name, workspacePath); + if (result[0]?.success) { + updatedForScope.add(name); + } + return result; + }, + }; + }; + const depsByScope = { + project: createUpdateDeps('project'), + user: createUpdateDeps('user'), }; - for (const pluginSpec of toUpdate) { - const result = await updatePlugin(pluginSpec, deps); + for (const { spec: pluginSpec, scope: pluginScope } of toUpdate) { + const result = await updatePlugin(pluginSpec, depsByScope[pluginScope]); results.push(result); if (!isJsonMode()) { diff --git a/src/cli/tui/actions/plugins.ts b/src/cli/tui/actions/plugins.ts index 02872bf0..126be32c 100644 --- a/src/cli/tui/actions/plugins.ts +++ b/src/cli/tui/actions/plugins.ts @@ -38,17 +38,18 @@ const { select, text, confirm, multiselect, autocomplete } = p; * Create dependencies for updatePlugin. * Tracks which marketplaces have been updated to avoid redundant fetches. */ -function createUpdateDeps() { +function createUpdateDeps(workspacePath?: string) { const updatedMarketplaces = new Set(); return { parsePluginSpec, - getMarketplace: (name: string, sourceLocation?: string) => findMarketplace(name, sourceLocation), + getMarketplace: (name: string, sourceLocation?: string) => + findMarketplace(name, sourceLocation, workspacePath), parseMarketplaceManifest, updateMarketplace: async (name: string) => { if (updatedMarketplaces.has(name)) { return [{ name, success: true }]; } - const result = await updateMarketplace(name); + const result = await updateMarketplace(name, workspacePath); if (result[0]?.success) { updatedMarketplaces.add(name); } @@ -156,7 +157,8 @@ async function runUpdatePlugin( const s = p.spinner(); s.start('Updating plugin...'); - const result = await updatePlugin(pluginSource, createUpdateDeps()); + const workspacePath = scope === 'project' ? context.workspacePath ?? undefined : undefined; + const result = await updatePlugin(pluginSource, createUpdateDeps(workspacePath)); if (!result.success) { s.stop('Update failed'); @@ -205,8 +207,9 @@ export async function runUpdateAllPlugins( const userPlugins = await getInstalledUserPlugins(); for (const plugin of userPlugins) { - // Avoid duplicates - if (!pluginsToUpdate.some((existing) => existing.spec === plugin.spec)) { + if (!pluginsToUpdate.some((existing) => + existing.spec === plugin.spec && existing.scope === 'user' + )) { pluginsToUpdate.push({ spec: plugin.spec, scope: 'user' }); } } @@ -218,14 +221,15 @@ export async function runUpdateAllPlugins( s.message(`Updating ${pluginsToUpdate.length} plugin(s)...`); - const deps = createUpdateDeps(); + const projectDeps = createUpdateDeps(context.workspacePath ?? undefined); + const userDeps = createUpdateDeps(); const results: Array<{ plugin: string; action: string; error?: string }> = []; let needsProjectSync = false; let needsUserSync = false; for (const { spec, scope } of pluginsToUpdate) { - const result = await updatePlugin(spec, deps); + const result = await updatePlugin(spec, scope === 'project' ? projectDeps : userDeps); const entry: { plugin: string; action: string; error?: string } = { plugin: spec, action: result.action, diff --git a/tests/e2e/plugin-update.test.ts b/tests/e2e/plugin-update.test.ts new file mode 100644 index 00000000..34ae9329 --- /dev/null +++ b/tests/e2e/plugin-update.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface CliResult { + exitCode: number; + stdout: string; + stderr: string; +} + +function runCli(workdir: string, homeDir: string, args: string[]): CliResult { + const cliEntry = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts'); + const proc = Bun.spawnSync(['bun', 'run', cliEntry, '--json', ...args], { + cwd: workdir, + env: { + ...process.env, + ALLAGENTS_TEST_HOME: homeDir, + HOME: homeDir, + }, + stderr: 'pipe', + stdout: 'pipe', + }); + + return { + exitCode: proc.exitCode, + stdout: new TextDecoder().decode(proc.stdout), + stderr: new TextDecoder().decode(proc.stderr), + }; +} + +describe('plugin update e2e', () => { + let rootDir: string; + let workspaceDir: string; + let marketplaceDir: string; + let homeDir: string; + + beforeEach(() => { + rootDir = join( + tmpdir(), + `allagents-e2e-plugin-update-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + workspaceDir = join(rootDir, 'workspace'); + marketplaceDir = join(rootDir, 'marketplace'); + homeDir = join(rootDir, 'home'); + + mkdirSync(join(workspaceDir, '.allagents'), { recursive: true }); + mkdirSync(join(marketplaceDir, '.claude-plugin'), { recursive: true }); + mkdirSync(join(marketplaceDir, 'plugins', 'demo', 'skills', 'demo'), { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients:\n - claude\nversion: 2\n', + 'utf-8', + ); + writeFileSync( + join(marketplaceDir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'project-marketplace', + description: 'Project marketplace update fixture', + plugins: [ + { + name: 'demo', + description: 'Demo plugin', + source: './plugins/demo', + }, + ], + }), + 'utf-8', + ); + writeFileSync( + join(marketplaceDir, 'plugins', 'demo', 'skills', 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: Demo skill\n---\n# Demo\n', + 'utf-8', + ); + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + }); + + test('updates a plugin from a project-scoped marketplace', () => { + const addResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'marketplace', + 'add', + marketplaceDir, + '--scope', + 'project', + ]); + expect(addResult.exitCode).toBe(0); + + const installResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'install', + 'demo@project-marketplace', + '--scope', + 'project', + ]); + expect(installResult.exitCode).toBe(0); + + const updateResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'update', + 'demo@project-marketplace', + '--scope', + 'project', + ]); + + expect(updateResult.exitCode).toBe(0); + const payload = JSON.parse(updateResult.stdout); + expect(payload.success).toBe(true); + expect(payload.data.results).toEqual([ + { + plugin: 'demo@project-marketplace', + success: true, + action: 'updated', + }, + ]); + }); + + test('keeps user-scoped marketplace updates isolated from the workspace', () => { + const addResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'marketplace', + 'add', + marketplaceDir, + '--scope', + 'user', + ]); + expect(addResult.exitCode).toBe(0); + + const installResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'install', + 'demo@project-marketplace', + '--scope', + 'user', + ]); + expect(installResult.exitCode).toBe(0); + + const updateResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'update', + 'demo@project-marketplace', + '--scope', + 'user', + ]); + + expect(updateResult.exitCode).toBe(0); + const payload = JSON.parse(updateResult.stdout); + expect(payload.success).toBe(true); + expect(payload.data.results[0]).toEqual({ + plugin: 'demo@project-marketplace', + success: true, + action: 'updated', + }); + }); + + test('updates the same plugin independently when installed in both scopes', () => { + for (const scope of ['user', 'project']) { + const addResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'marketplace', + 'add', + marketplaceDir, + '--scope', + scope, + ]); + expect(addResult.exitCode).toBe(0); + + const installResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'install', + 'demo@project-marketplace', + '--scope', + scope, + ]); + expect(installResult.exitCode).toBe(0); + } + + const updateResult = runCli(workspaceDir, homeDir, [ + 'plugin', + 'update', + 'demo@project-marketplace', + '--scope', + 'all', + ]); + + expect(updateResult.exitCode).toBe(0); + const payload = JSON.parse(updateResult.stdout); + expect(payload.success).toBe(true); + expect(payload.data.results).toHaveLength(2); + expect(payload.data.results).toEqual([ + { + plugin: 'demo@project-marketplace', + success: true, + action: 'updated', + }, + { + plugin: 'demo@project-marketplace', + success: true, + action: 'updated', + }, + ]); + }); +}); From b2d01e5f1911550a45e6eace3a59845a1e0f6b24 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Jul 2026 03:34:26 +0000 Subject: [PATCH 7/8] chore(release): bump version to 1.13.4-next.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd5c86ea..e2a862a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "allagents", - "version": "1.13.3", + "version": "1.13.4-next.1", "packageManager": "bun@1.3.12", "description": "CLI tool for managing multi-repo AI agent workspaces with plugin synchronization", "type": "module", From 046f26c450bc4d451c95034b4bac82bf4677419a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Jul 2026 06:14:05 +0000 Subject: [PATCH 8/8] chore(release): bump version to 1.13.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e2a862a6..fab4613f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "allagents", - "version": "1.13.4-next.1", + "version": "1.13.4", "packageManager": "bun@1.3.12", "description": "CLI tool for managing multi-repo AI agent workspaces with plugin synchronization", "type": "module",