feat: add github extension tool suite

This commit is contained in:
Lex Christopherson 2026-03-11 00:15:31 -06:00
parent 610b8aea63
commit 39fb2467ce
3 changed files with 1474 additions and 0 deletions

View file

@ -0,0 +1,207 @@
/**
* 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

@ -0,0 +1,537 @@
/**
* 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 } from "node:child_process";
// ─── Auth detection ───────────────────────────────────────────────────────────
let _useGhCli: boolean | null = null;
function hasGhCli(): boolean {
if (_useGhCli !== null) return _useGhCli;
try {
execSync("gh auth status", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
_useGhCli = true;
} catch {
_useGhCli = false;
}
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 shellEscape(s: string): string {
// Single-quote wrapping, escaping any existing single quotes
return "'" + s.replace(/'/g, "'\\''") + "'";
}
function ghCliApi<T>(
endpoint: string,
method: string,
params?: Record<string, string | number | boolean | string[] | undefined>,
body?: Record<string, unknown>,
cwd?: string,
): T {
const parts = ["gh", "api", shellEscape(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) {
parts.push("-f", shellEscape(`${key}[]=${v}`));
}
} else {
parts.push("-f", shellEscape(`${key}=${String(val)}`));
}
}
}
if (body) {
parts.push("--input", "-");
}
try {
const result = execSync(parts.join(" "), {
cwd: cwd ?? process.cwd(),
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
input: body ? JSON.stringify(body) : undefined,
});
if (!result.trim()) return {} as T;
return JSON.parse(result) as T;
} catch (e: unknown) {
const err = e as { stderr?: string; stdout?: string; message?: string };
const msg = err.stderr?.trim() || err.stdout?.trim() || err.message || String(e);
throw new Error(`gh api error: ${msg}`);
}
}
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

@ -0,0 +1,730 @@
/**
* 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 } from "@mariozechner/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
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 } : {}),
};
}
// ─── 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').",
],
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 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 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 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 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.",
],
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 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 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 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 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());
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 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 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");
}
});
}