Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ To add a browse command, add it to `browse/src/commands.ts`. To add a snapshot f

gstack generates SKILL.md files for two hosts: **Claude** (`.claude/skills/`) and **Codex** (`.agents/skills/`). Every template change needs to be generated for both.

**Copilot CLI support intentionally reuses the Claude-compatible `.claude/skills/` output.**
There is no separate `--host copilot` generator mode to maintain. If you change
install paths or setup behavior, make sure the Claude-format skill tree still works
for Copilot's documented `.claude/skills` support.

### Generating for both hosts

```bash
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source

## Install — 30 seconds

**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+, [Node.js](https://nodejs.org/) (Windows only)
**Requirements:** One supported host ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/use-copilot-agents/use-copilot-cli), or another SKILL.md-compatible agent), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+, [Node.js](https://nodejs.org/) (Windows only)

### Step 1: Install on your machine

Expand All @@ -54,6 +54,23 @@ Open Claude Code and paste this. Claude does the rest.

Real files get committed to your repo (not a submodule), so `git clone` just works. Everything lives inside `.claude/`. Nothing touches your PATH or runs in the background.

### GitHub Copilot CLI

GitHub Copilot CLI officially supports skills from `.claude/skills` and
`~/.claude/skills`, so the lowest-friction gstack install reuses the same
Claude-compatible layout instead of introducing a separate Copilot-only tree.

Install once for your user account:

```bash
git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --host copilot
```

For repo-local installs, vendoring gstack into `.claude/skills/gstack` works with
Copilot CLI too. Put your guidance in `AGENTS.md` or `.github/copilot-instructions.md`,
then use `/skills reload` if Copilot is already running.

### Codex, Gemini CLI, or Cursor

gstack works on any agent that supports the [SKILL.md standard](https://github.com/anthropics/claude-code). Skills live in `.agents/skills/` and are discovered automatically.
Expand Down Expand Up @@ -156,7 +173,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
| `/canary` | **SRE** | Post-deploy monitoring loop. Watches for console errors, performance regressions, and page failures. |
| `/benchmark` | **Performance Engineer** | Baseline page load times, Core Web Vitals, and resource sizes. Compare before/after on every PR. |
| `/document-release` | **Technical Writer** | Update all project docs to match what you just shipped. Catches stale READMEs automatically. |
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). |
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini, Copilot). |
| `/browse` | **QA Engineer** | Real Chromium browser, real clicks, real screenshots. ~100ms per command. |
| `/setup-browser-cookies` | **Session Manager** | Import cookies from your real browser (Chrome, Arc, Brave, Edge) into the headless session. Test authenticated pages. |
| `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → eng review automatically with encoded decision principles. Surfaces only taste decisions for your approval. |
Expand Down Expand Up @@ -228,6 +245,8 @@ Data is stored in [Supabase](https://supabase.com) (open source Firebase alterna

**Windows users:** gstack works on Windows 11 via Git Bash or WSL. Node.js is required in addition to Bun — Bun has a known bug with Playwright's pipe transport on Windows ([bun#4253](https://github.com/oven-sh/bun/issues/4253)). The browse server automatically falls back to Node.js. Make sure both `bun` and `node` are on your PATH.

**Copilot CLI doesn't see the skills?** Run `/skills list` or `/skills reload`. If you're using the Claude-compatible install path, rerun: `cd ~/.claude/skills/gstack && ./setup --host copilot`

**Claude says it can't see the skills?** Make sure your project's `CLAUDE.md` has a gstack section. Add this:

```
Expand Down
118 changes: 108 additions & 10 deletions bin/gstack-global-discover.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
/**
* gstack-global-discover — Discover AI coding sessions across Claude Code, Codex CLI, and Gemini CLI.
* gstack-global-discover — Discover AI coding sessions across Claude Code, Codex CLI, Gemini CLI, and GitHub Copilot CLI.
* Resolves each session's working directory to a git repo, deduplicates by normalized remote URL,
* and outputs structured JSON to stdout.
*
Expand All @@ -17,15 +17,15 @@ import { homedir } from "os";
// ── Types ──────────────────────────────────────────────────────────────────

interface Session {
tool: "claude_code" | "codex" | "gemini";
tool: "claude_code" | "codex" | "gemini" | "copilot";
cwd: string;
}

interface Repo {
name: string;
remote: string;
paths: string[];
sessions: { claude_code: number; codex: number; gemini: number };
sessions: { claude_code: number; codex: number; gemini: number; copilot: number };
}

interface DiscoveryResult {
Expand All @@ -36,6 +36,7 @@ interface DiscoveryResult {
claude_code: { total_sessions: number; repos: number };
codex: { total_sessions: number; repos: number };
gemini: { total_sessions: number; repos: number };
copilot: { total_sessions: number; repos: number };
};
total_sessions: number;
total_repos: number;
Expand Down Expand Up @@ -290,6 +291,42 @@ function extractCwdFromJsonl(filePath: string): string | null {
return null;
}

function extractCopilotCwdFromWorkspace(filePath: string): string | null {
try {
const text = readFileSync(filePath, { encoding: "utf-8" });
const match = text.match(/^cwd:\s*(.+)$/m);
if (!match) return null;
return match[1].trim().replace(/^['"]|['"]$/g, "");
} catch {
return null;
}
}

function extractCopilotCwdFromEvents(filePath: string): string | null {
try {
const fd = openSync(filePath, "r");
const buf = Buffer.alloc(16384);
const bytesRead = readSync(fd, buf, 0, 16384, 0);
closeSync(fd);
const lines = buf.toString("utf-8", 0, bytesRead).split("\n").slice(0, 25);
for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
const cwd = obj.data?.context?.cwd;
if (obj.type === "session.start" && typeof cwd === "string") {
return cwd;
}
} catch {
continue;
}
}
} catch {
// File read error
}
return null;
}

function scanCodex(since: Date): Session[] {
const sessionsDir = join(homedir(), ".codex", "sessions");
if (!existsSync(sessionsDir)) return [];
Expand Down Expand Up @@ -440,6 +477,59 @@ function scanGemini(since: Date): Session[] {
return sessions;
}

function scanCopilot(since: Date): Session[] {
const sessionsDir = join(homedir(), ".copilot", "session-state");
if (!existsSync(sessionsDir)) return [];

const sessions: Session[] = [];

let dirs: string[];
try {
dirs = readdirSync(sessionsDir);
} catch {
return [];
}

for (const dirName of dirs) {
const dirPath = join(sessionsDir, dirName);
try {
const stat = statSync(dirPath);
if (!stat.isDirectory()) continue;
} catch {
continue;
}

const workspacePath = join(dirPath, "workspace.yaml");
const eventsPath = join(dirPath, "events.jsonl");

const hasRecentWorkspace = existsSync(workspacePath) && (() => {
try {
return statSync(workspacePath).mtime >= since;
} catch {
return false;
}
})();
const hasRecentEvents = existsSync(eventsPath) && (() => {
try {
return statSync(eventsPath).mtime >= since;
} catch {
return false;
}
})();
if (!hasRecentWorkspace && !hasRecentEvents) continue;

let cwd = hasRecentWorkspace ? extractCopilotCwdFromWorkspace(workspacePath) : null;
if ((!cwd || !existsSync(cwd)) && hasRecentEvents) {
cwd = extractCopilotCwdFromEvents(eventsPath);
}
if (!cwd || !existsSync(cwd)) continue;

sessions.push({ tool: "copilot", cwd });
}

return sessions;
}

// ── Deduplication ──────────────────────────────────────────────────────────

async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
Expand Down Expand Up @@ -496,7 +586,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
}
}

const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 };
const sessionCounts = { claude_code: 0, codex: 0, gemini: 0, copilot: 0 };
for (const s of data.sessions) {
sessionCounts[s.tool]++;
}
Expand All @@ -512,8 +602,8 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
// Sort by total sessions descending
repos.sort(
(a, b) =>
b.sessions.claude_code + b.sessions.codex + b.sessions.gemini -
(a.sessions.claude_code + a.sessions.codex + a.sessions.gemini)
b.sessions.claude_code + b.sessions.codex + b.sessions.gemini + b.sessions.copilot -
(a.sessions.claude_code + a.sessions.codex + a.sessions.gemini + a.sessions.copilot)
);

return repos;
Expand All @@ -530,12 +620,13 @@ async function main() {
const ccSessions = scanClaudeCode(sinceDate);
const codexSessions = scanCodex(sinceDate);
const geminiSessions = scanGemini(sinceDate);
const copilotSessions = scanCopilot(sinceDate);

const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions];
const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions, ...copilotSessions];

// Summary to stderr
console.error(
`Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions`
`Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions, ${copilotSessions.length} Copilot sessions`
);

// Deduplicate
Expand All @@ -547,6 +638,7 @@ async function main() {
const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size;
const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size;
const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size;
const copilotRepos = new Set(repos.filter((r) => r.sessions.copilot > 0).map((r) => r.remote)).size;

const result: DiscoveryResult = {
window: since,
Expand All @@ -556,6 +648,7 @@ async function main() {
claude_code: { total_sessions: ccSessions.length, repos: ccRepos },
codex: { total_sessions: codexSessions.length, repos: codexRepos },
gemini: { total_sessions: geminiSessions.length, repos: geminiRepos },
copilot: { total_sessions: copilotSessions.length, repos: copilotRepos },
},
total_sessions: allSessions.length,
total_repos: repos.length,
Expand All @@ -566,15 +659,20 @@ async function main() {
} else {
// Summary format
console.log(`Window: ${since} (since ${startDate})`);
console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`);
console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length}, Copilot: ${copilotSessions.length})`);
console.log(`Repos: ${repos.length} unique`);
console.log("");
for (const repo of repos) {
const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini;
const total =
repo.sessions.claude_code +
repo.sessions.codex +
repo.sessions.gemini +
repo.sessions.copilot;
const tools = [];
if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`);
if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex}`);
if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`);
if (repo.sessions.copilot > 0) tools.push(`Copilot:${repo.sessions.copilot}`);
console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`);
console.log(` Remote: ${repo.remote}`);
console.log(` Paths: ${repo.paths.join(", ")}`);
Expand Down
Loading