From 624e4f234afee7d7d59340b766c8281c40bf4855 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 18 Mar 2026 23:50:30 -0700 Subject: [PATCH] feat: add gstack projects ls CLI command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CLI for inspecting project artifacts: gstack projects ls — list all projects with artifact counts and sizes gstack projects show — detailed view of one project with manifest entries gstack projects clean — find and remove E2E test garbage directories Reads .manifest.jsonl when available for richer output. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/gstack-projects | 6 + lib/cli-projects.ts | 266 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100755 bin/gstack-projects create mode 100644 lib/cli-projects.ts diff --git a/bin/gstack-projects b/bin/gstack-projects new file mode 100755 index 00000000..25d16733 --- /dev/null +++ b/bin/gstack-projects @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# gstack-projects — inspect and manage project artifacts +# Usage: gstack projects [ls|show|clean] +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bun run "$SCRIPT_DIR/../lib/cli-projects.ts" "$@" diff --git a/lib/cli-projects.ts b/lib/cli-projects.ts new file mode 100644 index 00000000..d65d3e0d --- /dev/null +++ b/lib/cli-projects.ts @@ -0,0 +1,266 @@ +/** + * CLI for inspecting gstack project artifacts. + * + * Commands: + * gstack projects ls — list all projects with artifact summary + * gstack projects show — detailed view of one project + * gstack projects clean — interactive cleanup of E2E garbage + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getProjectsDir } from './util'; + +interface ManifestEntry { + type: string; + path: string; + skill: string; + branch: string; + ts: string; +} + +interface ProjectSummary { + slug: string; + totalSize: number; + lastModified: Date; + artifactCounts: Record; + manifestEntries: ManifestEntry[]; + isEEGarbage: boolean; +} + +const E2E_GARBAGE_PATTERNS = [/^skill-e2e-/, /^test-project/]; + +/** List all projects in ~/.gstack/projects/ with artifact summaries. */ +export function listProjects(): ProjectSummary[] { + const projectsDir = getProjectsDir(); + if (!fs.existsSync(projectsDir)) return []; + + const entries = fs.readdirSync(projectsDir, { withFileTypes: true }); + const projects: ProjectSummary[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const slug = entry.name; + const projectDir = path.join(projectsDir, slug); + + const isEEGarbage = E2E_GARBAGE_PATTERNS.some(p => p.test(slug)); + const manifest = readManifest(projectDir); + const counts = countArtifacts(projectDir); + const { size, lastMod } = dirStats(projectDir); + + projects.push({ + slug, + totalSize: size, + lastModified: lastMod, + artifactCounts: counts, + manifestEntries: manifest, + isEEGarbage, + }); + } + + // Sort by last modified, most recent first + projects.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + return projects; +} + +/** Show detailed view of a single project. */ +export function showProject(slug: string): ProjectSummary | null { + const projectDir = getProjectsDir(slug); + if (!fs.existsSync(projectDir)) return null; + + const manifest = readManifest(projectDir); + const counts = countArtifacts(projectDir); + const { size, lastMod } = dirStats(projectDir); + const isEEGarbage = E2E_GARBAGE_PATTERNS.some(p => p.test(slug)); + + return { + slug, + totalSize: size, + lastModified: lastMod, + artifactCounts: counts, + manifestEntries: manifest, + isEEGarbage, + }; +} + +/** Format project list for CLI output. */ +export function formatProjectList(projects: ProjectSummary[]): string { + if (projects.length === 0) return 'No projects found in ~/.gstack/projects/'; + + const lines: string[] = []; + for (const p of projects) { + const totalArtifacts = Object.values(p.artifactCounts).reduce((a, b) => a + b, 0); + const sizeStr = formatSize(p.totalSize); + const agoStr = formatTimeAgo(p.lastModified); + const garbageFlag = p.isEEGarbage ? ' \u26A0\uFE0F E2E garbage' : ''; + + lines.push( + `${p.slug.padEnd(30)} ${String(totalArtifacts).padStart(3)} artifacts ${sizeStr.padStart(6)} (last: ${agoStr})${garbageFlag}`, + ); + + if (!p.isEEGarbage && totalArtifacts > 0) { + const parts: string[] = []; + for (const [dir, count] of Object.entries(p.artifactCounts)) { + if (count > 0) parts.push(`${dir}: ${count}`); + } + if (parts.length > 0) { + lines.push(` ${parts.join(' ')}`); + } + } + } + + return lines.join('\n'); +} + +/** Format detailed project view. */ +export function formatProjectDetail(project: ProjectSummary): string { + const lines: string[] = []; + lines.push(`# ${project.slug}`); + lines.push(`Size: ${formatSize(project.totalSize)} Last modified: ${project.lastModified.toISOString().slice(0, 16)}`); + lines.push(''); + + for (const [dir, count] of Object.entries(project.artifactCounts)) { + lines.push(`${dir}/: ${count} files`); + } + + if (project.manifestEntries.length > 0) { + lines.push(''); + lines.push('## Recent artifacts (from manifest)'); + const recent = project.manifestEntries.slice(0, 10); + for (const e of recent) { + lines.push(` ${e.ts.slice(0, 16)} ${e.type.padEnd(14)} ${e.path} (${e.skill})`); + } + } + + return lines.join('\n'); +} + +// --- Helpers --- + +function readManifest(projectDir: string): ManifestEntry[] { + const manifestPath = path.join(projectDir, '.manifest.jsonl'); + if (!fs.existsSync(manifestPath)) return []; + + try { + const lines = fs.readFileSync(manifestPath, 'utf-8').trim().split('\n'); + return lines + .filter(l => l.trim()) + .map(l => JSON.parse(l) as ManifestEntry) + .sort((a, b) => b.ts.localeCompare(a.ts)); + } catch { + return []; + } +} + +function countArtifacts(projectDir: string): Record { + const subdirs = ['reviews', 'plans', 'reports', 'retros', 'brainstorm']; + const counts: Record = {}; + + for (const sub of subdirs) { + const dir = path.join(projectDir, sub); + try { + const files = fs.readdirSync(dir); + counts[sub] = files.filter(f => !f.startsWith('.')).length; + } catch { + counts[sub] = 0; + } + } + + return counts; +} + +function dirStats(dir: string): { size: number; lastMod: Date } { + let totalSize = 0; + let lastMod = new Date(0); + + function walk(d: string) { + try { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) { + walk(full); + } else { + try { + const stat = fs.statSync(full); + totalSize += stat.size; + if (stat.mtime > lastMod) lastMod = stat.mtime; + } catch { /* skip unreadable files */ } + } + } + } catch { /* skip unreadable dirs */ } + } + + walk(dir); + return { size: totalSize, lastMod }; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +function formatTimeAgo(date: Date): string { + const diff = Date.now() - date.getTime(); + const mins = Math.floor(diff / 60000); + 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); + return `${days}d ago`; +} + +// --- CLI entry point --- + +if (import.meta.main) { + const [command, ...args] = process.argv.slice(2); + + switch (command) { + case 'ls': + case 'list': + case undefined: { + const projects = listProjects(); + console.log(formatProjectList(projects)); + break; + } + case 'show': { + const slug = args[0]; + if (!slug) { + console.error('Usage: gstack projects show '); + process.exit(1); + } + const project = showProject(slug); + if (!project) { + console.error(`Project not found: ${slug}`); + process.exit(1); + } + console.log(formatProjectDetail(project)); + break; + } + case 'clean': { + const projects = listProjects(); + const garbage = projects.filter(p => p.isEEGarbage); + if (garbage.length === 0) { + console.log('No E2E garbage found.'); + } else { + console.log(`Found ${garbage.length} E2E garbage directories:`); + for (const p of garbage) { + console.log(` ${p.slug} ${formatSize(p.totalSize)}`); + } + console.log('\nRun with --apply to delete them.'); + if (args.includes('--apply')) { + for (const p of garbage) { + const dir = getProjectsDir(p.slug); + fs.rmSync(dir, { recursive: true, force: true }); + console.log(` Deleted: ${p.slug}`); + } + } + } + break; + } + default: + console.error(`Unknown command: ${command}`); + console.error('Usage: gstack projects [ls|show|clean]'); + process.exit(1); + } +}