docs: update README for current state, remove github extension

- Remove github extension (3 files) and its test
- Fix GitHub badge/link URLs (glittercowboy/gsd-pi → gsd-build/GSD-2)
- Update /gsd description: wizard → step mode (default since /gsd next)
- Add missing commands: /gsd next, /worktree, /voice, /exit, /clear, keybindings
- Update bundled extensions: 9 → 13 (add Google Search, Mac Tools, MCPorter, Voice)
- Add Google Gemini API key to first launch, requirements, architecture tree
This commit is contained in:
Lex Christopherson 2026-03-11 17:10:51 -06:00
parent 5eb02e9a1c
commit a21022a3ef
5 changed files with 26 additions and 1602 deletions

View file

@ -2,11 +2,11 @@
# GSD 2 # GSD 2
**The evolution of [Get Shit Done](https://github.com/glittercowboy/get-shit-done) — now a real coding agent.** **The evolution of [Get Shit Done](https://github.com/gsd-build/get-shit-done) — now a real coding agent.**
[![npm version](https://img.shields.io/npm/v/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi) [![npm version](https://img.shields.io/npm/v/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi)
[![npm downloads](https://img.shields.io/npm/dm/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi) [![npm downloads](https://img.shields.io/npm/dm/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi)
[![GitHub stars](https://img.shields.io/github/stars/glittercowboy/gsd-pi?style=for-the-badge&logo=github&color=181717)](https://github.com/glittercowboy/gsd-pi) [![GitHub stars](https://img.shields.io/github/stars/gsd-build/GSD-2?style=for-the-badge&logo=github&color=181717)](https://github.com/gsd-build/GSD-2)
[![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE)
The original GSD went viral as a prompt framework for Claude Code. It worked, but it was fighting the tool — injecting prompts through slash commands, hoping the LLM would follow instructions, with no actual control over context windows, sessions, or execution. The original GSD went viral as a prompt framework for Claude Code. It worked, but it was fighting the tool — injecting prompts through slash commands, hoping the LLM would follow instructions, with no actual control over context windows, sessions, or execution.
@ -122,16 +122,18 @@ Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`,
9. **Escape hatch** — Press Escape to pause. The conversation is preserved. Interact with the agent, inspect what happened, or just `/gsd auto` to resume from disk state. 9. **Escape hatch** — Press Escape to pause. The conversation is preserved. Interact with the agent, inspect what happened, or just `/gsd auto` to resume from disk state.
### The `/gsd` Wizard ### `/gsd` and `/gsd next` — Step Mode
When you're not in auto mode, `/gsd` reads disk state and shows contextual options: By default, `/gsd` runs in **step mode**: the same state machine as auto mode, but it pauses between units with a wizard showing what completed and what's next. You advance one step at a time, review the output, and continue when ready.
- **No `.gsd/` directory** → Start a new project. Discussion flow captures your vision, constraints, and preferences. - **No `.gsd/` directory** → Start a new project. Discussion flow captures your vision, constraints, and preferences.
- **Milestone exists, no roadmap** → Discuss or research the milestone. - **Milestone exists, no roadmap** → Discuss or research the milestone.
- **Roadmap exists, slices pending** → Plan the next slice, or jump straight to auto. - **Roadmap exists, slices pending** → Plan the next slice, execute one task, or switch to auto.
- **Mid-task** → Resume from where you left off. - **Mid-task** → Resume from where you left off.
The wizard is the on-ramp. Auto mode is the highway. `/gsd next` is an explicit alias for step mode. You can switch from step → auto mid-session via the wizard.
Step mode is the on-ramp. Auto mode is the highway.
--- ---
@ -170,7 +172,7 @@ gsd
GSD opens an interactive agent session. From there, you have two ways to work: GSD opens an interactive agent session. From there, you have two ways to work:
**`/gsd`guided mode.** Type `/gsd` and GSD reads your project state and walks you through whatever's next. No project yet? It helps you describe what you want to build. Roadmap exists? It plans the next slice. Mid-task? It resumes. This is the hands-on mode where you work *with* the agent step by step. **`/gsd`step mode.** Type `/gsd` and GSD executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. Same state machine as auto mode, but you stay in the loop. No project yet? It starts the discussion flow. Roadmap exists? It plans or executes the next step.
**`/gsd auto` — autonomous mode.** Type `/gsd auto` and walk away. GSD researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting. **`/gsd auto` — autonomous mode.** Type `/gsd auto` and walk away. GSD researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting.
@ -196,13 +198,14 @@ Both terminals read and write the same `.gsd/` files on disk. Your decisions in
### First launch ### First launch
On first run, GSD prompts for optional API keys (Brave Search, Context7, Jina) for web research and documentation tools. All optional — press Enter to skip any. On first run, GSD prompts for optional API keys (Brave Search, Google Gemini, Context7, Jina) for web research and documentation tools. All optional — press Enter to skip any.
### Commands ### Commands
| Command | What it does | | Command | What it does |
|---------|-------------| |---------|-------------|
| `/gsd` | Guided mode — reads project state, walks you through what's next | | `/gsd` | Step mode — executes one unit at a time, pauses between each |
| `/gsd next` | Explicit step mode (same as bare `/gsd`) |
| `/gsd auto` | Autonomous mode — researches, plans, executes, commits, repeats | | `/gsd auto` | Autonomous mode — researches, plans, executes, commits, repeats |
| `/gsd stop` | Stop auto mode gracefully | | `/gsd stop` | Stop auto mode gracefully |
| `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) | | `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) |
@ -211,7 +214,13 @@ On first run, GSD prompts for optional API keys (Brave Search, Context7, Jina) f
| `/gsd prefs` | Model selection, timeouts, budget ceiling | | `/gsd prefs` | Model selection, timeouts, budget ceiling |
| `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format | | `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format |
| `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues | | `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues |
| `/worktree` (`/wt`) | Git worktree lifecycle — create, switch, merge, remove |
| `/voice` | Toggle real-time speech-to-text (macOS only) |
| `/exit` | Kill GSD process immediately |
| `/clear` | Start a new session (alias for `/new`) |
| `Ctrl+Alt+G` | Toggle dashboard overlay | | `Ctrl+Alt+G` | Toggle dashboard overlay |
| `Ctrl+Alt+V` | Toggle voice transcription |
| `Ctrl+Alt+B` | Show background shell processes |
--- ---
@ -311,16 +320,20 @@ budget_ceiling: 50.00
### Bundled Tools ### Bundled Tools
GSD ships with 9 extensions, all loaded automatically: GSD ships with 13 extensions, all loaded automatically:
| Extension | What it provides | | Extension | What it provides |
|-----------|-----------------| |-----------|-----------------|
| **GSD** | Core workflow engine, auto mode, commands, dashboard | | **GSD** | Core workflow engine, auto mode, commands, dashboard |
| **Browser Tools** | Playwright-based browser for UI verification | | **Browser Tools** | Playwright-based browser for UI verification |
| **Search the Web** | Brave Search + Jina page extraction | | **Search the Web** | Brave Search + Jina page extraction |
| **Google Search** | Gemini-powered web search with AI-synthesized answers |
| **Context7** | Up-to-date library/framework documentation | | **Context7** | Up-to-date library/framework documentation |
| **Background Shell** | Long-running process management with readiness detection | | **Background Shell** | Long-running process management with readiness detection |
| **Subagent** | Delegated tasks with isolated context windows | | **Subagent** | Delegated tasks with isolated context windows |
| **Mac Tools** | macOS native app automation via Accessibility APIs |
| **MCPorter** | Lazy on-demand MCP server integration |
| **Voice** | Real-time speech-to-text transcription (macOS) |
| **Slash Commands** | Custom command creation | | **Slash Commands** | Custom command creation |
| **Ask User Questions** | Structured user input with single/multi-select | | **Ask User Questions** | Structured user input with single/multi-select |
| **Secure Env Collect** | Masked secret collection without manual .env editing | | **Secure Env Collect** | Masked secret collection without manual .env editing |
@ -345,12 +358,12 @@ GSD is a TypeScript application that embeds the Pi coding agent SDK.
gsd (CLI binary) gsd (CLI binary)
└─ loader.ts Sets PI_PACKAGE_DIR, GSD env vars, dynamic-imports cli.ts └─ loader.ts Sets PI_PACKAGE_DIR, GSD env vars, dynamic-imports cli.ts
└─ cli.ts Wires SDK managers, loads extensions, starts InteractiveMode └─ cli.ts Wires SDK managers, loads extensions, starts InteractiveMode
├─ wizard.ts First-run API key collection (Brave/Context7/Jina) ├─ wizard.ts First-run API key collection (Brave/Gemini/Context7/Jina)
├─ app-paths.ts ~/.gsd/agent/, ~/.gsd/sessions/, auth.json ├─ app-paths.ts ~/.gsd/agent/, ~/.gsd/sessions/, auth.json
├─ resource-loader.ts Syncs bundled extensions + agents to ~/.gsd/agent/ ├─ resource-loader.ts Syncs bundled extensions + agents to ~/.gsd/agent/
└─ src/resources/ └─ src/resources/
├─ extensions/gsd/ Core GSD extension (auto, state, commands, ...) ├─ extensions/gsd/ Core GSD extension (auto, state, commands, ...)
├─ extensions/... 10 supporting extensions ├─ extensions/... 12 supporting extensions
├─ agents/ scout, researcher, worker ├─ agents/ scout, researcher, worker
├─ AGENTS.md Agent routing instructions ├─ AGENTS.md Agent routing instructions
└─ GSD-WORKFLOW.md Manual bootstrap protocol └─ GSD-WORKFLOW.md Manual bootstrap protocol
@ -373,6 +386,7 @@ gsd (CLI binary)
Optional: Optional:
- Brave Search API key (web research) - Brave Search API key (web research)
- Google Gemini API key (web research via Gemini Search grounding)
- Context7 API key (library docs) - Context7 API key (library docs)
- Jina API key (page extraction) - Jina API key (page extraction)

View file

@ -1,207 +0,0 @@
/**
* Formatters produce text summaries for issues, PRs, comments, etc.
*
* Used by both tools (LLM context) and renderers (TUI display).
*/
import type { GhIssue, GhPullRequest, GhComment, GhReview, GhLabel, GhMilestone } from "./gh-api.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function stateIcon(state: string, draft?: boolean): string {
if (draft) return "◇";
switch (state) {
case "open":
return "●";
case "closed":
return "✓";
case "merged":
return "⊕";
default:
return "○";
}
}
function truncateBody(body: string | null, maxLines = 10): string {
if (!body) return "(no description)";
const lines = body.split("\n");
if (lines.length <= maxLines) return body;
return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines} more lines)`;
}
// ─── Issue formatting ─────────────────────────────────────────────────────────
export function formatIssueOneLiner(issue: GhIssue): string {
const icon = stateIcon(issue.state);
const labels = issue.labels.map((l) => l.name).join(", ");
const labelStr = labels ? ` [${labels}]` : "";
const assignee = issue.assignees.length ? `${issue.assignees.map((a) => a.login).join(", ")}` : "";
return `${icon} #${issue.number} ${issue.title}${labelStr}${assignee} (${timeAgo(issue.updated_at)})`;
}
export function formatIssueDetail(issue: GhIssue): string {
const lines: string[] = [];
lines.push(`# Issue #${issue.number}: ${issue.title}`);
lines.push(`State: ${issue.state} | Author: @${issue.user.login} | Created: ${timeAgo(issue.created_at)} | Updated: ${timeAgo(issue.updated_at)}`);
if (issue.assignees.length) {
lines.push(`Assignees: ${issue.assignees.map((a) => `@${a.login}`).join(", ")}`);
}
if (issue.labels.length) {
lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`);
}
if (issue.milestone) {
lines.push(`Milestone: ${issue.milestone.title}`);
}
lines.push(`Comments: ${issue.comments}`);
lines.push(`URL: ${issue.html_url}`);
lines.push("");
lines.push(truncateBody(issue.body, 30));
return lines.join("\n");
}
export function formatIssueList(issues: GhIssue[]): string {
if (!issues.length) return "No issues found.";
return issues.map(formatIssueOneLiner).join("\n");
}
// ─── PR formatting ────────────────────────────────────────────────────────────
export function formatPROneLiner(pr: GhPullRequest): string {
const icon = stateIcon(pr.merged_at ? "merged" : pr.state, pr.draft);
const labels = pr.labels.map((l) => l.name).join(", ");
const labelStr = labels ? ` [${labels}]` : "";
const draftStr = pr.draft ? " (draft)" : "";
const reviewers = pr.requested_reviewers.map((r) => r.login).join(", ");
const reviewerStr = reviewers ? `${reviewers}` : "";
return `${icon} #${pr.number} ${pr.title}${draftStr}${labelStr}${reviewerStr} (${timeAgo(pr.updated_at)})`;
}
export function formatPRDetail(pr: GhPullRequest): string {
const lines: string[] = [];
const mergedState = pr.merged_at ? "merged" : pr.state;
lines.push(`# PR #${pr.number}: ${pr.title}`);
lines.push(`State: ${mergedState}${pr.draft ? " (draft)" : ""} | Author: @${pr.user.login} | Created: ${timeAgo(pr.created_at)} | Updated: ${timeAgo(pr.updated_at)}`);
lines.push(`Branch: ${pr.head.ref}${pr.base.ref}`);
if (pr.assignees.length) {
lines.push(`Assignees: ${pr.assignees.map((a) => `@${a.login}`).join(", ")}`);
}
if (pr.labels.length) {
lines.push(`Labels: ${pr.labels.map((l) => l.name).join(", ")}`);
}
if (pr.milestone) {
lines.push(`Milestone: ${pr.milestone.title}`);
}
if (pr.requested_reviewers.length) {
lines.push(`Reviewers: ${pr.requested_reviewers.map((r) => `@${r.login}`).join(", ")}`);
}
lines.push(`Mergeable: ${pr.mergeable === null ? "checking..." : pr.mergeable ? "yes" : "no"} (${pr.mergeable_state})`);
lines.push(`Comments: ${pr.comments} | Review comments: ${pr.review_comments}`);
lines.push(`URL: ${pr.html_url}`);
lines.push("");
lines.push(truncateBody(pr.body, 30));
return lines.join("\n");
}
export function formatPRList(prs: GhPullRequest[]): string {
if (!prs.length) return "No pull requests found.";
return prs.map(formatPROneLiner).join("\n");
}
// ─── Comment formatting ──────────────────────────────────────────────────────
export function formatComment(comment: GhComment): string {
return `@${comment.user.login} (${timeAgo(comment.created_at)}):\n${truncateBody(comment.body, 8)}`;
}
export function formatCommentList(comments: GhComment[]): string {
if (!comments.length) return "No comments.";
return comments.map(formatComment).join("\n\n---\n\n");
}
// ─── Review formatting ───────────────────────────────────────────────────────
function reviewStateIcon(state: string): string {
switch (state) {
case "APPROVED":
return "✓";
case "CHANGES_REQUESTED":
return "✗";
case "COMMENTED":
return "💬";
case "DISMISSED":
return "—";
case "PENDING":
return "…";
default:
return "?";
}
}
export function formatReview(review: GhReview): string {
const icon = reviewStateIcon(review.state);
const body = review.body ? `\n${truncateBody(review.body, 5)}` : "";
return `${icon} @${review.user.login}: ${review.state} (${timeAgo(review.submitted_at)})${body}`;
}
export function formatReviewList(reviews: GhReview[]): string {
if (!reviews.length) return "No reviews.";
return reviews.map(formatReview).join("\n\n");
}
// ─── Label / Milestone formatting ─────────────────────────────────────────────
export function formatLabel(label: GhLabel): string {
const desc = label.description ? `${label.description}` : "";
return `${label.name} (#${label.color})${desc}`;
}
export function formatLabelList(labels: GhLabel[]): string {
if (!labels.length) return "No labels.";
return labels.map(formatLabel).join("\n");
}
export function formatMilestone(ms: GhMilestone): string {
const progress = ms.open_issues + ms.closed_issues > 0 ? Math.round((ms.closed_issues / (ms.open_issues + ms.closed_issues)) * 100) : 0;
const due = ms.due_on ? ` | Due: ${new Date(ms.due_on).toISOString().split("T")[0]}` : "";
return `${ms.title} (${ms.state}) — ${progress}% complete (${ms.closed_issues}/${ms.open_issues + ms.closed_issues})${due}`;
}
export function formatMilestoneList(milestones: GhMilestone[]): string {
if (!milestones.length) return "No milestones.";
return milestones.map(formatMilestone).join("\n");
}
// ─── File change formatting ───────────────────────────────────────────────────
export function formatFileChanges(
files: { filename: string; status: string; additions: number; deletions: number; changes: number }[],
): string {
if (!files.length) return "No files changed.";
const lines = files.map((f) => {
const statusIcon = f.status === "added" ? "+" : f.status === "removed" ? "-" : "~";
return `${statusIcon} ${f.filename} (+${f.additions} -${f.deletions})`;
});
const totalAdd = files.reduce((s, f) => s + f.additions, 0);
const totalDel = files.reduce((s, f) => s + f.deletions, 0);
lines.push(`\n${files.length} files changed, +${totalAdd} -${totalDel}`);
return lines.join("\n");
}

View file

@ -1,553 +0,0 @@
/**
* GitHub API layer wraps `gh` CLI with fallback to GITHUB_TOKEN + fetch.
*
* All GitHub communication goes through this module.
* Prefers `gh api` when the CLI is available and authenticated.
* Falls back to raw REST API with GITHUB_TOKEN env var.
*/
import { execSync, spawnSync, type SpawnSyncReturns } from "node:child_process";
// ─── Auth detection ───────────────────────────────────────────────────────────
let _useGhCli: boolean | null = null;
let ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> =>
spawnSync("gh", args, {
cwd,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
input,
});
function ghSpawn(args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> {
return ghSpawnImpl(args, input, cwd);
}
export function resetGhCliDetectionForTests(): void {
_useGhCli = null;
ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> =>
spawnSync("gh", args, {
cwd,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
input,
});
}
export function setGhSpawnForTests(fn: (args: string[], input?: string, cwd?: string) => SpawnSyncReturns<string>): void {
ghSpawnImpl = fn;
_useGhCli = null;
}
export function hasGhCli(): boolean {
if (_useGhCli !== null) return _useGhCli;
const result = ghSpawn(["auth", "token"]);
_useGhCli = result.status === 0 && !result.error && !!result.stdout?.trim();
return _useGhCli;
}
function getToken(): string | undefined {
return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
}
export function isAuthenticated(): boolean {
return hasGhCli() || !!getToken();
}
export function authMethod(): string {
if (hasGhCli()) return "gh CLI";
if (getToken()) return "GITHUB_TOKEN";
return "none";
}
// ─── Repo detection ───────────────────────────────────────────────────────────
export interface RepoInfo {
owner: string;
repo: string;
fullName: string;
}
export function detectRepo(cwd: string): RepoInfo | null {
try {
const remote = execSync("git remote get-url origin", {
cwd,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
// Handle SSH: git@github.com:owner/repo.git
// Handle HTTPS: https://github.com/owner/repo.git
const sshMatch = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2], fullName: `${sshMatch[1]}/${sshMatch[2]}` };
}
return null;
} catch {
return null;
}
}
export function getCurrentBranch(cwd: string): string | null {
try {
return execSync("git rev-parse --abbrev-ref HEAD", {
cwd,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
return null;
}
}
export function getDefaultBranch(cwd: string): string {
try {
const result = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo refs/remotes/origin/main", {
cwd,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
return result.replace("refs/remotes/origin/", "");
} catch {
return "main";
}
}
// ─── API calls ────────────────────────────────────────────────────────────────
/**
* Call the GitHub REST API. Returns parsed JSON.
*
* When method is GET and params are provided, they're appended as query params.
* When method is POST/PUT/PATCH/DELETE, params are sent as JSON body.
*/
export async function ghApi<T = unknown>(
endpoint: string,
options: {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
params?: Record<string, string | number | boolean | string[] | undefined>;
body?: Record<string, unknown>;
cwd?: string;
} = {},
): Promise<T> {
const method = options.method ?? "GET";
if (hasGhCli()) {
return ghCliApi<T>(endpoint, method, options.params, options.body, options.cwd);
}
const token = getToken();
if (!token) throw new Error("Not authenticated. Install gh CLI or set GITHUB_TOKEN.");
return fetchApi<T>(endpoint, method, options.params, options.body, token);
}
function ghCliApi<T>(
endpoint: string,
method: string,
params?: Record<string, string | number | boolean | string[] | undefined>,
body?: Record<string, unknown>,
cwd?: string,
): T {
const args = ["api", endpoint, "--method", method];
if (params) {
for (const [key, val] of Object.entries(params)) {
if (val === undefined) continue;
if (Array.isArray(val)) {
for (const v of val) {
args.push("-f", `${key}[]=${v}`);
}
} else {
args.push("-f", `${key}=${String(val)}`);
}
}
}
if (body) {
args.push("--input", "-");
}
const result = ghSpawn(args, body ? JSON.stringify(body) : undefined, cwd ?? process.cwd());
const stdout = result.stdout?.trim() ?? "";
const stderr = result.stderr?.trim() ?? "";
if (result.status !== 0) {
throw new Error(`gh api error: ${stderr || stdout || result.error?.message || `exit code ${result.status}`}`);
}
if (!stdout) return {} as T;
return JSON.parse(stdout) as T;
}
async function fetchApi<T>(
endpoint: string,
method: string,
params?: Record<string, string | number | boolean | string[] | undefined>,
body?: Record<string, unknown>,
token?: string,
): Promise<T> {
let url = endpoint.startsWith("http") ? endpoint : `https://api.github.com${endpoint}`;
if (method === "GET" && params) {
const qs = new URLSearchParams();
for (const [key, val] of Object.entries(params)) {
if (val === undefined) continue;
if (Array.isArray(val)) {
for (const v of val) qs.append(key, v);
} else {
qs.set(key, String(val));
}
}
const qsStr = qs.toString();
if (qsStr) url += `?${qsStr}`;
}
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, {
method,
headers,
body: method !== "GET" && body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GitHub API ${res.status}: ${text}`);
}
const text = await res.text();
if (!text.trim()) return {} as T;
return JSON.parse(text) as T;
}
// ─── Typed API wrappers ───────────────────────────────────────────────────────
export interface GhIssue {
number: number;
title: string;
state: string;
body: string | null;
user: { login: string };
labels: { name: string; color: string }[];
assignees: { login: string }[];
milestone: { title: string; number: number } | null;
created_at: string;
updated_at: string;
closed_at: string | null;
comments: number;
html_url: string;
pull_request?: { url: string };
}
export interface GhPullRequest {
number: number;
title: string;
state: string;
body: string | null;
user: { login: string };
labels: { name: string; color: string }[];
assignees: { login: string }[];
milestone: { title: string; number: number } | null;
head: { ref: string; sha: string };
base: { ref: string };
created_at: string;
updated_at: string;
merged_at: string | null;
closed_at: string | null;
comments: number;
review_comments: number;
draft: boolean;
mergeable: boolean | null;
mergeable_state: string;
html_url: string;
diff_url: string;
requested_reviewers: { login: string }[];
}
export interface GhComment {
id: number;
body: string;
user: { login: string };
created_at: string;
updated_at: string;
html_url: string;
}
export interface GhLabel {
name: string;
color: string;
description: string | null;
}
export interface GhMilestone {
number: number;
title: string;
description: string | null;
state: string;
open_issues: number;
closed_issues: number;
due_on: string | null;
}
export interface GhReview {
id: number;
user: { login: string };
state: string;
body: string | null;
submitted_at: string;
html_url: string;
}
export interface GhCheckRun {
name: string;
status: string;
conclusion: string | null;
html_url: string;
}
// ─── Issues ───────────────────────────────────────────────────────────────────
export async function listIssues(
repo: RepoInfo,
options: {
state?: "open" | "closed" | "all";
labels?: string;
assignee?: string;
milestone?: string;
sort?: "created" | "updated" | "comments";
direction?: "asc" | "desc";
per_page?: number;
page?: number;
} = {},
): Promise<GhIssue[]> {
const params: Record<string, string | number | undefined> = {
state: options.state ?? "open",
sort: options.sort ?? "updated",
direction: options.direction ?? "desc",
per_page: String(options.per_page ?? 30),
page: String(options.page ?? 1),
};
if (options.labels) params.labels = options.labels;
if (options.assignee) params.assignee = options.assignee;
if (options.milestone) params.milestone = options.milestone;
const issues = await ghApi<GhIssue[]>(`/repos/${repo.fullName}/issues`, { params });
// Filter out PRs (GitHub API returns PRs in issues endpoint)
return issues.filter((i) => !i.pull_request);
}
export async function getIssue(repo: RepoInfo, number: number): Promise<GhIssue> {
return ghApi<GhIssue>(`/repos/${repo.fullName}/issues/${number}`);
}
export async function createIssue(
repo: RepoInfo,
data: { title: string; body?: string; labels?: string[]; assignees?: string[]; milestone?: number },
): Promise<GhIssue> {
return ghApi<GhIssue>(`/repos/${repo.fullName}/issues`, {
method: "POST",
body: data,
});
}
export async function updateIssue(
repo: RepoInfo,
number: number,
data: { title?: string; body?: string; state?: string; labels?: string[]; assignees?: string[]; milestone?: number | null },
): Promise<GhIssue> {
return ghApi<GhIssue>(`/repos/${repo.fullName}/issues/${number}`, {
method: "PATCH",
body: data,
});
}
export async function addComment(repo: RepoInfo, number: number, body: string): Promise<GhComment> {
return ghApi<GhComment>(`/repos/${repo.fullName}/issues/${number}/comments`, {
method: "POST",
body: { body },
});
}
export async function listComments(repo: RepoInfo, number: number): Promise<GhComment[]> {
return ghApi<GhComment[]>(`/repos/${repo.fullName}/issues/${number}/comments`);
}
// ─── Pull Requests ────────────────────────────────────────────────────────────
export async function listPullRequests(
repo: RepoInfo,
options: {
state?: "open" | "closed" | "all";
sort?: "created" | "updated" | "popularity" | "long-running";
direction?: "asc" | "desc";
per_page?: number;
page?: number;
head?: string;
base?: string;
} = {},
): Promise<GhPullRequest[]> {
const params: Record<string, string | number | undefined> = {
state: options.state ?? "open",
sort: options.sort ?? "updated",
direction: options.direction ?? "desc",
per_page: String(options.per_page ?? 30),
page: String(options.page ?? 1),
};
if (options.head) params.head = options.head;
if (options.base) params.base = options.base;
return ghApi<GhPullRequest[]>(`/repos/${repo.fullName}/pulls`, { params });
}
export async function getPullRequest(repo: RepoInfo, number: number): Promise<GhPullRequest> {
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls/${number}`);
}
export async function createPullRequest(
repo: RepoInfo,
data: { title: string; body?: string; head: string; base: string; draft?: boolean },
): Promise<GhPullRequest> {
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls`, {
method: "POST",
body: data,
});
}
export async function updatePullRequest(
repo: RepoInfo,
number: number,
data: { title?: string; body?: string; state?: string; base?: string },
): Promise<GhPullRequest> {
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls/${number}`, {
method: "PATCH",
body: data,
});
}
export async function getPullRequestDiff(repo: RepoInfo, number: number): Promise<string> {
if (hasGhCli()) {
try {
return execSync(`gh pr diff ${number} --repo ${repo.fullName}`, {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e: unknown) {
const err = e as { stderr?: string; message?: string };
throw new Error(err.stderr?.trim() || err.message || String(e));
}
}
const token = getToken();
const headers: Record<string, string> = {
Accept: "application/vnd.github.v3.diff",
"X-GitHub-Api-Version": "2022-11-28",
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`https://api.github.com/repos/${repo.fullName}/pulls/${number}`, { headers });
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
return res.text();
}
export async function listPullRequestFiles(
repo: RepoInfo,
number: number,
): Promise<{ filename: string; status: string; additions: number; deletions: number; changes: number }[]> {
return ghApi(`/repos/${repo.fullName}/pulls/${number}/files`);
}
// ─── Reviews ──────────────────────────────────────────────────────────────────
export async function listReviews(repo: RepoInfo, number: number): Promise<GhReview[]> {
return ghApi<GhReview[]>(`/repos/${repo.fullName}/pulls/${number}/reviews`);
}
export async function createReview(
repo: RepoInfo,
number: number,
data: { body?: string; event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT" },
): Promise<GhReview> {
return ghApi<GhReview>(`/repos/${repo.fullName}/pulls/${number}/reviews`, {
method: "POST",
body: data,
});
}
export async function requestReviewers(
repo: RepoInfo,
number: number,
reviewers: string[],
): Promise<GhPullRequest> {
return ghApi<GhPullRequest>(`/repos/${repo.fullName}/pulls/${number}/requested_reviewers`, {
method: "POST",
body: { reviewers },
});
}
// ─── Checks ───────────────────────────────────────────────────────────────────
export async function listCheckRuns(repo: RepoInfo, ref: string): Promise<{ check_runs: GhCheckRun[] }> {
return ghApi(`/repos/${repo.fullName}/commits/${ref}/check-runs`);
}
// ─── Labels & Milestones ──────────────────────────────────────────────────────
export async function listLabels(repo: RepoInfo): Promise<GhLabel[]> {
return ghApi<GhLabel[]>(`/repos/${repo.fullName}/labels`, {
params: { per_page: "100" },
});
}
export async function createLabel(
repo: RepoInfo,
data: { name: string; color: string; description?: string },
): Promise<GhLabel> {
return ghApi<GhLabel>(`/repos/${repo.fullName}/labels`, {
method: "POST",
body: data,
});
}
export async function listMilestones(repo: RepoInfo): Promise<GhMilestone[]> {
return ghApi<GhMilestone[]>(`/repos/${repo.fullName}/milestones`, {
params: { state: "all", per_page: "100" },
});
}
export async function createMilestone(
repo: RepoInfo,
data: { title: string; description?: string; due_on?: string },
): Promise<GhMilestone> {
return ghApi<GhMilestone>(`/repos/${repo.fullName}/milestones`, {
method: "POST",
body: data,
});
}
// ─── Search ───────────────────────────────────────────────────────────────────
export interface GhSearchResult<T> {
total_count: number;
items: T[];
}
export async function searchIssues(
query: string,
options: { per_page?: number; page?: number } = {},
): Promise<GhSearchResult<GhIssue>> {
return ghApi<GhSearchResult<GhIssue>>("/search/issues", {
params: {
q: query,
per_page: String(options.per_page ?? 30),
page: String(options.page ?? 1),
},
});
}

View file

@ -1,778 +0,0 @@
/**
* GitHub Extension /gh
*
* Full-suite GitHub issues and PR tracker/helper for pi.
* Provides LLM tools + /gh slash command for managing issues, PRs,
* reviews, labels, milestones, and comments.
*
* Auth: gh CLI (preferred) GITHUB_TOKEN env var (fallback)
*
* Tools:
* github_issues list, view, create, update, close, search issues
* github_prs list, view, create, update, diff, files, checks for PRs
* github_comments list, add comments on issues/PRs
* github_reviews list, create reviews, request reviewers
* github_labels list, create labels; list, create milestones
*
* Commands:
* /gh issues [state] browse issues
* /gh prs [state] browse PRs
* /gh view <number> view issue or PR detail
* /gh create issue create issue interactively
* /gh create pr create PR from current branch
* /gh labels list labels
* /gh milestones list milestones
* /gh status show auth + repo status
*/
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
import { showConfirm } from "../shared/confirm-ui.js";
import {
isAuthenticated,
authMethod,
detectRepo,
getCurrentBranch,
getDefaultBranch,
type RepoInfo,
listIssues,
getIssue,
createIssue,
updateIssue,
addComment,
listComments,
listPullRequests,
getPullRequest,
createPullRequest,
updatePullRequest,
getPullRequestDiff,
listPullRequestFiles,
listReviews,
createReview,
requestReviewers,
listCheckRuns,
listLabels,
createLabel,
listMilestones,
createMilestone,
searchIssues,
} from "./gh-api.js";
import {
formatIssueList,
formatIssueDetail,
formatPRList,
formatPRDetail,
formatCommentList,
formatReviewList,
formatFileChanges,
formatLabelList,
formatMilestoneList,
} from "./formatters.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function requireRepo(cwd: string): RepoInfo {
const repo = detectRepo(cwd);
if (!repo) throw new Error("Not in a GitHub repository. Run this from a git repo with a GitHub remote.");
return repo;
}
function requireAuth(): void {
if (!isAuthenticated()) {
throw new Error("Not authenticated to GitHub. Install and authenticate `gh` CLI, or set GITHUB_TOKEN env var.");
}
}
function truncateOutput(text: string): string {
const result = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
if (result.truncated) {
return result.content + `\n\n[Output truncated: showing ${result.outputLines}/${result.totalLines} lines]`;
}
return result.content;
}
function textResult(text: string, details?: Record<string, unknown>) {
return {
content: [{ type: "text" as const, text: truncateOutput(text) }],
...(details ? { details } : {}),
};
}
/**
* Confirmation gate for outward-facing GitHub actions.
* Shows a themed yes/no confirmation in interactive mode.
* In non-interactive mode (no UI), blocks the action.
* Returns the rejected textResult if denied, or undefined if confirmed.
*/
async function confirmAction(
ctx: ExtensionContext,
action: string,
): Promise<ReturnType<typeof textResult> | undefined> {
if (!ctx.hasUI) {
return textResult(`Blocked: "${action}" requires user confirmation but no UI is available.`);
}
const confirmed = await showConfirm(ctx, {
title: "GitHub",
message: action,
});
if (!confirmed) {
return textResult(`Cancelled: user declined "${action}".`);
}
return undefined;
}
// ─── Extension ────────────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
// ─── Tool: github_issues ────────────────────────────────────────────────
pi.registerTool({
name: "github_issues",
label: "GitHub Issues",
description: "Manage GitHub issues: list, view, create, update, close, reopen, or search issues in the current repository.",
promptSnippet: "List, view, create, update, close, reopen, or search GitHub issues",
promptGuidelines: [
"Use github_issues to interact with GitHub issues instead of running `gh` CLI commands directly.",
"When listing issues, default to state='open' and include relevant filters like labels or assignee.",
"When searching, use GitHub search syntax in the query (e.g., 'is:open label:bug').",
"Mutating actions (create, update, close, reopen) require user confirmation before executing.",
],
parameters: Type.Object({
action: StringEnum(["list", "view", "create", "update", "close", "reopen", "search"] as const),
number: Type.Optional(Type.Number({ description: "Issue number (for view/update/close/reopen)" })),
title: Type.Optional(Type.String({ description: "Issue title (for create)" })),
body: Type.Optional(Type.String({ description: "Issue body (for create/update)" })),
labels: Type.Optional(Type.String({ description: "Comma-separated labels (for list filter or create/update)" })),
assignee: Type.Optional(Type.String({ description: "Assignee username (for list filter or create/update)" })),
assignees: Type.Optional(Type.String({ description: "Comma-separated assignees (for create/update)" })),
milestone: Type.Optional(Type.String({ description: "Milestone number or title (for list filter)" })),
state: Type.Optional(StringEnum(["open", "closed", "all"] as const)),
query: Type.Optional(Type.String({ description: "Search query using GitHub search syntax (for search action)" })),
per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
requireAuth();
const repo = requireRepo(ctx.cwd);
switch (params.action) {
case "list": {
const issues = await listIssues(repo, {
state: params.state,
labels: params.labels,
assignee: params.assignee,
milestone: params.milestone,
per_page: params.per_page,
});
return textResult(
`Issues in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatIssueList(issues)}`,
{ issues: issues.map((i) => ({ number: i.number, title: i.title, state: i.state })) },
);
}
case "view": {
if (!params.number) return textResult("Error: 'number' is required for view action.");
const issue = await getIssue(repo, params.number);
const comments = await listComments(repo, params.number);
let text = formatIssueDetail(issue);
if (comments.length) {
text += `\n\n## Comments (${comments.length})\n\n${formatCommentList(comments)}`;
}
return textResult(text, { issue: { number: issue.number, title: issue.title, state: issue.state } });
}
case "create": {
if (!params.title) return textResult("Error: 'title' is required for create action.");
const createGate = await confirmAction(ctx, `Create issue "${params.title}"?`);
if (createGate) return createGate;
const newIssue = await createIssue(repo, {
title: params.title,
body: params.body,
labels: params.labels?.split(",").map((l) => l.trim()),
assignees: params.assignees?.split(",").map((a) => a.trim()),
});
return textResult(
`Created issue #${newIssue.number}: ${newIssue.title}\n${newIssue.html_url}`,
{ issue: { number: newIssue.number, title: newIssue.title } },
);
}
case "update": {
if (!params.number) return textResult("Error: 'number' is required for update action.");
const updateGate = await confirmAction(ctx, `Update issue #${params.number}?`);
if (updateGate) return updateGate;
const updated = await updateIssue(repo, params.number, {
title: params.title,
body: params.body,
labels: params.labels?.split(",").map((l) => l.trim()),
assignees: params.assignees?.split(",").map((a) => a.trim()),
});
return textResult(
`Updated issue #${updated.number}: ${updated.title}\n${updated.html_url}`,
{ issue: { number: updated.number, title: updated.title } },
);
}
case "close": {
if (!params.number) return textResult("Error: 'number' is required for close action.");
const closeGate = await confirmAction(ctx, `Close issue #${params.number}?`);
if (closeGate) return closeGate;
const closed = await updateIssue(repo, params.number, { state: "closed" });
return textResult(`Closed issue #${closed.number}: ${closed.title}`, { issue: { number: closed.number } });
}
case "reopen": {
if (!params.number) return textResult("Error: 'number' is required for reopen action.");
const reopenGate = await confirmAction(ctx, `Reopen issue #${params.number}?`);
if (reopenGate) return reopenGate;
const reopened = await updateIssue(repo, params.number, { state: "open" });
return textResult(`Reopened issue #${reopened.number}: ${reopened.title}`, { issue: { number: reopened.number } });
}
case "search": {
if (!params.query) return textResult("Error: 'query' is required for search action.");
const q = `repo:${repo.fullName} ${params.query}`;
const results = await searchIssues(q, { per_page: params.per_page });
const issuesOnly = results.items.filter((i) => !i.pull_request);
return textResult(
`Search results (${results.total_count} total, showing ${issuesOnly.length}):\n\n${formatIssueList(issuesOnly)}`,
{ total: results.total_count },
);
}
default:
return textResult(`Unknown action: ${params.action}`);
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("github_issues "));
text += theme.fg("muted", `${args.action ?? "?"}`);
if (args.number) text += theme.fg("accent", ` #${args.number}`);
if (args.title) text += theme.fg("dim", ` "${args.title}"`);
if (args.query) text += theme.fg("dim", ` "${args.query}"`);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0);
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
if (!expanded) {
const firstLine = content.split("\n")[0] ?? "";
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
}
return new Text(content, 0, 0);
},
});
// ─── Tool: github_prs ───────────────────────────────────────────────────
pi.registerTool({
name: "github_prs",
label: "GitHub PRs",
description: "Manage GitHub pull requests: list, view, create, update, get diff, list files, and check CI status.",
promptSnippet: "List, view, create, update, diff, files, and checks for GitHub pull requests",
promptGuidelines: [
"Use github_prs to interact with GitHub pull requests instead of running `gh` CLI commands directly.",
"Use action='diff' to see the actual code changes in a PR.",
"Use action='files' for a summary of changed files without the full diff.",
"Use action='checks' to see CI/CD status for a PR.",
"Mutating actions (create, update) require user confirmation before executing.",
],
parameters: Type.Object({
action: StringEnum(["list", "view", "create", "update", "diff", "files", "checks"] as const),
number: Type.Optional(Type.Number({ description: "PR number (for view/update/diff/files/checks)" })),
title: Type.Optional(Type.String({ description: "PR title (for create)" })),
body: Type.Optional(Type.String({ description: "PR body (for create/update)" })),
head: Type.Optional(Type.String({ description: "Head branch (for create, defaults to current branch)" })),
base: Type.Optional(Type.String({ description: "Base branch (for create, defaults to repo default branch)" })),
draft: Type.Optional(Type.Boolean({ description: "Create as draft PR (for create)" })),
state: Type.Optional(StringEnum(["open", "closed", "all"] as const)),
per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
requireAuth();
const repo = requireRepo(ctx.cwd);
switch (params.action) {
case "list": {
const prs = await listPullRequests(repo, {
state: params.state,
per_page: params.per_page,
});
return textResult(
`Pull requests in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatPRList(prs)}`,
{ prs: prs.map((p) => ({ number: p.number, title: p.title, state: p.state, draft: p.draft })) },
);
}
case "view": {
if (!params.number) return textResult("Error: 'number' is required for view action.");
const pr = await getPullRequest(repo, params.number);
const reviews = await listReviews(repo, params.number);
let text = formatPRDetail(pr);
if (reviews.length) {
text += `\n\n## Reviews (${reviews.length})\n\n${formatReviewList(reviews)}`;
}
return textResult(text, { pr: { number: pr.number, title: pr.title, state: pr.state } });
}
case "create": {
if (!params.title) return textResult("Error: 'title' is required for create action.");
const head = params.head ?? getCurrentBranch(ctx.cwd);
if (!head) return textResult("Error: Could not determine current branch. Provide 'head' parameter.");
const base = params.base ?? getDefaultBranch(ctx.cwd);
const createPRGate = await confirmAction(ctx, `Create PR "${params.title}" (${head}${base})?`);
if (createPRGate) return createPRGate;
const newPR = await createPullRequest(repo, {
title: params.title,
body: params.body,
head,
base,
draft: params.draft,
});
return textResult(
`Created PR #${newPR.number}: ${newPR.title}\n${newPR.head.ref}${newPR.base.ref}\n${newPR.html_url}`,
{ pr: { number: newPR.number, title: newPR.title } },
);
}
case "update": {
if (!params.number) return textResult("Error: 'number' is required for update action.");
const updatePRGate = await confirmAction(ctx, `Update PR #${params.number}?`);
if (updatePRGate) return updatePRGate;
const updated = await updatePullRequest(repo, params.number, {
title: params.title,
body: params.body,
base: params.base,
});
return textResult(
`Updated PR #${updated.number}: ${updated.title}\n${updated.html_url}`,
{ pr: { number: updated.number, title: updated.title } },
);
}
case "diff": {
if (!params.number) return textResult("Error: 'number' is required for diff action.");
const diff = await getPullRequestDiff(repo, params.number);
return textResult(`Diff for PR #${params.number}:\n\n${diff}`);
}
case "files": {
if (!params.number) return textResult("Error: 'number' is required for files action.");
const files = await listPullRequestFiles(repo, params.number);
return textResult(
`Changed files in PR #${params.number}:\n\n${formatFileChanges(files)}`,
{ files: files.map((f) => ({ filename: f.filename, status: f.status })) },
);
}
case "checks": {
if (!params.number) return textResult("Error: 'number' is required for checks action.");
const pr = await getPullRequest(repo, params.number);
const checks = await listCheckRuns(repo, pr.head.sha);
if (!checks.check_runs.length) {
return textResult(`No CI checks found for PR #${params.number}.`);
}
const lines = checks.check_runs.map((c) => {
const icon = c.conclusion === "success" ? "✓" : c.conclusion === "failure" ? "✗" : c.status === "in_progress" ? "⟳" : "…";
return `${icon} ${c.name}: ${c.conclusion ?? c.status}`;
});
return textResult(
`CI checks for PR #${params.number}:\n\n${lines.join("\n")}`,
{ checks: checks.check_runs.map((c) => ({ name: c.name, conclusion: c.conclusion, status: c.status })) },
);
}
default:
return textResult(`Unknown action: ${params.action}`);
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("github_prs "));
text += theme.fg("muted", `${args.action ?? "?"}`);
if (args.number) text += theme.fg("accent", ` #${args.number}`);
if (args.title) text += theme.fg("dim", ` "${args.title}"`);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0);
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
if (!expanded) {
const firstLine = content.split("\n")[0] ?? "";
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
}
return new Text(content, 0, 0);
},
});
// ─── Tool: github_comments ──────────────────────────────────────────────
pi.registerTool({
name: "github_comments",
label: "GitHub Comments",
description: "List or add comments on GitHub issues and pull requests.",
promptSnippet: "List or add comments on GitHub issues and PRs",
parameters: Type.Object({
action: StringEnum(["list", "add"] as const),
number: Type.Number({ description: "Issue or PR number" }),
body: Type.Optional(Type.String({ description: "Comment body text (for add)" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
requireAuth();
const repo = requireRepo(ctx.cwd);
switch (params.action) {
case "list": {
const comments = await listComments(repo, params.number);
return textResult(
`Comments on #${params.number} (${comments.length}):\n\n${formatCommentList(comments)}`,
{ count: comments.length },
);
}
case "add": {
if (!params.body) return textResult("Error: 'body' is required for add action.");
const addGate = await confirmAction(ctx, `Add comment on #${params.number}?`);
if (addGate) return addGate;
const comment = await addComment(repo, params.number, params.body);
return textResult(
`Added comment on #${params.number}: ${comment.html_url}`,
{ comment: { id: comment.id } },
);
}
default:
return textResult(`Unknown action: ${params.action}`);
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("github_comments "));
text += theme.fg("muted", `${args.action ?? "?"}`);
text += theme.fg("accent", ` #${args.number ?? "?"}`);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0);
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
if (!expanded) {
const firstLine = content.split("\n")[0] ?? "";
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
}
return new Text(content, 0, 0);
},
});
// ─── Tool: github_reviews ───────────────────────────────────────────────
pi.registerTool({
name: "github_reviews",
label: "GitHub Reviews",
description: "Manage GitHub PR reviews: list reviews, submit a review (approve/request changes/comment), or request reviewers.",
promptSnippet: "List reviews, submit reviews, or request reviewers on GitHub PRs",
promptGuidelines: [
"Use event='APPROVE' to approve, 'REQUEST_CHANGES' to request changes, 'COMMENT' for a general review comment.",
"Use action='request_reviewers' to assign reviewers to a PR.",
],
parameters: Type.Object({
action: StringEnum(["list", "submit", "request_reviewers"] as const),
number: Type.Number({ description: "PR number" }),
body: Type.Optional(Type.String({ description: "Review body text (for submit)" })),
event: Type.Optional(StringEnum(["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const)),
reviewers: Type.Optional(Type.String({ description: "Comma-separated reviewer usernames (for request_reviewers)" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
requireAuth();
const repo = requireRepo(ctx.cwd);
switch (params.action) {
case "list": {
const reviews = await listReviews(repo, params.number);
return textResult(
`Reviews on PR #${params.number} (${reviews.length}):\n\n${formatReviewList(reviews)}`,
{ count: reviews.length },
);
}
case "submit": {
if (!params.event) return textResult("Error: 'event' is required for submit action (APPROVE, REQUEST_CHANGES, or COMMENT).");
const submitGate = await confirmAction(ctx, `Submit ${params.event} review on PR #${params.number}?`);
if (submitGate) return submitGate;
const review = await createReview(repo, params.number, {
body: params.body,
event: params.event,
});
return textResult(
`Submitted review on PR #${params.number}: ${review.state}\n${review.html_url}`,
{ review: { id: review.id, state: review.state } },
);
}
case "request_reviewers": {
if (!params.reviewers) return textResult("Error: 'reviewers' is required for request_reviewers action.");
const reviewerList = params.reviewers.split(",").map((r) => r.trim());
const reviewersGate = await confirmAction(ctx, `Request reviewers on PR #${params.number}: ${reviewerList.join(", ")}?`);
if (reviewersGate) return reviewersGate;
await requestReviewers(repo, params.number, reviewerList);
return textResult(
`Requested reviewers on PR #${params.number}: ${reviewerList.join(", ")}`,
{ reviewers: reviewerList },
);
}
default:
return textResult(`Unknown action: ${params.action}`);
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("github_reviews "));
text += theme.fg("muted", `${args.action ?? "?"}`);
text += theme.fg("accent", ` #${args.number ?? "?"}`);
if (args.event) text += theme.fg("dim", ` ${args.event}`);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Processing..."), 0, 0);
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
if (!expanded) {
const firstLine = content.split("\n")[0] ?? "";
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
}
return new Text(content, 0, 0);
},
});
// ─── Tool: github_labels ────────────────────────────────────────────────
pi.registerTool({
name: "github_labels",
label: "GitHub Labels",
description: "Manage GitHub labels and milestones: list/create labels, list/create milestones.",
promptSnippet: "List or create GitHub labels and milestones",
parameters: Type.Object({
action: StringEnum(["list_labels", "create_label", "list_milestones", "create_milestone"] as const),
name: Type.Optional(Type.String({ description: "Label or milestone name (for create)" })),
color: Type.Optional(Type.String({ description: "Label hex color without # (for create_label, e.g. 'ff0000')" })),
description: Type.Optional(Type.String({ description: "Description (for create)" })),
due_on: Type.Optional(Type.String({ description: "Milestone due date ISO 8601 (for create_milestone, e.g. '2025-12-31T00:00:00Z')" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
requireAuth();
const repo = requireRepo(ctx.cwd);
switch (params.action) {
case "list_labels": {
const labels = await listLabels(repo);
return textResult(`Labels in ${repo.fullName}:\n\n${formatLabelList(labels)}`, { count: labels.length });
}
case "create_label": {
if (!params.name) return textResult("Error: 'name' is required for create_label.");
const labelGate = await confirmAction(ctx, `Create label "${params.name}"?`);
if (labelGate) return labelGate;
const label = await createLabel(repo, {
name: params.name,
color: params.color ?? "ededed",
description: params.description,
});
return textResult(`Created label: ${label.name} (#${label.color})`, { label: { name: label.name } });
}
case "list_milestones": {
const milestones = await listMilestones(repo);
return textResult(`Milestones in ${repo.fullName}:\n\n${formatMilestoneList(milestones)}`, { count: milestones.length });
}
case "create_milestone": {
if (!params.name) return textResult("Error: 'name' is required for create_milestone.");
const milestoneGate = await confirmAction(ctx, `Create milestone "${params.name}"?`);
if (milestoneGate) return milestoneGate;
const ms = await createMilestone(repo, {
title: params.name,
description: params.description,
due_on: params.due_on,
});
return textResult(`Created milestone: ${ms.title} (#${ms.number})`, { milestone: { number: ms.number, title: ms.title } });
}
default:
return textResult(`Unknown action: ${params.action}`);
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("github_labels "));
text += theme.fg("muted", `${args.action ?? "?"}`);
if (args.name) text += theme.fg("dim", ` "${args.name}"`);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0);
const content = result.content?.[0]?.type === "text" ? result.content[0].text : "";
if (!expanded) {
const firstLine = content.split("\n")[0] ?? "";
return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0);
}
return new Text(content, 0, 0);
},
});
// ─── Slash command: /gh ──────────────────────────────────────────────────
pi.registerCommand("gh", {
description: "GitHub helper: /gh issues|prs|view|create|labels|milestones|status",
getArgumentCompletions: (prefix: string) => {
const subcommands = ["issues", "prs", "view", "create", "labels", "milestones", "status"];
const parts = prefix.trim().split(/\s+/);
if (parts.length <= 1) {
return subcommands
.filter((cmd) => cmd.startsWith(parts[0] ?? ""))
.map((cmd) => ({ value: cmd, label: cmd }));
}
if (parts[0] === "issues" || parts[0] === "prs") {
const states = ["open", "closed", "all"];
const statePrefix = parts[1] ?? "";
return states
.filter((s) => s.startsWith(statePrefix))
.map((s) => ({ value: `${parts[0]} ${s}`, label: s }));
}
if (parts[0] === "create") {
const types = ["issue", "pr"];
const typePrefix = parts[1] ?? "";
return types
.filter((t) => t.startsWith(typePrefix))
.map((t) => ({ value: `create ${t}`, label: t }));
}
return [];
},
handler: async (args, ctx) => {
const parts = args.trim().split(/\s+/);
const sub = parts[0];
const rest = parts.slice(1).join(" ");
if (!isAuthenticated()) {
ctx.ui.notify("Not authenticated to GitHub. Install `gh` CLI or set GITHUB_TOKEN.", "error");
return;
}
const repo = detectRepo(ctx.cwd);
if (!repo && sub !== "status") {
ctx.ui.notify("Not in a GitHub repository.", "error");
return;
}
try {
switch (sub) {
case "issues": {
const state = (rest as "open" | "closed" | "all") || "open";
const issues = await listIssues(repo!, { state });
const display = `Issues in ${repo!.fullName} (${state}):\n\n${formatIssueList(issues)}`;
pi.sendMessage({ customType: "github", content: display, display: true });
break;
}
case "prs": {
const state = (rest as "open" | "closed" | "all") || "open";
const prs = await listPullRequests(repo!, { state });
const display = `Pull requests in ${repo!.fullName} (${state}):\n\n${formatPRList(prs)}`;
pi.sendMessage({ customType: "github", content: display, display: true });
break;
}
case "view": {
const num = parseInt(rest, 10);
if (isNaN(num)) {
ctx.ui.notify("Usage: /gh view <number>", "error");
return;
}
// Try as issue first, then PR
try {
const issue = await getIssue(repo!, num);
if (issue.pull_request) {
// It's a PR
const pr = await getPullRequest(repo!, num);
const reviews = await listReviews(repo!, num);
let text = formatPRDetail(pr);
if (reviews.length) text += `\n\n## Reviews\n\n${formatReviewList(reviews)}`;
pi.sendMessage({ customType: "github", content: text, display: true });
} else {
const comments = await listComments(repo!, num);
let text = formatIssueDetail(issue);
if (comments.length) text += `\n\n## Comments\n\n${formatCommentList(comments)}`;
pi.sendMessage({ customType: "github", content: text, display: true });
}
} catch {
ctx.ui.notify(`Could not find issue or PR #${num}`, "error");
}
break;
}
case "create": {
const type = parts[1];
if (type === "issue") {
ctx.ui.notify("Use the agent to create an issue: tell it the title, description, and labels you want.", "info");
} else if (type === "pr") {
const branch = getCurrentBranch(ctx.cwd);
const base = getDefaultBranch(ctx.cwd);
ctx.ui.notify(
`Current branch: ${branch}\nBase: ${base}\n\nTell the agent the PR title and description to create it.`,
"info",
);
} else {
ctx.ui.notify("Usage: /gh create issue|pr", "error");
}
break;
}
case "labels": {
const labels = await listLabels(repo!);
pi.sendMessage({ customType: "github", content: `Labels in ${repo!.fullName}:\n\n${formatLabelList(labels)}`, display: true });
break;
}
case "milestones": {
const milestones = await listMilestones(repo!);
pi.sendMessage({
customType: "github",
content: `Milestones in ${repo!.fullName}:\n\n${formatMilestoneList(milestones)}`,
display: true,
});
break;
}
case "status": {
const auth = authMethod();
const repoStr = repo ? `${repo.fullName}` : "not detected";
const branch = repo ? getCurrentBranch(ctx.cwd) ?? "unknown" : "n/a";
const text = `GitHub Extension Status\n\nAuth: ${auth}\nRepo: ${repoStr}\nBranch: ${branch}`;
pi.sendMessage({ customType: "github", content: text, display: true });
break;
}
default:
ctx.ui.notify("Usage: /gh issues|prs|view|create|labels|milestones|status", "info");
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
ctx.ui.notify(`GitHub error: ${msg}`, "error");
}
},
});
// ─── Message renderer ───────────────────────────────────────────────────
pi.registerMessageRenderer("github", (message, _options, theme) => {
const content = message.content ?? "";
// Apply some light styling to the GitHub output
const styled = content
.replace(/^(# .+)$/gm, (m: string) => theme.fg("accent", theme.bold(m)))
.replace(/(●)/g, theme.fg("success", "$1"))
.replace(/(✓)/g, theme.fg("success", "$1"))
.replace(/(✗)/g, theme.fg("error", "$1"))
.replace(/(⊕)/g, theme.fg("accent", "$1"))
.replace(/(◇)/g, theme.fg("dim", "$1"))
.replace(/(https:\/\/github\.com\S+)/g, theme.fg("mdLink", "$1"));
return new Text(styled, 0, 0);
});
// ─── Session start notification ─────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
const auth = authMethod();
if (auth === "none") {
ctx.ui.notify("GitHub extension: not authenticated. Install `gh` CLI or set GITHUB_TOKEN.", "warning");
}
});
}

View file

@ -1,52 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync as realSpawnSync } from "node:child_process";
import * as ghApiModule from "../resources/extensions/github/gh-api.ts";
function makeSpawnResult(overrides: Partial<ReturnType<typeof realSpawnSync>>): ReturnType<typeof realSpawnSync> {
return {
status: 0,
stdout: "",
stderr: "",
output: [null, "", ""],
pid: 1,
signal: null,
...overrides,
} as ReturnType<typeof realSpawnSync>;
}
test("hasGhCli treats zero-exit token output as authenticated", () => {
ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ stdout: "gho_test\n" }));
try {
assert.equal(ghApiModule.hasGhCli(), true);
assert.equal(ghApiModule.authMethod(), "gh CLI");
} finally {
ghApiModule.resetGhCliDetectionForTests();
}
});
test("hasGhCli rejects zero-exit responses with empty stdout", () => {
ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ stdout: "" }));
try {
assert.equal(ghApiModule.hasGhCli(), false);
} finally {
ghApiModule.resetGhCliDetectionForTests();
}
});
test("hasGhCli rejects spawnSync error even with zero exit", () => {
ghApiModule.setGhSpawnForTests(() => makeSpawnResult({
stdout: "gho_test\n",
stderr: "EPERM",
error: new Error("spawnSync gh EPERM"),
}));
try {
assert.equal(ghApiModule.hasGhCli(), false);
} finally {
ghApiModule.resetGhCliDetectionForTests();
}
});