diff --git a/apps/mcp-server/src/cli/cli.spec.ts b/apps/mcp-server/src/cli/cli.spec.ts index cb652788..3fe55ddb 100644 --- a/apps/mcp-server/src/cli/cli.spec.ts +++ b/apps/mcp-server/src/cli/cli.spec.ts @@ -129,6 +129,27 @@ describe('cli', () => { expect(result.options.uninstallYes).toBe(true); }); + it('should parse create-plugin command with name', () => { + const result = parseArgs(['create-plugin', 'my-plugin']); + + expect(result.command).toBe('create-plugin'); + expect(result.options.createPluginName).toBe('my-plugin'); + }); + + it('should parse create-plugin command with --template flag', () => { + const result = parseArgs(['create-plugin', 'my-plugin', '--template', 'full']); + + expect(result.command).toBe('create-plugin'); + expect(result.options.createPluginName).toBe('my-plugin'); + expect(result.options.createPluginTemplate).toBe('full'); + }); + + it('should parse create-plugin without template as undefined', () => { + const result = parseArgs(['create-plugin', 'my-plugin']); + + expect(result.options.createPluginTemplate).toBeUndefined(); + }); + it('should parse init with --yes flag', () => { const result = parseArgs(['init', '--yes']); diff --git a/apps/mcp-server/src/cli/cli.ts b/apps/mcp-server/src/cli/cli.ts index df95801b..b3237bb4 100644 --- a/apps/mcp-server/src/cli/cli.ts +++ b/apps/mcp-server/src/cli/cli.ts @@ -15,6 +15,7 @@ import type { UninstallOptions, SearchOptions, UpdateOptions, + CreatePluginOptions, } from './cli.types'; import type { CompletionOptions } from './completion'; @@ -29,6 +30,7 @@ export interface ParsedArgs { | 'uninstall' | 'search' | 'update' + | 'create-plugin' | 'mcp' | 'tui' | 'completion' @@ -40,6 +42,7 @@ export interface ParsedArgs { Partial & Partial & Partial & + Partial & Partial; } @@ -112,6 +115,19 @@ export function parseArgs(args: string[]): ParsedArgs { }; } + if (command === 'create-plugin') { + const createPluginName = args[1]; + let createPluginTemplate: string | undefined; + const templateIdx = args.indexOf('--template'); + if (templateIdx !== -1) { + createPluginTemplate = args[templateIdx + 1]; + } + return { + command: 'create-plugin', + options: { ...options, createPluginName, createPluginTemplate }, + }; + } + if (command === 'completion') { const shell = args[1]; return { @@ -160,6 +176,7 @@ Usage: codingbuddy mcp Start MCP server (stdio mode) codingbuddy tui Monitor agent execution in real-time codingbuddy tui --restart Restart TUI client (fixes blank screen) + codingbuddy create-plugin Scaffold a new plugin project codingbuddy completion Generate shell completions (bash, zsh, fish) codingbuddy --help Show this help codingbuddy --version Show version @@ -180,6 +197,8 @@ Examples: codingbuddy update my-plugin Update a specific plugin codingbuddy uninstall my-plugin Remove a plugin codingbuddy uninstall my-plugin -y Remove without confirmation + codingbuddy create-plugin my-plugin Scaffold minimal plugin + codingbuddy create-plugin my-plugin --template full Scaffold with examples codingbuddy mcp Start MCP server for AI assistants Note: @@ -324,6 +343,27 @@ export async function main(args: string[] = process.argv.slice(2)): Promise [--template minimal|full]\n', + ); + process.exitCode = 1; + break; + } + const template = options.createPluginTemplate === 'full' ? 'full' : 'minimal'; + const createResult = await runCreatePlugin({ + name: options.createPluginName, + outputDir: process.cwd(), + template, + }); + if (!createResult.success) { + process.exitCode = 1; + } + break; + } + case 'completion': { const { runCompletion } = await import('./completion'); if (!options.shell) { diff --git a/apps/mcp-server/src/cli/cli.types.ts b/apps/mcp-server/src/cli/cli.types.ts index d49a61e8..b8e53d70 100644 --- a/apps/mcp-server/src/cli/cli.types.ts +++ b/apps/mcp-server/src/cli/cli.types.ts @@ -83,6 +83,16 @@ export interface UpdateOptions { updatePluginName?: string; } +/** + * Create-plugin command options (parsed from CLI args) + */ +export interface CreatePluginOptions { + /** Plugin name */ + createPluginName?: string; + /** Template type: minimal or full */ + createPluginTemplate?: string; +} + /** * Console output levels */ diff --git a/apps/mcp-server/src/cli/plugin/create-plugin.command.spec.ts b/apps/mcp-server/src/cli/plugin/create-plugin.command.spec.ts new file mode 100644 index 00000000..f5da9ce8 --- /dev/null +++ b/apps/mcp-server/src/cli/plugin/create-plugin.command.spec.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const mockConsoleUtils = { + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + }, +}; + +vi.mock('../utils/console', () => ({ + createConsoleUtils: () => mockConsoleUtils, +})); + +vi.mock('child_process', () => ({ + execSync: vi.fn(() => 'Test Author'), +})); + +import { + runCreatePlugin, + validatePluginName, + generateMinimalTemplate, + generateFullTemplate, +} from './create-plugin.command'; + +describe('create-plugin.command', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-plugin-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ========================================================================= + // Name validation + // ========================================================================= + describe('validatePluginName', () => { + it('should accept valid lowercase-dash names', () => { + expect(validatePluginName('my-plugin')).toBe(true); + expect(validatePluginName('plugin123')).toBe(true); + expect(validatePluginName('a')).toBe(true); + expect(validatePluginName('next-js-router')).toBe(true); + }); + + it('should reject names starting with a number', () => { + expect(validatePluginName('123plugin')).toBe(false); + }); + + it('should reject names with uppercase letters', () => { + expect(validatePluginName('MyPlugin')).toBe(false); + }); + + it('should reject names with special characters', () => { + expect(validatePluginName('my_plugin')).toBe(false); + expect(validatePluginName('my.plugin')).toBe(false); + expect(validatePluginName('my plugin')).toBe(false); + }); + + it('should reject empty names', () => { + expect(validatePluginName('')).toBe(false); + }); + }); + + // ========================================================================= + // Minimal template generation + // ========================================================================= + describe('generateMinimalTemplate', () => { + it('should return plugin.json, README.md, and .gitignore', () => { + const files = generateMinimalTemplate({ + name: 'my-plugin', + description: 'A test plugin', + author: 'Test Author', + }); + + expect(files).toHaveProperty('plugin.json'); + expect(files).toHaveProperty('README.md'); + expect(files).toHaveProperty('.gitignore'); + }); + + it('should generate valid plugin.json with correct fields', () => { + const files = generateMinimalTemplate({ + name: 'my-plugin', + description: 'A test plugin', + author: 'Test Author', + }); + + const manifest = JSON.parse(files['plugin.json']); + expect(manifest.name).toBe('my-plugin'); + expect(manifest.version).toBe('1.0.0'); + expect(manifest.description).toBe('A test plugin'); + expect(manifest.author).toBe('Test Author'); + expect(manifest.provides).toBeDefined(); + }); + + it('should generate README with plugin name', () => { + const files = generateMinimalTemplate({ + name: 'my-plugin', + description: 'A test plugin', + author: 'Test Author', + }); + + expect(files['README.md']).toContain('my-plugin'); + expect(files['README.md']).toContain('A test plugin'); + }); + }); + + // ========================================================================= + // Full template generation + // ========================================================================= + describe('generateFullTemplate', () => { + it('should include all minimal files plus example directories', () => { + const files = generateFullTemplate({ + name: 'my-plugin', + description: 'A test plugin', + author: 'Test Author', + }); + + expect(files).toHaveProperty('plugin.json'); + expect(files).toHaveProperty('README.md'); + expect(files).toHaveProperty('.gitignore'); + expect(files).toHaveProperty('agents/example-agent.json'); + expect(files).toHaveProperty('skills/example-skill.md'); + expect(files).toHaveProperty('rules/example-rule.md'); + expect(files).toHaveProperty('checklists/example-checklist.json'); + }); + + it('should generate valid example agent JSON', () => { + const files = generateFullTemplate({ + name: 'my-plugin', + description: 'A test plugin', + author: 'Test Author', + }); + + const agent = JSON.parse(files['agents/example-agent.json']); + expect(agent.name).toBeDefined(); + expect(agent.role).toBeDefined(); + }); + + it('should generate valid example checklist JSON', () => { + const files = generateFullTemplate({ + name: 'my-plugin', + description: 'A test plugin', + author: 'Test Author', + }); + + const checklist = JSON.parse(files['checklists/example-checklist.json']); + expect(checklist.name).toBeDefined(); + expect(checklist.items).toBeDefined(); + expect(Array.isArray(checklist.items)).toBe(true); + }); + + it('should list provided files in plugin.json', () => { + const files = generateFullTemplate({ + name: 'my-plugin', + description: 'A test plugin', + author: 'Test Author', + }); + + const manifest = JSON.parse(files['plugin.json']); + expect(manifest.provides.agents).toContain('agents/example-agent.json'); + expect(manifest.provides.skills).toContain('skills/example-skill.md'); + expect(manifest.provides.rules).toContain('rules/example-rule.md'); + expect(manifest.provides.checklists).toContain('checklists/example-checklist.json'); + }); + }); + + // ========================================================================= + // runCreatePlugin (integration) + // ========================================================================= + describe('runCreatePlugin', () => { + it('should create minimal template files on disk', async () => { + const result = await runCreatePlugin({ + name: 'test-plugin', + outputDir: tmpDir, + template: 'minimal', + }); + + expect(result.success).toBe(true); + const pluginDir = path.join(tmpDir, 'test-plugin'); + expect(fs.existsSync(path.join(pluginDir, 'plugin.json'))).toBe(true); + expect(fs.existsSync(path.join(pluginDir, 'README.md'))).toBe(true); + expect(fs.existsSync(path.join(pluginDir, '.gitignore'))).toBe(true); + }); + + it('should create full template files on disk', async () => { + const result = await runCreatePlugin({ + name: 'test-plugin', + outputDir: tmpDir, + template: 'full', + }); + + expect(result.success).toBe(true); + const pluginDir = path.join(tmpDir, 'test-plugin'); + expect(fs.existsSync(path.join(pluginDir, 'agents/example-agent.json'))).toBe(true); + expect(fs.existsSync(path.join(pluginDir, 'skills/example-skill.md'))).toBe(true); + expect(fs.existsSync(path.join(pluginDir, 'rules/example-rule.md'))).toBe(true); + expect(fs.existsSync(path.join(pluginDir, 'checklists/example-checklist.json'))).toBe(true); + }); + + it('should fail if plugin name is invalid', async () => { + const result = await runCreatePlugin({ + name: 'Invalid-Name', + outputDir: tmpDir, + template: 'minimal', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid plugin name'); + expect(mockConsoleUtils.log.error).toHaveBeenCalled(); + }); + + it('should fail if target directory already exists', async () => { + fs.mkdirSync(path.join(tmpDir, 'existing-plugin')); + + const result = await runCreatePlugin({ + name: 'existing-plugin', + outputDir: tmpDir, + template: 'minimal', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + expect(mockConsoleUtils.log.error).toHaveBeenCalled(); + }); + + it('should use git config author as default', async () => { + const result = await runCreatePlugin({ + name: 'test-plugin', + outputDir: tmpDir, + template: 'minimal', + }); + + expect(result.success).toBe(true); + const pluginDir = path.join(tmpDir, 'test-plugin'); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, 'plugin.json'), 'utf-8')); + expect(manifest.author).toBe('Test Author'); + }); + + it('should use provided description', async () => { + const result = await runCreatePlugin({ + name: 'test-plugin', + outputDir: tmpDir, + template: 'minimal', + description: 'My custom description', + }); + + expect(result.success).toBe(true); + const pluginDir = path.join(tmpDir, 'test-plugin'); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, 'plugin.json'), 'utf-8')); + expect(manifest.description).toBe('My custom description'); + }); + + it('should log success with created path', async () => { + await runCreatePlugin({ + name: 'test-plugin', + outputDir: tmpDir, + template: 'minimal', + }); + + expect(mockConsoleUtils.log.success).toHaveBeenCalledWith( + expect.stringContaining('test-plugin'), + ); + }); + }); +}); diff --git a/apps/mcp-server/src/cli/plugin/create-plugin.command.ts b/apps/mcp-server/src/cli/plugin/create-plugin.command.ts new file mode 100644 index 00000000..862c64f0 --- /dev/null +++ b/apps/mcp-server/src/cli/plugin/create-plugin.command.ts @@ -0,0 +1,215 @@ +/** + * Plugin Create Command + * + * CLI command handler for `codingbuddy create-plugin `. + * Scaffolds a new plugin project with minimal or full template. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import { createConsoleUtils } from '../utils/console'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CreatePluginOptions { + name: string; + outputDir: string; + template: 'minimal' | 'full'; + description?: string; + author?: string; +} + +export interface CreatePluginResult { + success: boolean; + pluginDir?: string; + error?: string; +} + +export interface TemplateContext { + name: string; + description: string; + author: string; +} + +// ============================================================================ +// Validation +// ============================================================================ + +const PLUGIN_NAME_REGEX = /^[a-z][a-z0-9-]*$/; + +export function validatePluginName(name: string): boolean { + return PLUGIN_NAME_REGEX.test(name); +} + +// ============================================================================ +// Author detection +// ============================================================================ + +function getDefaultAuthor(): string { + try { + return execSync('git config user.name', { encoding: 'utf-8' }).trim(); + } catch { + return 'unknown'; + } +} + +// ============================================================================ +// Template generators +// ============================================================================ + +export function generateMinimalTemplate(ctx: TemplateContext): Record { + const manifest = { + name: ctx.name, + version: '1.0.0', + description: ctx.description, + author: ctx.author, + provides: { + agents: [], + rules: [], + skills: [], + checklists: [], + }, + }; + + const readme = `# ${ctx.name} + +${ctx.description} + +## Installation + +\`\`\`bash +codingbuddy install ./${ctx.name} +\`\`\` + +## Author + +${ctx.author} +`; + + const gitignore = `node_modules/ +dist/ +.DS_Store +`; + + return { + 'plugin.json': JSON.stringify(manifest, null, 2), + 'README.md': readme, + '.gitignore': gitignore, + }; +} + +export function generateFullTemplate(ctx: TemplateContext): Record { + const minimalFiles = generateMinimalTemplate(ctx); + + const manifest = JSON.parse(minimalFiles['plugin.json']); + manifest.provides = { + agents: ['agents/example-agent.json'], + rules: ['rules/example-rule.md'], + skills: ['skills/example-skill.md'], + checklists: ['checklists/example-checklist.json'], + }; + + const exampleAgent = { + name: 'Example Agent', + description: `Example specialist agent for ${ctx.name}`, + role: { + title: 'Example Specialist', + expertise: ['example-domain'], + }, + activation_message: { + formatted: '🤖 example-agent [Example Specialist]', + }, + }; + + const exampleSkill = `# Example Skill + +## Purpose + +An example skill provided by ${ctx.name}. + +## Instructions + +Describe the skill instructions here. +`; + + const exampleRule = `# Example Rule + +## Description + +An example rule provided by ${ctx.name}. + +## Guidelines + +- Guideline 1 +- Guideline 2 +`; + + const exampleChecklist = { + name: 'Example Checklist', + description: `Example checklist from ${ctx.name}`, + items: [ + { + id: 'example-1', + text: 'Check example condition', + severity: 'medium', + }, + ], + }; + + return { + ...minimalFiles, + 'plugin.json': JSON.stringify(manifest, null, 2), + 'agents/example-agent.json': JSON.stringify(exampleAgent, null, 2), + 'skills/example-skill.md': exampleSkill, + 'rules/example-rule.md': exampleRule, + 'checklists/example-checklist.json': JSON.stringify(exampleChecklist, null, 2), + }; +} + +// ============================================================================ +// Command runner +// ============================================================================ + +export async function runCreatePlugin(options: CreatePluginOptions): Promise { + const console = createConsoleUtils(); + + if (!validatePluginName(options.name)) { + const msg = `Invalid plugin name "${options.name}". Must match /^[a-z][a-z0-9-]*$/`; + console.log.error(msg); + return { success: false, error: msg }; + } + + const pluginDir = path.join(options.outputDir, options.name); + + if (fs.existsSync(pluginDir)) { + const msg = `Directory "${pluginDir}" already exists`; + console.log.error(msg); + return { success: false, error: msg }; + } + + const author = options.author ?? getDefaultAuthor(); + const description = options.description ?? `A CodingBuddy plugin: ${options.name}`; + + const ctx: TemplateContext = { + name: options.name, + description, + author, + }; + + const files = + options.template === 'full' ? generateFullTemplate(ctx) : generateMinimalTemplate(ctx); + + console.log.step('🔧', `Scaffolding plugin "${options.name}" (${options.template} template)...`); + + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(pluginDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf-8'); + } + + console.log.success(`Plugin "${options.name}" created at ${pluginDir}`); + return { success: true, pluginDir }; +}