Files
gstack/bin/gstack-global-discover.ts
Garry Tan 40d00bd2ce v1.41.1.0 fix wave: 7 HIGH bugs from external audit + regression tests (PR #1169 follow-up) (#1592)
* fix(build-app): escape sed replacement metachars in Chromium rebrand

build-app.sh injects \$APP_NAME directly into the replacement half of
sed's s/// when patching Chromium's localized InfoPlist.strings. If
\$APP_NAME ever carries '/', '&', or '\\' — the command either breaks
or starts interpreting input as sed syntax. The trailing '|| true'
would then silently hide the failure and ship a DMG that still says
'Google Chrome for Testing' in the menu bar.

Escape replacement metachars before substitution. No change for the
default name 'GStack Browser'.

* fix(build-app): bail out if 'mktemp -d' fails instead of cp-ing into '/'

The DMG creation step sets DMG_TMP from 'mktemp -d' with no error check.
If mktemp fails (tmpfs full, permissions, TMPDIR misconfigured), DMG_TMP
is empty and the very next line — 'cp -a "\$APP_DIR" "\$DMG_TMP/"' —
expands to 'cp -a "<app>" "/"', which copies the bundle into the root of
the filesystem.

Refuse to continue unless mktemp produced a real directory. Defensive
second check catches the (rare) case where mktemp succeeds but returns
something that isn't a directory we can cp into.

* fix(telemetry-sync): drop predictable $$ tmp-file fallback

gstack-telemetry-sync tried 'mktemp /tmp/gstack-sync-XXXXXX' and on
failure fell back to '/tmp/gstack-sync-$$'. $$ is the PID — predictable
and reusable, so on shared hosts another user can pre-create or symlink
the path and either steal the response body or clobber an unrelated
file when curl writes through it.

Drop the fallback. If mktemp cannot produce a unique file we just skip
this sync cycle — the events stay on disk and the next run picks them
up. Also install an EXIT trap so the response file is cleaned up on
unexpected exit, not just on the happy path.

* fix(verify-rls): drop predictable $$-based tmp file fallback

Same shape as gstack-telemetry-sync: on mktemp failure the script fell
back to '/tmp/verify-rls-$$-$TOTAL', which is fully predictable from the
PID and a per-check counter. On a shared box another user can pre-create
or symlink the path and either capture the HTTP response body (which may
leak what the RLS tests revealed) or corrupt an unrelated file that curl
writes through.

Make mktemp strict. On failure return from the check function; the caller
tallies a FAIL and the run moves on.

* fix(security-classifier): close writer + delete tmp on download error

downloadFile() opens an fs.WriteStream to '<dest>.tmp.<pid>' and drives
it from a fetch body reader, but if reader.read() or writer.write()
throws mid-download the writer is never closed. That leaks an FD per
failed attempt and leaves the half-written tmp on disk. A later retry
can land in renameSync(tmp, dest) with a truncated TestSavantAI /
DeBERTa ONNX file — which then loads but produces garbage classifier
verdicts until the user manually nukes the models cache.

Wrap the download loop in try/catch. On failure, destroy() the writer
and unlink the tmp before rethrowing, so the next attempt starts from a
clean slate.

* fix(meta-commands): guard JSON.parse in pdf --from-file parser

parsePdfFromFile() runs JSON.parse on user-supplied file contents with
no try/catch. A malformed payload surfaces as an uncaught SyntaxError
from the 'pdf' command handler and the user sees an opaque stack trace
instead of "this file isn't valid JSON". Worse, the same call path is
used by make-pdf when header/footer HTML would overflow Windows'
CreateProcess argv cap, so a corrupt payload file there can take down
the make-pdf run.

Wrap JSON.parse. Re-throw with a message that names the offending file
and echoes the parser's own explanation. Also reject top-level non-
objects (null, array, primitive) since the rest of the function treats
json as an object — catching that here produces a clear error instead
of a TypeError further down.

* fix(global-discover): stop dropping sessions when header >8KB

extractCwdFromJsonl() reads the first 8KB of each JSONL session file and
runs JSON.parse on every newline-split line. When a session record
happens to straddle the 8KB cap, the last line ends in a truncated JSON
fragment, JSON.parse throws, the catch block 'continue's silently, and
if that was the only line carrying 'cwd' the whole project gets dropped
from the discovery output without a warning.

Two independent hardening steps:
  1. Raise the read cap to 64KB. Session headers observed in Claude
     Code / Codex / Gemini transcripts fit comfortably; this just moves
     the cliff out of the normal range.
  2. Drop the final segment after splitting on '\\n'. If the read hit
     the cap mid-line, that segment is guaranteed incomplete; if the
     file ended inside the buffer, the split produces an empty final
     segment and dropping it is a no-op.

Together these make the parser robust regardless of how verbose the
leading records are.

* test: export downloadFile, parsePdfFromFile, extractCwdFromJsonl

These three internal helpers are now imported by regression tests
landing in the next commits (PR #1169 follow-up). Pattern matches the
existing normalizeRemoteUrl export in gstack-global-discover.ts which
test/global-discover.test.ts already imports side-effect-free.

No change to runtime behavior; gstack has no public package entrypoint
that would re-export these, so the in-repo surface is unchanged for
callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security-classifier): await writer close before unlinking tmp on error

The earlier downloadFile() error-path cleanup hit a race: Node's
createWriteStream lazily opens the FD and flushes buffered writes during
destroy(), so a naive `fs.unlinkSync(tmp)` immediately after `writer.destroy()`
hits ENOENT (file not yet on disk), then the writer's destroy finishes on the
next tick and creates the file fresh — leaving the half-written tmp behind
exactly as the original fix tried to prevent.

The new sequence awaits the writer's 'close' event before unlinking, so the FD
is fully torn down and no subsequent flush can re-create the path.

Caught by browse/test/security-classifier-download-cleanup.test.ts in the
next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(browse): regression tests for downloadFile cleanup + parsePdfFromFile guard

Covers PR #1169 bugs #6 and #7:

- security-classifier-download-cleanup.test.ts pins downloadFile error-path
  cleanup against three failure shapes: reader rejects mid-stream, non-2xx
  response, missing body. Asserts the dest file is not created and no
  <dest>.tmp.* siblings remain (glob-matched, not exact path — codex push:
  if the fix later switches to mkdtempSync, the assertion still holds).
  Includes a happy-path case so the cleanup isn't fighting a correct download.

- regression-pr1169-pdf-from-file-invalid-json.test.ts pins parsePdfFromFile
  to throw a helpful error for: invalid JSON, empty file, top-level array,
  top-level number, top-level string, top-level null, top-level boolean.
  Codex push: JSON.parse accepts primitives too, so Array.isArray + typeof
  guard must be tested separately from the JSON.parse try/catch.

Both files use mkdtempSync(process.cwd()/...) for fixture isolation since
SAFE_DIRECTORIES allows TEMP_DIR or cwd; cwd is universal across CI hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(global-discover): regression for extractCwdFromJsonl 64KB cap

PR #1169 bug #8: the 8KB read cap landed mid-line on Claude Code session
headers, JSON.parse threw on the truncated tail, the catch silently
continued, and the project disappeared from /gstack discovery output.

Six new cases under describe("extractCwdFromJsonl 64KB cap"):

- happy path: small JSONL with obj.cwd returns it
- 12KB first line with obj.cwd: returns cwd (the bug case)
- 80KB single line overflowing 64KB: returns null without crashing
- complete line followed by partial second line: trailing-partial-drop
  must not poison the result; returns first line's cwd
- missing file: returns null (file read error swallowed)
- malformed first line + valid second line within cap: skips bad,
  returns second's cwd

Tests use the exported extractCwdFromJsonl (added in earlier export
commit) and live in a separate describe block from the existing
"4KB / 128KB buffer" tests, which exercise the unrelated scanCodex
meta.payload.cwd path at L338 — different function, different bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: regression tests for shell-script bugs in PR #1169 (#2-#5)

Two new test files pinning the four shell-script invariants from the
external audit:

regression-pr1169-build-app-sed.test.ts — bugs #2 + #3
- Runtime isolation: extracts the sed-escape sequence from build-app.sh
  and runs it against hostile $APP_NAME values ("Foo/Bar&Baz", "Cool\App",
  "A/B\C&D"). Asserts the literal hostile name round-trips through a real
  `sed s///` invocation, locking the metachar safety end-to-end.
- Static check: the rebrand block must contain both the escape line AND
  the sed line referencing $APP_NAME_SED_ESCAPED; bare $APP_NAME
  interpolation directly into the s/// replacement is rejected.
- Static check: DMG_TMP=$(mktemp -d) is followed by an explicit `|| { ... exit }`
  failure handler AND a `[ -z "$DMG_TMP" ] || [ ! -d "$DMG_TMP" ]` validation
  AND the cp -a appears AFTER both guards.
- Runtime fake-bin: extracts the guard shape, runs with a fake mktemp that
  exits 1, asserts the script exits non-zero before any cp block can reach.

regression-pr1169-mktemp-fallbacks.test.ts — bugs #4 + #5
- Per codex pushback, the invariant is "no `mktemp ... || echo <path>`
  fallback shape" — not just "no $$ token." That's a stronger invariant
  that catches future swaps to $RANDOM or hardcoded paths.
- For each of bin/gstack-telemetry-sync and supabase/verify-rls.sh:
  - no echo-based fallback after mktemp
  - no $$ inside any /tmp path literal
  - mktemp failure path explicitly exits / returns non-zero
  - telemetry-sync also pins the `trap rm -f $RESP_FILE EXIT` cleanup
    so success paths don't leak the tmp on normal exit.

All seven new test files are gate-tier (deterministic, sub-second, no LLM,
no network). Runtime shell tests use fake-bin PATH stubs in temp dirs;
no $HOME mutation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.41.1.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: RagavRida <ragavrida@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:56:41 -07:00

610 lines
19 KiB
TypeScript

#!/usr/bin/env bun
/**
* gstack-global-discover — Discover AI coding sessions across Claude Code, Codex CLI, and Gemini CLI.
* Resolves each session's working directory to a git repo, deduplicates by normalized remote URL,
* and outputs structured JSON to stdout.
*
* Usage:
* gstack-global-discover --since 7d [--format json|summary]
* gstack-global-discover --help
*/
import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs";
import { join, basename } from "path";
import { execSync } from "child_process";
import { homedir } from "os";
// ── Types ──────────────────────────────────────────────────────────────────
interface Session {
tool: "claude_code" | "codex" | "gemini";
cwd: string;
}
interface Repo {
name: string;
remote: string;
paths: string[];
sessions: { claude_code: number; codex: number; gemini: number };
}
interface DiscoveryResult {
window: string;
start_date: string;
repos: Repo[];
tools: {
claude_code: { total_sessions: number; repos: number };
codex: { total_sessions: number; repos: number };
gemini: { total_sessions: number; repos: number };
};
total_sessions: number;
total_repos: number;
}
// ── CLI parsing ────────────────────────────────────────────────────────────
function printUsage(): void {
console.error(`Usage: gstack-global-discover --since <window> [--format json|summary]
--since <window> Time window: e.g. 7d, 14d, 30d, 24h
--format <fmt> Output format: json (default) or summary
--help Show this help
Examples:
gstack-global-discover --since 7d
gstack-global-discover --since 14d --format summary`);
}
function parseArgs(): { since: string; format: "json" | "summary" } {
const args = process.argv.slice(2);
let since = "";
let format: "json" | "summary" = "json";
for (let i = 0; i < args.length; i++) {
if (args[i] === "--help" || args[i] === "-h") {
printUsage();
process.exit(0);
} else if (args[i] === "--since" && args[i + 1]) {
since = args[++i];
} else if (args[i] === "--format" && args[i + 1]) {
const f = args[++i];
if (f !== "json" && f !== "summary") {
console.error(`Invalid format: ${f}. Use 'json' or 'summary'.`);
printUsage();
process.exit(1);
}
format = f;
} else {
console.error(`Unknown argument: ${args[i]}`);
printUsage();
process.exit(1);
}
}
if (!since) {
console.error("Error: --since is required.");
printUsage();
process.exit(1);
}
if (!/^\d+(d|h|w)$/.test(since)) {
console.error(`Invalid window format: ${since}. Use e.g. 7d, 24h, 2w.`);
process.exit(1);
}
return { since, format };
}
function windowToDate(window: string): Date {
const match = window.match(/^(\d+)(d|h|w)$/);
if (!match) throw new Error(`Invalid window: ${window}`);
const [, numStr, unit] = match;
const num = parseInt(numStr, 10);
const now = new Date();
if (unit === "h") {
return new Date(now.getTime() - num * 60 * 60 * 1000);
} else if (unit === "w") {
// weeks — midnight-aligned like days
const d = new Date(now);
d.setDate(d.getDate() - num * 7);
d.setHours(0, 0, 0, 0);
return d;
} else {
// days — midnight-aligned
const d = new Date(now);
d.setDate(d.getDate() - num);
d.setHours(0, 0, 0, 0);
return d;
}
}
// ── URL normalization ──────────────────────────────────────────────────────
export function normalizeRemoteUrl(url: string): string {
let normalized = url.trim();
// SSH → HTTPS: git@github.com:user/repo → https://github.com/user/repo
const sshMatch = normalized.match(/^(?:ssh:\/\/)?git@([^:]+):(.+)$/);
if (sshMatch) {
normalized = `https://${sshMatch[1]}/${sshMatch[2]}`;
}
// Strip .git suffix
if (normalized.endsWith(".git")) {
normalized = normalized.slice(0, -4);
}
// Lowercase the host portion
try {
const parsed = new URL(normalized);
parsed.hostname = parsed.hostname.toLowerCase();
normalized = parsed.toString();
// Remove trailing slash
if (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
} catch {
// Not a valid URL (e.g., local:<path>), return as-is
}
return normalized;
}
// ── Git helpers ────────────────────────────────────────────────────────────
function isGitRepo(dir: string): boolean {
return existsSync(join(dir, ".git"));
}
function getGitRemote(cwd: string): string | null {
if (!existsSync(cwd) || !isGitRepo(cwd)) return null;
try {
const remote = execSync("git remote get-url origin", {
cwd,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
return remote || null;
} catch (err: any) {
// Expected: no remote configured, repo not found, git not installed
if (err?.status !== undefined) return null; // non-zero exit from git
if (err?.code === 'ENOENT') return null; // git binary not found
throw err;
}
}
// ── Scanners ───────────────────────────────────────────────────────────────
function scanClaudeCode(since: Date): Session[] {
const projectsDir = join(homedir(), ".claude", "projects");
if (!existsSync(projectsDir)) return [];
const sessions: Session[] = [];
let dirs: string[];
try {
dirs = readdirSync(projectsDir);
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return [];
throw err;
}
for (const dirName of dirs) {
const dirPath = join(projectsDir, dirName);
try {
const stat = statSync(dirPath);
if (!stat.isDirectory()) continue;
} catch {
continue;
}
// Find JSONL files
let jsonlFiles: string[];
try {
jsonlFiles = readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
} catch {
continue;
}
if (jsonlFiles.length === 0) continue;
// Coarse mtime pre-filter: check if any JSONL file is recent
const hasRecentFile = jsonlFiles.some((f) => {
try {
return statSync(join(dirPath, f)).mtime >= since;
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return false;
throw err;
}
});
if (!hasRecentFile) continue;
// Resolve cwd
let cwd = resolveClaudeCodeCwd(dirPath, dirName, jsonlFiles);
if (!cwd) continue;
// Count only JSONL files modified within the window as sessions
const recentFiles = jsonlFiles.filter((f) => {
try {
return statSync(join(dirPath, f)).mtime >= since;
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return false;
throw err;
}
});
for (let i = 0; i < recentFiles.length; i++) {
sessions.push({ tool: "claude_code", cwd });
}
}
return sessions;
}
function resolveClaudeCodeCwd(
dirPath: string,
dirName: string,
jsonlFiles: string[]
): string | null {
// Fast-path: decode directory name
// e.g., -Users-garrytan-git-repo → /Users/garrytan/git/repo
const decoded = dirName.replace(/^-/, "/").replace(/-/g, "/");
if (existsSync(decoded)) return decoded;
// Fallback: read cwd from first JSONL file
// Sort by mtime descending, pick most recent
const sorted = jsonlFiles
.map((f) => {
try {
return { name: f, mtime: statSync(join(dirPath, f)).mtime.getTime() };
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return null;
throw err;
}
})
.filter(Boolean)
.sort((a, b) => b!.mtime - a!.mtime) as { name: string; mtime: number }[];
for (const file of sorted.slice(0, 3)) {
const cwd = extractCwdFromJsonl(join(dirPath, file.name));
if (cwd && existsSync(cwd)) return cwd;
}
return null;
}
export function extractCwdFromJsonl(filePath: string): string | null {
// Read a capped prefix so huge JSONL files don't blow up memory. 64KB
// comfortably fits the largest observed session headers; the old 8KB cap
// would sometimes fall inside a single long line and silently drop the
// project (JSON.parse failure on the truncated tail).
const MAX_BYTES = 64 * 1024;
const MAX_LINES = 30;
try {
const fd = openSync(filePath, "r");
const buf = Buffer.alloc(MAX_BYTES);
const bytesRead = readSync(fd, buf, 0, MAX_BYTES, 0);
closeSync(fd);
const text = buf.toString("utf-8", 0, bytesRead);
// Drop the final segment — it may be an incomplete line at the cap boundary.
const parts = text.split("\n");
const completeLines = parts.length > 1 ? parts.slice(0, -1) : parts;
for (const line of completeLines.slice(0, MAX_LINES)) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (obj.cwd) return obj.cwd;
} catch {
continue;
}
}
} catch {
// File read error
}
return null;
}
function scanCodex(since: Date): Session[] {
const sessionsDir = process.env.CODEX_SESSIONS_DIR || join(homedir(), ".codex", "sessions");
if (!existsSync(sessionsDir)) return [];
const sessions: Session[] = [];
// Walk YYYY/MM/DD directory structure
try {
const years = readdirSync(sessionsDir);
for (const year of years) {
const yearPath = join(sessionsDir, year);
if (!statSync(yearPath).isDirectory()) continue;
const months = readdirSync(yearPath);
for (const month of months) {
const monthPath = join(yearPath, month);
if (!statSync(monthPath).isDirectory()) continue;
const days = readdirSync(monthPath);
for (const day of days) {
const dayPath = join(monthPath, day);
if (!statSync(dayPath).isDirectory()) continue;
const files = readdirSync(dayPath).filter((f) =>
f.startsWith("rollout-") && f.endsWith(".jsonl")
);
for (const file of files) {
const filePath = join(dayPath, file);
try {
const stat = statSync(filePath);
if (stat.mtime < since) continue;
} catch {
continue;
}
// Codex session_meta lines embed the full system prompt in
// base_instructions (~15KB as of CLI v0.117+). A 4KB buffer
// truncates the line and JSON.parse fails. 128KB covers current
// sizes with room for growth.
try {
const fd = openSync(filePath, "r");
const buf = Buffer.alloc(131072);
const bytesRead = readSync(fd, buf, 0, 131072, 0);
closeSync(fd);
const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n")[0];
if (!firstLine) continue;
const meta = JSON.parse(firstLine);
if (meta.type === "session_meta" && meta.payload?.cwd) {
sessions.push({ tool: "codex", cwd: meta.payload.cwd });
}
} catch {
console.error(`Warning: could not parse Codex session ${filePath}`);
}
}
}
}
}
} catch {
// Directory read error
}
return sessions;
}
function scanGemini(since: Date): Session[] {
const tmpDir = join(homedir(), ".gemini", "tmp");
if (!existsSync(tmpDir)) return [];
// Load projects.json for path mapping
const projectsPath = join(homedir(), ".gemini", "projects.json");
let projectsMap: Record<string, string> = {}; // name → path
if (existsSync(projectsPath)) {
try {
const data = JSON.parse(readFileSync(projectsPath, { encoding: "utf-8" }));
// Format: { projects: { "/path": "name" } } — we want name → path
const projects = data.projects || {};
for (const [path, name] of Object.entries(projects)) {
projectsMap[name as string] = path;
}
} catch {
console.error("Warning: could not parse ~/.gemini/projects.json");
}
}
const sessions: Session[] = [];
const seenTimestamps = new Map<string, Set<string>>(); // projectName → Set<startTime>
let projectDirs: string[];
try {
projectDirs = readdirSync(tmpDir);
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return [];
throw err;
}
for (const projectName of projectDirs) {
const chatsDir = join(tmpDir, projectName, "chats");
if (!existsSync(chatsDir)) continue;
// Resolve cwd from projects.json
let cwd = projectsMap[projectName] || null;
// Fallback: check .project_root
if (!cwd) {
const projectRootFile = join(tmpDir, projectName, ".project_root");
if (existsSync(projectRootFile)) {
try {
cwd = readFileSync(projectRootFile, { encoding: "utf-8" }).trim();
} catch {}
}
}
if (!cwd || !existsSync(cwd)) continue;
const seen = seenTimestamps.get(projectName) || new Set<string>();
seenTimestamps.set(projectName, seen);
let files: string[];
try {
files = readdirSync(chatsDir).filter((f) =>
f.startsWith("session-") && f.endsWith(".json")
);
} catch {
continue;
}
for (const file of files) {
const filePath = join(chatsDir, file);
try {
const stat = statSync(filePath);
if (stat.mtime < since) continue;
} catch {
continue;
}
try {
const data = JSON.parse(readFileSync(filePath, { encoding: "utf-8" }));
const startTime = data.startTime || "";
// Deduplicate by startTime within project
if (startTime && seen.has(startTime)) continue;
if (startTime) seen.add(startTime);
sessions.push({ tool: "gemini", cwd });
} catch {
console.error(`Warning: could not parse Gemini session ${filePath}`);
}
}
}
return sessions;
}
// ── Deduplication ──────────────────────────────────────────────────────────
async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
// Group sessions by cwd
const byCwd = new Map<string, Session[]>();
for (const s of sessions) {
const existing = byCwd.get(s.cwd) || [];
existing.push(s);
byCwd.set(s.cwd, existing);
}
// Resolve git remotes for each cwd
const cwds = Array.from(byCwd.keys());
const remoteMap = new Map<string, string>(); // cwd → normalized remote
for (const cwd of cwds) {
const raw = getGitRemote(cwd);
if (raw) {
remoteMap.set(cwd, normalizeRemoteUrl(raw));
} else if (existsSync(cwd) && isGitRepo(cwd)) {
remoteMap.set(cwd, `local:${cwd}`);
}
}
// Group by normalized remote
const byRemote = new Map<string, { paths: string[]; sessions: Session[] }>();
for (const [cwd, cwdSessions] of byCwd) {
const remote = remoteMap.get(cwd);
if (!remote) continue;
const existing = byRemote.get(remote) || { paths: [], sessions: [] };
if (!existing.paths.includes(cwd)) existing.paths.push(cwd);
existing.sessions.push(...cwdSessions);
byRemote.set(remote, existing);
}
// Build Repo objects
const repos: Repo[] = [];
for (const [remote, data] of byRemote) {
// Find first valid path
const validPath = data.paths.find((p) => existsSync(p) && isGitRepo(p));
if (!validPath) continue;
// Derive name from remote URL
let name: string;
if (remote.startsWith("local:")) {
name = basename(remote.replace("local:", ""));
} else {
try {
const url = new URL(remote);
name = basename(url.pathname);
} catch {
name = basename(remote);
}
}
const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 };
for (const s of data.sessions) {
sessionCounts[s.tool]++;
}
repos.push({
name,
remote,
paths: data.paths,
sessions: sessionCounts,
});
}
// 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)
);
return repos;
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
const { since, format } = parseArgs();
const sinceDate = windowToDate(since);
const startDate = sinceDate.toISOString().split("T")[0];
// Run all scanners
const ccSessions = scanClaudeCode(sinceDate);
const codexSessions = scanCodex(sinceDate);
const geminiSessions = scanGemini(sinceDate);
const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions];
// Summary to stderr
console.error(
`Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions`
);
// Deduplicate
const repos = await resolveAndDeduplicate(allSessions);
console.error(`${repos.length} unique repos`);
// Count per-tool repo counts
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 result: DiscoveryResult = {
window: since,
start_date: startDate,
repos,
tools: {
claude_code: { total_sessions: ccSessions.length, repos: ccRepos },
codex: { total_sessions: codexSessions.length, repos: codexRepos },
gemini: { total_sessions: geminiSessions.length, repos: geminiRepos },
},
total_sessions: allSessions.length,
total_repos: repos.length,
};
if (format === "json") {
console.log(JSON.stringify(result, null, 2));
} 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(`Repos: ${repos.length} unique`);
console.log("");
for (const repo of repos) {
const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini;
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}`);
console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`);
console.log(` Remote: ${repo.remote}`);
console.log(` Paths: ${repo.paths.join(", ")}`);
}
}
}
// Only run main when executed directly (not when imported for testing)
if (import.meta.main) {
main().catch((err) => {
console.error(`Fatal error: ${err.message}`);
process.exit(1);
});
}