From 719a4be3541b782e8ff01f5921d438c51a42c081 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 12 Feb 2026 07:11:18 +0000 Subject: [PATCH] fix(sync): copy .github folder content for copilot client Add support for copying .github folder content (prompts, copilot-instructions.md) from plugins to workspace when the copilot client is configured. - Add githubPath to ClientMapping interface - Configure githubPath: '.github/' for copilot client - Add copyGitHubContent function in transform.ts - Include GitHub content copying in copyPluginToWorkspace Closes #122 --- src/core/transform.ts | 61 ++++++++++++++++++++-- src/models/client-mapping.ts | 4 ++ tests/unit/core/github-content.test.ts | 70 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/unit/core/github-content.test.ts diff --git a/src/core/transform.ts b/src/core/transform.ts index c1b9841..1cf4060 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -466,6 +466,60 @@ export async function copyAgents( return Promise.all(copyPromises); } +/** + * Copy GitHub-specific content from plugin to workspace + * This includes prompts (.github/prompts/), copilot-instructions.md, and other GitHub Copilot files + * @param pluginPath - Path to plugin directory + * @param workspacePath - Path to workspace directory + * @param client - Target client type + * @param options - Copy options (dryRun) + * @returns Array of copy results + */ +export async function copyGitHubContent( + pluginPath: string, + workspacePath: string, + client: ClientType, + options: CopyOptions = {}, +): Promise { + const { dryRun = false } = options; + const mapping = getMapping(client, options); + const results: CopyResult[] = []; + + // Skip if client doesn't support GitHub content + if (!mapping.githubPath) { + return results; + } + + const sourceDir = join(pluginPath, '.github'); + if (!existsSync(sourceDir)) { + return results; + } + + const destDir = join(workspacePath, mapping.githubPath); + + if (dryRun) { + results.push({ source: sourceDir, destination: destDir, action: 'copied' }); + return results; + } + + await mkdir(destDir, { recursive: true }); + + try { + // Copy entire .github directory contents recursively + await cp(sourceDir, destDir, { recursive: true }); + results.push({ source: sourceDir, destination: destDir, action: 'copied' }); + } catch (error) { + results.push({ + source: sourceDir, + destination: destDir, + action: 'failed', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + + return results; +} + /** * Options for copying a plugin to workspace */ @@ -490,7 +544,7 @@ export interface PluginCopyOptions extends CopyOptions { /** * Copy all plugin content to workspace for a specific client - * Plugins provide: commands, skills, hooks, agents + * Plugins provide: commands, skills, hooks, agents, and GitHub-specific content * @param pluginPath - Path to plugin directory * @param workspacePath - Path to workspace directory * @param client - Target client type @@ -506,7 +560,7 @@ export async function copyPluginToWorkspace( const { skillNameMap, syncMode, canonicalSkillsPath, ...baseOptions } = options; // Run copy operations in parallel for better performance - const [commandResults, skillResults, hookResults, agentResults] = await Promise.all([ + const [commandResults, skillResults, hookResults, agentResults, githubResults] = await Promise.all([ copyCommands(pluginPath, workspacePath, client, baseOptions), copySkills(pluginPath, workspacePath, client, { ...baseOptions, @@ -516,9 +570,10 @@ export async function copyPluginToWorkspace( }), copyHooks(pluginPath, workspacePath, client, baseOptions), copyAgents(pluginPath, workspacePath, client, baseOptions), + copyGitHubContent(pluginPath, workspacePath, client, baseOptions), ]); - return [...commandResults, ...skillResults, ...hookResults, ...agentResults]; + return [...commandResults, ...skillResults, ...hookResults, ...agentResults, ...githubResults]; } /** diff --git a/src/models/client-mapping.ts b/src/models/client-mapping.ts index c0c3952..f3fbd5a 100644 --- a/src/models/client-mapping.ts +++ b/src/models/client-mapping.ts @@ -11,6 +11,8 @@ export interface ClientMapping { agentFile: string; agentFileFallback?: string; hooksPath?: string; + /** Path for GitHub-specific content (prompts, copilot-instructions.md) */ + githubPath?: string; } /** @@ -32,6 +34,7 @@ export const CLIENT_MAPPINGS: Record = { copilot: { skillsPath: '.agents/skills/', agentFile: 'AGENTS.md', + githubPath: '.github/', }, codex: { skillsPath: '.agents/skills/', @@ -101,6 +104,7 @@ export const USER_CLIENT_MAPPINGS: Record = { copilot: { skillsPath: '.agents/skills/', agentFile: 'AGENTS.md', + githubPath: '.github/', }, codex: { skillsPath: '.agents/skills/', diff --git a/tests/unit/core/github-content.test.ts b/tests/unit/core/github-content.test.ts new file mode 100644 index 0000000..bea2dee --- /dev/null +++ b/tests/unit/core/github-content.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { copyGitHubContent } from '../../../src/core/transform.js'; + +describe('copyGitHubContent', () => { + let testDir: string; + let pluginDir: string; + let workspaceDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'allagents-github-test-')); + pluginDir = join(testDir, 'plugin'); + workspaceDir = join(testDir, 'workspace'); + await mkdir(pluginDir, { recursive: true }); + await mkdir(workspaceDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('copies .github folder for copilot client', async () => { + // Setup: Create .github folder with prompts in plugin + await mkdir(join(pluginDir, '.github', 'prompts'), { recursive: true }); + await writeFile(join(pluginDir, '.github', 'prompts', 'test.md'), '# Test Prompt'); + await writeFile(join(pluginDir, '.github', 'copilot-instructions.md'), '# Copilot Instructions'); + + const results = await copyGitHubContent(pluginDir, workspaceDir, 'copilot'); + + expect(results).toHaveLength(1); + expect(results[0].action).toBe('copied'); + expect(existsSync(join(workspaceDir, '.github', 'prompts', 'test.md'))).toBe(true); + expect(existsSync(join(workspaceDir, '.github', 'copilot-instructions.md'))).toBe(true); + + const content = await readFile(join(workspaceDir, '.github', 'prompts', 'test.md'), 'utf-8'); + expect(content).toBe('# Test Prompt'); + }); + + it('skips clients without githubPath', async () => { + await mkdir(join(pluginDir, '.github', 'prompts'), { recursive: true }); + await writeFile(join(pluginDir, '.github', 'prompts', 'test.md'), '# Test'); + + // Claude doesn't have githubPath, so should skip + const results = await copyGitHubContent(pluginDir, workspaceDir, 'claude'); + + expect(results).toHaveLength(0); + expect(existsSync(join(workspaceDir, '.github'))).toBe(false); + }); + + it('returns empty when plugin has no .github folder', async () => { + const results = await copyGitHubContent(pluginDir, workspaceDir, 'copilot'); + + expect(results).toHaveLength(0); + }); + + it('supports dry run mode', async () => { + await mkdir(join(pluginDir, '.github', 'prompts'), { recursive: true }); + await writeFile(join(pluginDir, '.github', 'prompts', 'test.md'), '# Test'); + + const results = await copyGitHubContent(pluginDir, workspaceDir, 'copilot', { dryRun: true }); + + expect(results).toHaveLength(1); + expect(results[0].action).toBe('copied'); + // Should not actually copy in dry run + expect(existsSync(join(workspaceDir, '.github'))).toBe(false); + }); +});