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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CopyResult[]> {
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
*/
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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];
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/models/client-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface ClientMapping {
agentFile: string;
agentFileFallback?: string;
hooksPath?: string;
/** Path for GitHub-specific content (prompts, copilot-instructions.md) */
githubPath?: string;
}

/**
Expand All @@ -32,6 +34,7 @@ export const CLIENT_MAPPINGS: Record<ClientType, ClientMapping> = {
copilot: {
skillsPath: '.agents/skills/',
agentFile: 'AGENTS.md',
githubPath: '.github/',
},
codex: {
skillsPath: '.agents/skills/',
Expand Down Expand Up @@ -101,6 +104,7 @@ export const USER_CLIENT_MAPPINGS: Record<ClientType, ClientMapping> = {
copilot: {
skillsPath: '.agents/skills/',
agentFile: 'AGENTS.md',
githubPath: '.github/',
},
codex: {
skillsPath: '.agents/skills/',
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/core/github-content.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});