diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py index 483e62e..30299fb 100755 --- a/.trellis/scripts/common/cli_adapter.py +++ b/.trellis/scripts/common/cli_adapter.py @@ -1,7 +1,7 @@ """ CLI Adapter for Multi-Platform Support. -Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Qoder, and CodeBuddy interfaces. +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Qoder, CodeBuddy, and GitHub Copilot interfaces. Supported platforms: - claude: Claude Code (default) @@ -15,6 +15,7 @@ - antigravity: Antigravity (workflow-based) - qoder: Qoder - codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) Usage: from common.cli_adapter import CLIAdapter @@ -45,6 +46,7 @@ "antigravity", "qoder", "codebuddy", + "copilot", ] @@ -111,6 +113,8 @@ def config_dir_name(self) -> str: return ".qoder" elif self.platform == "codebuddy": return ".codebuddy" + elif self.platform == "copilot": + return ".github/copilot" else: return ".claude" @@ -508,9 +512,10 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: "antigravity", "qoder", "codebuddy", + "copilot", ): raise ValueError( - f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'qoder', or 'codebuddy')" + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'qoder', 'codebuddy', or 'copilot')" ) return CLIAdapter(platform=platform) # type: ignore @@ -529,6 +534,7 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: ".agent", ".qoder", ".codebuddy", + ".github/copilot", ) """All platform config directory names (used by detect_platform exclusion checks).""" @@ -581,6 +587,7 @@ def detect_platform(project_root: Path) -> Platform: "antigravity", "qoder", "codebuddy", + "copilot", ): return env_platform # type: ignore @@ -634,6 +641,10 @@ def detect_platform(project_root: Path) -> Platform: if (project_root / ".qoder").is_dir(): return "qoder" + # Check for .github/copilot directory (GitHub Copilot-specific) + if (project_root / ".github" / "copilot").is_dir(): + return "copilot" + return "claude" diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 7ddc5d3..fe0e3c3 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -72,6 +72,7 @@ program .option("--windsurf", "Include Windsurf workflows") .option("--qoder", "Include Qoder commands") .option("--codebuddy", "Include CodeBuddy commands") + .option("--copilot", "Include GitHub Copilot hooks") .option("-y, --yes", "Skip prompts and use defaults") .option( "-u, --user ", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 2833d9c..f1a33ec 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -362,6 +362,7 @@ interface InitOptions { windsurf?: boolean; qoder?: boolean; codebuddy?: boolean; + copilot?: boolean; yes?: boolean; user?: string; force?: boolean; diff --git a/packages/cli/src/configurators/copilot.ts b/packages/cli/src/configurators/copilot.ts new file mode 100644 index 0000000..6b567fa --- /dev/null +++ b/packages/cli/src/configurators/copilot.ts @@ -0,0 +1,31 @@ +import path from "node:path"; +import { getAllHooks, getHooksConfig } from "../templates/copilot/index.js"; +import { ensureDir, writeFile } from "../utils/file-writer.js"; +import { resolvePlaceholders } from "./shared.js"; + +/** + * Configure GitHub Copilot by writing: + * - .github/copilot/hooks/session-start.py (hook scripts) + * - .github/copilot/hooks.json (hooks config, tracked by trellis update) + * - .github/hooks/trellis.json (hooks config for VS Code Copilot discovery) + */ +export async function configureCopilot(cwd: string): Promise { + const copilotRoot = path.join(cwd, ".github", "copilot"); + + // Hook scripts → .github/copilot/hooks/ + const hooksDir = path.join(copilotRoot, "hooks"); + ensureDir(hooksDir); + + for (const hook of getAllHooks()) { + await writeFile(path.join(hooksDir, hook.name), hook.content); + } + + // Hooks config → .github/copilot/hooks.json (tracked copy) + const resolvedConfig = resolvePlaceholders(getHooksConfig()); + await writeFile(path.join(copilotRoot, "hooks.json"), resolvedConfig); + + // Hooks config → .github/hooks/trellis.json (VS Code Copilot discovery) + const githubHooksDir = path.join(cwd, ".github", "hooks"); + ensureDir(githubHooksDir); + await writeFile(path.join(githubHooksDir, "trellis.json"), resolvedConfig); +} diff --git a/packages/cli/src/configurators/index.ts b/packages/cli/src/configurators/index.ts index 88edc07..557dba0 100644 --- a/packages/cli/src/configurators/index.ts +++ b/packages/cli/src/configurators/index.ts @@ -30,6 +30,7 @@ import { configureAntigravity } from "./antigravity.js"; import { configureWindsurf } from "./windsurf.js"; import { configureQoder } from "./qoder.js"; import { configureCodebuddy } from "./codebuddy.js"; +import { configureCopilot } from "./copilot.js"; // Shared utilities import { resolvePlaceholders } from "./shared.js"; @@ -63,6 +64,10 @@ import { getAllWorkflows as getAntigravityWorkflows } from "../templates/antigra import { getAllWorkflows as getAllWindsurfWorkflows } from "../templates/windsurf/index.js"; import { getAllSkills as getQoderSkills } from "../templates/qoder/index.js"; import { getAllCommands as getCodebuddyCommands } from "../templates/codebuddy/index.js"; +import { + getAllHooks as getCopilotHooks, + getHooksConfig as getCopilotHooksConfig, +} from "../templates/copilot/index.js"; // ============================================================================= // Platform Functions Registry @@ -241,6 +246,22 @@ const PLATFORM_FUNCTIONS: Record = { return files; }, }, + copilot: { + configure: configureCopilot, + collectTemplates: () => { + const files = new Map(); + for (const hook of getCopilotHooks()) { + files.set(`.github/copilot/hooks/${hook.name}`, hook.content); + } + // Note: .github/hooks/trellis.json is also written by configureCopilot + // for VS Code Copilot discovery, but tracked here under configDir + files.set( + ".github/copilot/hooks.json", + resolvePlaceholders(getCopilotHooksConfig()), + ); + return files; + }, + }, }; // ============================================================================= diff --git a/packages/cli/src/templates/copilot/hooks.json b/packages/cli/src/templates/copilot/hooks.json new file mode 100644 index 0000000..bc3ed03 --- /dev/null +++ b/packages/cli/src/templates/copilot/hooks.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "SessionStart": [ + { + "type": "command", + "command": "{{PYTHON_CMD}} .github/copilot/hooks/session-start.py", + "timeout": 10 + } + ] + } +} diff --git a/packages/cli/src/templates/copilot/hooks/session-start.py b/packages/cli/src/templates/copilot/hooks/session-start.py new file mode 100644 index 0000000..2d26f29 --- /dev/null +++ b/packages/cli/src/templates/copilot/hooks/session-start.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copilot Session Start Hook - Inject Trellis context into VS Code Copilot sessions. + +Output format follows Copilot hook protocol: + stdout JSON → { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } } +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import warnings +from io import StringIO +from pathlib import Path + +warnings.filterwarnings("ignore") + + +def should_skip_injection() -> bool: + return os.environ.get("COPILOT_NON_INTERACTIVE") == "1" + + +def read_file(path: Path, fallback: str = "") -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return fallback + + +def run_script(script_path: Path) -> str: + try: + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + cmd = [sys.executable, "-W", "ignore", str(script_path)] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + cwd=str(script_path.parent.parent.parent), + env=env, + ) + return result.stdout if result.returncode == 0 else "No context available" + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "No context available" + + +def _normalize_task_ref(task_ref: str) -> str: + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith("tasks/"): + return f".trellis/{normalized}" + + return normalized + + +def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: + normalized = _normalize_task_ref(task_ref) + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + if normalized.startswith(".trellis/"): + return trellis_dir.parent / path_obj + return trellis_dir / "tasks" / path_obj + + +def _get_task_status(trellis_dir: Path) -> str: + current_task_file = trellis_dir / ".current-task" + if not current_task_file.is_file(): + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + + task_ref = _normalize_task_ref(current_task_file.read_text(encoding="utf-8").strip()) + if not task_ref: + return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" + + task_dir = _resolve_task_dir(trellis_dir, task_ref) + if not task_dir.is_dir(): + return f"Status: STALE POINTER\nTask: {task_ref}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" + + task_json_path = task_dir / "task.json" + task_data: dict = {} + if task_json_path.is_file(): + try: + task_data = json.loads(task_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, PermissionError): + pass + + task_title = task_data.get("title", task_ref) + task_status = task_data.get("status", "unknown") + + if task_status == "completed": + return f"Status: COMPLETED\nTask: {task_title}\nNext: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` or start a new task" + + has_context = False + for jsonl_name in ("implement.jsonl", "check.jsonl", "spec.jsonl"): + jsonl_path = task_dir / jsonl_name + if jsonl_path.is_file() and jsonl_path.stat().st_size > 0: + has_context = True + break + + has_prd = (task_dir / "prd.md").is_file() + + if not has_prd: + return f"Status: NOT READY\nTask: {task_title}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start" + + if not has_context: + return f"Status: NOT READY\nTask: {task_title}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing" + + return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check" + + +def main() -> None: + if should_skip_injection(): + sys.exit(0) + + # Read hook input from stdin + try: + hook_input = json.loads(sys.stdin.read()) + project_dir = Path(hook_input.get("cwd", ".")).resolve() + except (json.JSONDecodeError, KeyError): + project_dir = Path(".").resolve() + + trellis_dir = project_dir / ".trellis" + + output = StringIO() + + output.write(""" +You are starting a new session in a Trellis-managed project. +Read and follow all instructions below carefully. + + +""") + + output.write("\n") + context_script = trellis_dir / "scripts" / "get_context.py" + output.write(run_script(context_script)) + output.write("\n\n\n") + + output.write("\n") + workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found") + output.write(workflow_content) + output.write("\n\n\n") + + output.write("\n") + output.write("**Note**: The guidelines below are index files — they list available guideline documents and their locations.\n") + output.write("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n\n") + + spec_dir = trellis_dir / "spec" + if spec_dir.is_dir(): + for sub in sorted(spec_dir.iterdir()): + if not sub.is_dir() or sub.name.startswith("."): + continue + + if sub.name == "guides": + index_file = sub / "index.md" + if index_file.is_file(): + output.write(f"## {sub.name}\n") + output.write(read_file(index_file)) + output.write("\n\n") + continue + + index_file = sub / "index.md" + if index_file.is_file(): + output.write(f"## {sub.name}\n") + output.write(read_file(index_file)) + output.write("\n\n") + else: + for nested in sorted(sub.iterdir()): + if not nested.is_dir(): + continue + nested_index = nested / "index.md" + if nested_index.is_file(): + output.write(f"## {sub.name}/{nested.name}\n") + output.write(read_file(nested_index)) + output.write("\n\n") + + output.write("\n\n") + + task_status = _get_task_status(trellis_dir) + output.write(f"\n{task_status}\n\n\n") + + output.write(""" +Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them. +Start from Step 4. Wait for user's first message, then follow the workflow to handle their request. +If there is an active task, ask whether to continue it. +""") + + context = output.getvalue() + result = { + "suppressOutput": True, + "systemMessage": f"Trellis context injected ({len(context)} chars)", + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": context, + }, + } + + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/packages/cli/src/templates/copilot/index.ts b/packages/cli/src/templates/copilot/index.ts new file mode 100644 index 0000000..6720ee9 --- /dev/null +++ b/packages/cli/src/templates/copilot/index.ts @@ -0,0 +1,51 @@ +/** + * Copilot templates + * + * These are GENERIC templates for user projects. + * + * Directory structure: + * copilot/ + * ├── hooks/ # Hook scripts → .github/copilot/hooks/ + * └── hooks.json # Hooks config → .github/hooks/trellis.json + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function readTemplate(relativePath: string): string { + return readFileSync(join(__dirname, relativePath), "utf-8"); +} + +function listFiles(dir: string): string[] { + try { + return readdirSync(join(__dirname, dir)).sort(); + } catch { + return []; + } +} + +export interface HookTemplate { + name: string; + content: string; +} + +export function getAllHooks(): HookTemplate[] { + const hooks: HookTemplate[] = []; + + for (const file of listFiles("hooks")) { + if (!file.endsWith(".py")) { + continue; + } + hooks.push({ name: file, content: readTemplate(`hooks/${file}`) }); + } + + return hooks; +} + +export function getHooksConfig(): string { + return readTemplate("hooks.json"); +} diff --git a/packages/cli/src/templates/extract.ts b/packages/cli/src/templates/extract.ts index b276b6f..54e9d39 100644 --- a/packages/cli/src/templates/extract.ts +++ b/packages/cli/src/templates/extract.ts @@ -270,6 +270,23 @@ export function getCodebuddyTemplatePath(): string { ); } +/** + * Get the path to the copilot templates directory. + * + * This reads from src/templates/copilot/ (development) or dist/templates/copilot/ (production). + * These are GENERIC templates, not the Trellis project's own .github/copilot/ configuration. + */ +export function getCopilotTemplatePath(): string { + const templatePath = path.join(__dirname, "copilot"); + if (fs.existsSync(templatePath)) { + return templatePath; + } + + throw new Error( + "Could not find copilot templates directory. Expected at templates/copilot/", + ); +} + /** * Read a file from the .trellis directory * @param relativePath - Path relative to .trellis/ (e.g., 'scripts/task.py') diff --git a/packages/cli/src/templates/trellis/scripts/common/cli_adapter.py b/packages/cli/src/templates/trellis/scripts/common/cli_adapter.py index 08910fb..01e33aa 100755 --- a/packages/cli/src/templates/trellis/scripts/common/cli_adapter.py +++ b/packages/cli/src/templates/trellis/scripts/common/cli_adapter.py @@ -1,7 +1,7 @@ """ CLI Adapter for Multi-Platform Support. -Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, and CodeBuddy interfaces. +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, and GitHub Copilot interfaces. Supported platforms: - claude: Claude Code (default) @@ -16,6 +16,7 @@ - windsurf: Windsurf (workflow-based) - qoder: Qoder - codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) Usage: from common.cli_adapter import CLIAdapter @@ -47,6 +48,7 @@ "windsurf", "qoder", "codebuddy", + "copilot", ] @@ -115,6 +117,8 @@ def config_dir_name(self) -> str: return ".qoder" elif self.platform == "codebuddy": return ".codebuddy" + elif self.platform == "copilot": + return ".github/copilot" else: return ".claude" @@ -538,9 +542,10 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: "windsurf", "qoder", "codebuddy", + "copilot", ): raise ValueError( - f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', or 'codebuddy')" + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', or 'copilot')" ) return CLIAdapter(platform=platform) # type: ignore @@ -560,6 +565,7 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: ".windsurf", ".qoder", ".codebuddy", + ".github/copilot", ) """All platform config directory names (used by detect_platform exclusion checks).""" @@ -614,6 +620,7 @@ def detect_platform(project_root: Path) -> Platform: "windsurf", "qoder", "codebuddy", + "copilot", ): return env_platform # type: ignore @@ -675,6 +682,10 @@ def detect_platform(project_root: Path) -> Platform: if (project_root / ".qoder").is_dir(): return "qoder" + # Check for .github/copilot directory (GitHub Copilot-specific) + if (project_root / ".github" / "copilot").is_dir(): + return "copilot" + return "claude" diff --git a/packages/cli/src/types/ai-tools.ts b/packages/cli/src/types/ai-tools.ts index f122904..aa152ca 100644 --- a/packages/cli/src/types/ai-tools.ts +++ b/packages/cli/src/types/ai-tools.ts @@ -19,7 +19,8 @@ export type AITool = | "antigravity" | "windsurf" | "qoder" - | "codebuddy"; + | "codebuddy" + | "copilot"; /** * Template directory categories @@ -37,7 +38,8 @@ export type TemplateDir = | "antigravity" | "windsurf" | "qoder" - | "codebuddy"; + | "codebuddy" + | "copilot"; /** * CLI flag names for platform selection (e.g., --claude, --cursor, --kilo, --kiro, --gemini, --antigravity) @@ -55,7 +57,8 @@ export type CliFlag = | "antigravity" | "windsurf" | "qoder" - | "codebuddy"; + | "codebuddy" + | "copilot"; /** * Configuration for an AI tool @@ -190,6 +193,14 @@ export const AI_TOOLS: Record = { defaultChecked: false, hasPythonHooks: false, }, + copilot: { + name: "GitHub Copilot", + templateDirs: ["common", "copilot"], + configDir: ".github/copilot", + cliFlag: "copilot", + defaultChecked: false, + hasPythonHooks: true, + }, }; /**