Skip to content
Closed
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
15 changes: 13 additions & 2 deletions .trellis/scripts/common/cli_adapter.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -15,6 +15,7 @@
- antigravity: Antigravity (workflow-based)
- qoder: Qoder
- codebuddy: CodeBuddy
- copilot: GitHub Copilot (VS Code)

Usage:
from common.cli_adapter import CLIAdapter
Expand Down Expand Up @@ -45,6 +46,7 @@
"antigravity",
"qoder",
"codebuddy",
"copilot",
]


Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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)."""

Expand Down Expand Up @@ -581,6 +587,7 @@ def detect_platform(project_root: Path) -> Platform:
"antigravity",
"qoder",
"codebuddy",
"copilot",
):
return env_platform # type: ignore

Expand Down Expand Up @@ -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"


Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ interface InitOptions {
windsurf?: boolean;
qoder?: boolean;
codebuddy?: boolean;
copilot?: boolean;
yes?: boolean;
user?: string;
force?: boolean;
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/configurators/copilot.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
21 changes: 21 additions & 0 deletions packages/cli/src/configurators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -241,6 +246,22 @@ const PLATFORM_FUNCTIONS: Record<AITool, PlatformFunctions> = {
return files;
},
},
copilot: {
configure: configureCopilot,
collectTemplates: () => {
const files = new Map<string, string>();
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;
},
},
};

// =============================================================================
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/templates/copilot/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": "{{PYTHON_CMD}} .github/copilot/hooks/session-start.py",
"timeout": 10
}
]
}
}
218 changes: 218 additions & 0 deletions packages/cli/src/templates/copilot/hooks/session-start.py
Original file line number Diff line number Diff line change
@@ -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("""<session-context>
You are starting a new session in a Trellis-managed project.
Read and follow all instructions below carefully.
</session-context>

""")

output.write("<current-state>\n")
context_script = trellis_dir / "scripts" / "get_context.py"
output.write(run_script(context_script))
output.write("\n</current-state>\n\n")

output.write("<workflow>\n")
workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found")
output.write(workflow_content)
output.write("\n</workflow>\n\n")

output.write("<guidelines>\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("</guidelines>\n\n")

task_status = _get_task_status(trellis_dir)
output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")

output.write("""<ready>
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.
</ready>""")

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()
Loading