Skip to content

Instantly share code, notes, and snippets.

@WomB0ComB0
Created March 15, 2026 08:02
Show Gist options
  • Select an option

  • Save WomB0ComB0/4c33529a855b7714f4c661d4d30e1c93 to your computer and use it in GitHub Desktop.

Select an option

Save WomB0ComB0/4c33529a855b7714f4c661d4d30e1c93 to your computer and use it in GitHub Desktop.
grepo - Enhanced with AI-generated documentation
#!/usr/bin/env bun
/**
* grepo — Unified GitHub Repository CLI Tool
*
* @description AI-powered GitHub repository management using Gemini
* @author Mike Odnis
* @license MIT
*/
import { Effect, Layer } from "effect";
import { loadEnv, buildConfig } from "./lib/grepo/shared";
import { GeminiLive, GitHubLive } from "./lib/grepo/services";
import { Logger } from "./utils/logger";
import type { Gemini, GitHub } from "./lib/grepo/services";
import type { GeminiError, GitHubError, GitIngestError, GrepoValidationError } from "./lib/grepo/errors";
const logger = new Logger("GREPO");
await loadEnv();
const argv = process.argv.slice(2);
if (argv.length === 0) {
// TUI handles everything: command selection, execution, mutations, exit
const { launchTui } = await import("./lib/grepo/tui");
await launchTui();
process.exit(0); // defensive: launchTui also exits, but guard against refactors
}
const config = buildConfig(argv);
// Build layers
const layers = Layer.merge(GeminiLive(config.geminiApiKey), GitHubLive(config.githubToken));
// Resolve command module before entering Effect
type CommandRunner = (config: typeof config) => Effect.Effect<void, GeminiError | GitHubError | GitIngestError | GrepoValidationError, Gemini | GitHub>;
let run: CommandRunner;
switch (config.command) {
case "readme":
run = (await import("./lib/grepo/commands/readme")).run;
break;
case "topics":
run = (await import("./lib/grepo/commands/topics")).run;
break;
case "describe":
run = (await import("./lib/grepo/commands/describe")).run;
break;
case "summary":
case "tech":
case "improve":
run = (await import("./lib/grepo/commands/analyze")).run;
break;
}
// Run with error handling
await Effect.runPromise(
run(config).pipe(
Effect.provide(layers),
Effect.catchTags({
GrepoValidationError: (e) =>
Effect.sync(() => {
logger.error(`Validation Error: ${e.message}`, undefined, { field: e.field });
process.exit(1);
}),
GeminiError: (e) =>
Effect.sync(() => {
logger.error(`Gemini Error: ${e.message}`);
process.exit(1);
}),
GitHubError: (e) =>
Effect.sync(() => {
logger.error(`GitHub Error (${e.endpoint}): ${e.message}`, undefined, { status: e.statusCode });
process.exit(1);
}),
GitIngestError: (e) =>
Effect.sync(() => {
logger.error(`GitIngest Error: ${e.message}`);
process.exit(1);
}),
}),
),
).catch((error) => {
logger.error("An unexpected error occurred", error);
process.exit(1);
});

grepo.ts

File Type: TS Lines: 89 Size: 2.6 KB Generated: 3/15/2026, 4:02:20 AM

Basic Analysis

This is a ts file containing 89 lines of code.

Content Preview

#!/usr/bin/env bun

/**
 * grepo — Unified GitHub Repository CLI Tool
 *
 * @description AI-powered GitHub repository management using Gemini
 * @author Mike Odnis
 * @license MIT
 */

import { Effect...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import { Effect } from "effect";
import { displayHeader, SEPARATOR } from "../../cli";
import { Logger } from "../../../utils/logger";
import type { GrepoConfig } from "../shared";
import { Gemini, fetchRepo } from "../services";
const logger = new Logger("GREPO:ANALYZE");
type AnalysisType = "summary" | "tech" | "improve";
const PROMPTS: Record<AnalysisType, string> = {
summary: "Provide a comprehensive 2-3 paragraph summary of this repository.",
tech: "List all technologies, frameworks, and tools used in this repository as a categorized markdown list.",
improve: "Suggest 5 specific, actionable improvements for this repository.",
};
export const run = (config: GrepoConfig) =>
Effect.gen(function* () {
const gemini = yield* Gemini;
const analysisType = config.command as AnalysisType;
displayHeader(`grepo ${analysisType}`, {
Repository: config.repoUrl,
});
logger.info("Fetching repository content via GitIngest...");
const repoData = yield* fetchRepo(config.repoUrl);
logger.success("Content fetched successfully");
logger.info(`Running ${analysisType} analysis via Gemini...`);
const prompt = `${PROMPTS[analysisType]}\n\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 4000)}`;
const result = yield* gemini.generateContent(prompt);
logger.success("Analysis complete");
console.log();
console.log(SEPARATOR);
console.log(result);
console.log(SEPARATOR);
console.log();
});

analyze.ts

File Type: TS Lines: 41 Size: 1.5 KB Generated: 3/15/2026, 3:54:20 AM

Basic Analysis

This is a ts file containing 41 lines of code.

Content Preview

import { Effect } from "effect";
import { displayHeader, SEPARATOR } from "../../cli";
import { Logger } from "../../../utils/logger";
import type { GrepoConfig } from "../shared";
import { Gemini, fe...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import { Effect } from "effect";
import { displayHeader, SEPARATOR } from "../../cli";
import * as validation from "../../validation";
import { Logger } from "../../../utils/logger";
import type { GrepoConfig } from "../shared";
import { Gemini, GitHub, fetchRepo } from "../services";
const logger = new Logger("GREPO:DESCRIBE");
const DESCRIBE_PROMPT = `Analyze this repository and generate:
1. A concise repository description (max 350 characters) suitable for GitHub's "About" section
2. A homepage URL if you can detect one from the repository content
Look for homepage URLs in:
- package.json "homepage" field
- docs site configurations (docusaurus, vitepress, mkdocs, etc.)
- deployment configs referencing domains (vercel.json, netlify.toml, CNAME files)
- GitHub Pages configuration
- README badges or links pointing to live demos, docs, or package registries (npm, PyPI, crates.io, etc.)
Return ONLY a JSON object with this shape:
{
"description": "the description here",
"homepage": "https://example.com or null if not found"
}`;
export const run = (config: GrepoConfig) =>
Effect.gen(function* () {
const gemini = yield* Gemini;
const github = yield* GitHub;
displayHeader("grepo describe", {
Repository: config.repoUrl,
Apply: config.shouldApply ? "Yes" : "No",
"Dry Run": config.isDryRun ? "Yes" : "No",
});
logger.info("Fetching repository content via GitIngest...");
const repoData = yield* fetchRepo(config.repoUrl);
logger.success("Content fetched successfully");
logger.info("Generating description and detecting homepage via Gemini...");
const prompt = `${DESCRIBE_PROMPT}\n\nURL: ${repoData.repo_url}\nSummary: ${repoData.summary}\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 6000)}`;
const result = yield* gemini.generateContent(prompt);
logger.success("Analysis complete");
// Parse JSON response
const jsonMatch = /\{[\s\S]*?\}/.exec(result);
if (!jsonMatch) {
logger.warn("Could not parse AI response");
console.log();
console.log(SEPARATOR);
console.log(result);
console.log(SEPARATOR);
return;
}
const parsed = JSON.parse(jsonMatch[0]) as { description: string; homepage: string | null };
console.log();
console.log(SEPARATOR);
console.log(` Description: ${parsed.description}`);
console.log(` Homepage: ${parsed.homepage || "(none detected)"}`);
console.log(SEPARATOR);
console.log();
if (parsed.description.length > 350) {
logger.warn(`Description is ${parsed.description.length} chars (max 350), it will be truncated by GitHub`);
}
const { owner, repo } = validation.parseGitHubUrl(config.repoUrl);
const updateData: { description?: string; homepage?: string } = {
description: parsed.description,
};
if (parsed.homepage) {
updateData.homepage = parsed.homepage;
}
if (config.isDryRun) {
logger.info("DRY RUN: Would set:", updateData);
return;
}
if (config.shouldApply) {
logger.info(`Updating ${owner}/${repo} on GitHub...`);
yield* github.updateRepo(owner, repo, updateData);
logger.success("Repository description and homepage updated successfully");
} else {
logger.info("Use --apply to set description and homepage on GitHub");
}
});

describe.ts

File Type: TS Lines: 93 Size: 3.3 KB Generated: 3/15/2026, 4:02:20 AM

Basic Analysis

This is a ts file containing 93 lines of code.

Content Preview

import { Effect } from "effect";
import { displayHeader, SEPARATOR } from "../../cli";
import * as validation from "../../validation";
import { Logger } from "../../../utils/logger";
import type { Gre...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import { Effect } from "effect";
import { writeFile } from "node:fs/promises";
import { displayHeader } from "../../cli";
import * as validation from "../../validation";
import { Logger } from "../../../utils/logger";
import type { GrepoConfig, DocumentationStyle, OutputFormat } from "../shared";
import { Gemini, GitHub, fetchRepo } from "../services";
const logger = new Logger("GREPO:README");
function getStyleGuidance(style: DocumentationStyle): string {
const styles = {
minimal: `- Keep total README under 300 lines; prioritize scannability
- Include ONLY: title, one-liner description, install command, and single quick-start example`,
standard: `- Target 400-800 lines; balance depth with readability
- Include: badges, description, installation, usage examples (2-3), API overview, and contributing basics`,
comprehensive: `- No line limit; prioritize thoroughness and discoverability
- Include all sections: badges, description, features list, installation, API reference, architecture, etc.`,
};
return styles[style] || styles.standard;
}
function buildPrompt(repoData: any, format: OutputFormat, style: DocumentationStyle): string {
return `You are an expert technical writer. Generate production-ready ${format.toUpperCase()} documentation for this repository.
<output_rules>
- Return ONLY raw ${format.toUpperCase()} content—no preamble, no markdown fences
- First line must be the H1 title
</output_rules>
<style_profile>
Style: ${style.toUpperCase()}
${getStyleGuidance(style)}
</style_profile>
<structure>
Provide: Header, TOC, Overview, Features, Architecture (with Mermaid), Quick Start, Usage, Configuration, API, Development, Contributing, Roadmap, License.
</structure>
<repository_context>
URL: ${repoData.repo_url}
Summary: ${repoData.summary}
Structure: ${repoData.tree}
Content Sample: ${repoData.content.slice(0, 8000)}
</repository_context>`;
}
export const run = (config: GrepoConfig) =>
Effect.gen(function* () {
const gemini = yield* Gemini;
const github = yield* GitHub;
displayHeader("grepo readme", {
Repository: config.repoUrl,
Format: config.outputFormat,
Style: config.style,
Output: config.outputFile,
Push: config.shouldPush ? `Yes (${config.branch})` : "No",
});
logger.info("Fetching repository content via GitIngest...");
const repoData = yield* fetchRepo(config.repoUrl);
logger.success("Content fetched successfully");
logger.info("Generating README content via Gemini...");
const prompt = buildPrompt(repoData, config.outputFormat, config.style);
const content = yield* gemini.generateContent(prompt);
logger.success("README generated successfully");
if (config.outputFile) {
logger.info(`Saving README to ${config.outputFile}...`);
yield* Effect.promise(() => writeFile(config.outputFile, content));
logger.success("File saved locally");
}
if (config.shouldPush) {
const { owner, repo } = validation.parseGitHubUrl(config.repoUrl);
logger.info(`Pushing README to GitHub (${owner}/${repo})...`);
yield* github.pushFile(
owner,
repo,
config.outputFile,
content,
"docs: update README with AI-generated content",
config.branch,
);
logger.success("Pushed to GitHub successfully");
}
});

readme.ts

File Type: TS Lines: 90 Size: 3.3 KB Generated: 3/15/2026, 3:58:20 AM

Basic Analysis

This is a ts file containing 90 lines of code.

Content Preview

import { Effect } from "effect";
import { writeFile } from "node:fs/promises";
import { displayHeader } from "../../cli";
import * as validation from "../../validation";
import { Logger } from "../../...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import { Effect } from "effect";
import { displayHeader, SEPARATOR } from "../../cli";
import * as validation from "../../validation";
import { Logger } from "../../../utils/logger";
import type { GrepoConfig } from "../shared";
import { Gemini, GitHub, fetchRepo } from "../services";
const logger = new Logger("GREPO:TOPICS");
const TOPICS_PROMPT = `Analyze this repository and suggest 5-8 relevant GitHub topics.
Return ONLY a JSON array of lowercase, hyphenated strings. Example: ["typescript", "cli-tool", "ai-powered"]`;
export const run = (config: GrepoConfig) =>
Effect.gen(function* () {
const gemini = yield* Gemini;
const github = yield* GitHub;
displayHeader("grepo topics", {
Repository: config.repoUrl,
Apply: config.shouldApply ? "Yes" : "No",
"Dry Run": config.isDryRun ? "Yes" : "No",
Merge: config.shouldMerge ? "Yes" : "No",
});
logger.info("Fetching repository content via GitIngest...");
const repoData = yield* fetchRepo(config.repoUrl);
logger.success("Content fetched successfully");
logger.info("Running topics analysis via Gemini...");
const prompt = `${TOPICS_PROMPT}\n\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 4000)}`;
const result = yield* gemini.generateContent(prompt);
logger.success("Analysis complete");
console.log();
console.log(SEPARATOR);
console.log(result);
console.log(SEPARATOR);
console.log();
// Parse topics from AI response
const jsonMatch = /\[[\s\S]*?\]/.exec(result);
if (!jsonMatch) {
logger.warn("Could not find topics list in AI response");
return;
}
let suggested = (JSON.parse(jsonMatch[0]) as string[]).map((t) =>
t.toLowerCase().trim().replaceAll(/\s+/g, "-"),
);
const { owner, repo } = validation.parseGitHubUrl(config.repoUrl);
let finalTopics = suggested;
if (config.shouldMerge && config.githubToken) {
logger.info("Fetching current topics from GitHub...");
const current = yield* github.getTopics(owner, repo);
finalTopics = [...new Set([...current, ...suggested])].sort((a, b) => a.localeCompare(b));
}
if (config.isDryRun) {
logger.info("DRY RUN: Would apply these topics:", { topics: finalTopics });
return;
}
if (config.shouldApply) {
logger.info("Applying topics to GitHub...");
yield* github.setTopics(owner, repo, finalTopics);
logger.success("Topics applied successfully");
} else {
logger.info("Use --apply to set these topics on GitHub");
}
});

topics.ts

File Type: TS Lines: 73 Size: 2.5 KB Generated: 3/15/2026, 3:58:20 AM

Basic Analysis

This is a ts file containing 73 lines of code.

Content Preview

import { Effect } from "effect";
import { displayHeader, SEPARATOR } from "../../cli";
import * as validation from "../../validation";
import { Logger } from "../../../utils/logger";
import type { Gre...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import { Data } from "effect";
export class GrepoValidationError extends Data.TaggedError("GrepoValidationError")<{
readonly message: string;
readonly field?: string;
}> {}
export class GeminiError extends Data.TaggedError("GeminiError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
export class GitHubError extends Data.TaggedError("GitHubError")<{
readonly message: string;
readonly statusCode?: number;
readonly endpoint?: string;
}> {}
export class GitIngestError extends Data.TaggedError("GitIngestError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
export type GrepoError = GrepoValidationError | GeminiError | GitHubError | GitIngestError;

errors.ts

File Type: TS Lines: 25 Size: 701.0 B Generated: 3/15/2026, 3:50:20 AM

Basic Analysis

This is a ts file containing 25 lines of code.

Content Preview

import { Data } from "effect";

export class GrepoValidationError extends Data.TaggedError("GrepoValidationError")<{
  readonly message: string;
  readonly field?: string;
}> {}

export class GeminiEr...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import { Context, Effect, Layer } from "effect";
import type { Schema } from "effect";
import { GeminiService as GeminiClient } from "../gemini";
import { GitHubClient } from "../github";
import { fetchRepositoryContent, type GitIngestResponse } from "../gitingest";
import { GeminiError, GitHubError, GitIngestError } from "./errors";
// ============================================================================
// Gemini Service
// ============================================================================
export interface GeminiServiceApi {
readonly generateContent: (prompt: string) => Effect.Effect<string, GeminiError>;
}
export class Gemini extends Context.Tag("Gemini")<Gemini, GeminiServiceApi>() {}
export const GeminiLive = (apiKey: string) =>
Layer.succeed(Gemini, {
generateContent: (prompt: string) =>
Effect.tryPromise({
try: () => new GeminiClient(apiKey).generateContent(prompt),
catch: (error) =>
new GeminiError({
message: error instanceof Error ? error.message : String(error),
cause: error,
}),
}),
});
// ============================================================================
// GitHub Service
// ============================================================================
export interface GitHubServiceApi {
readonly getTopics: (owner: string, repo: string) => Effect.Effect<readonly string[], GitHubError>;
readonly setTopics: (owner: string, repo: string, topics: string[]) => Effect.Effect<void, GitHubError>;
readonly pushFile: (
owner: string,
repo: string,
path: string,
content: string,
message: string,
branch: string,
) => Effect.Effect<void, GitHubError>;
readonly updateRepo: (
owner: string,
repo: string,
data: { description?: string; homepage?: string },
) => Effect.Effect<void, GitHubError>;
}
export class GitHub extends Context.Tag("GitHub")<GitHub, GitHubServiceApi>() {}
export const GitHubLive = (token?: string) => {
const client = new GitHubClient(token);
return Layer.succeed(GitHub, {
getTopics: (owner, repo) =>
Effect.tryPromise({
try: () => client.getTopics(owner, repo),
catch: (error) =>
new GitHubError({
message: error instanceof Error ? error.message : String(error),
endpoint: `repos/${owner}/${repo}/topics`,
}),
}),
setTopics: (owner, repo, topics) =>
Effect.tryPromise({
try: () => client.setTopics(owner, repo, topics),
catch: (error) =>
new GitHubError({
message: error instanceof Error ? error.message : String(error),
endpoint: `repos/${owner}/${repo}/topics`,
}),
}),
pushFile: (owner, repo, path, content, message, branch) =>
Effect.tryPromise({
try: () => client.pushFile(owner, repo, path, content, message, branch),
catch: (error) =>
new GitHubError({
message: error instanceof Error ? error.message : String(error),
endpoint: `repos/${owner}/${repo}/contents/${path}`,
}),
}),
updateRepo: (owner, repo, data) =>
Effect.tryPromise({
try: () => client.updateRepo(owner, repo, data),
catch: (error) =>
new GitHubError({
message: error instanceof Error ? error.message : String(error),
endpoint: `repos/${owner}/${repo}`,
}),
}),
});
};
// ============================================================================
// GitIngest Service
// ============================================================================
export type RepoData = Schema.Schema.Type<typeof GitIngestResponse>;
export const fetchRepo = (repoUrl: string): Effect.Effect<RepoData, GitIngestError> =>
Effect.tryPromise({
try: () => fetchRepositoryContent(repoUrl),
catch: (error) =>
new GitIngestError({
message: error instanceof Error ? error.message : String(error),
cause: error,
}),
});

services.ts

File Type: TS Lines: 112 Size: 3.9 KB Generated: 3/15/2026, 3:54:20 AM

Basic Analysis

This is a ts file containing 112 lines of code.

Content Preview

import { Context, Effect, Layer } from "effect";
import type { Schema } from "effect";
import { GeminiService as GeminiClient } from "../gemini";
import { GitHubClient } from "../github";
import { fet...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import { Schema } from "effect";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { parseArgs } from "../cli";
import * as validation from "../validation";
import { GrepoValidationError } from "./errors";
// ============================================================================
// Config Schema
// ============================================================================
export const Command = Schema.Literal("readme", "topics", "describe", "summary", "tech", "improve");
export type Command = Schema.Schema.Type<typeof Command>;
export const OutputFormat = Schema.Literal("md", "mdx");
export type OutputFormat = Schema.Schema.Type<typeof OutputFormat>;
export const DocumentationStyle = Schema.Literal("minimal", "standard", "comprehensive");
export type DocumentationStyle = Schema.Schema.Type<typeof DocumentationStyle>;
export interface GrepoConfig {
geminiApiKey: string;
githubToken?: string;
command: Command;
repoUrl: string;
// readme-specific
outputFormat: OutputFormat;
outputFile: string;
style: DocumentationStyle;
// mutation flags
shouldApply: boolean;
shouldPush: boolean;
shouldMerge: boolean;
isDryRun: boolean;
branch: string;
}
// ============================================================================
// Env Loading
// ============================================================================
export async function loadEnv(): Promise<void> {
const scriptDirEnv = join(import.meta.dir, "../../.env");
if (!existsSync(scriptDirEnv)) return;
const envContent = await Bun.file(scriptDirEnv).text();
for (const line of envContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [key, ...valueParts] = trimmed.split("=");
if (key && valueParts.length > 0) {
const value = valueParts.join("=").trim().replaceAll(/(^["'])|(['"]$)/g, "");
if (!process.env[key.trim()]) {
process.env[key.trim()] = value;
}
}
}
}
// ============================================================================
// Config Builder
// ============================================================================
const BOOLEAN_FLAGS = ["push", "apply", "merge", "dry-run"] as const;
const USAGE = `Usage: grepo <command> <github-url> [options]
Commands:
readme Generate README documentation
topics Generate and apply repository topics
describe Generate repository description and homepage URL
summary Summarize repository
tech List technologies used
improve Suggest improvements
Options:
--format md|mdx Output format (readme only, default: md)
--style minimal|standard|comprehensive Documentation style (readme only, default: standard)
--output <file> Output file path (readme only)
--push Push to GitHub (readme only)
--apply Apply changes to GitHub (topics, describe)
--merge Merge with existing topics (topics only)
--dry-run Preview changes without applying
--branch <name> Target branch (default: main)`;
export function buildConfig(argv: string[]): GrepoConfig {
const { positional, options } = parseArgs(argv, [...BOOLEAN_FLAGS]);
if (positional.length < 2) {
console.log(USAGE);
process.exit(1);
}
const command = positional[0];
const validCommands: Command[] = ["readme", "topics", "describe", "summary", "tech", "improve"];
if (!validCommands.includes(command as Command)) {
console.error(`Unknown command: ${command}`);
console.log(USAGE);
process.exit(1);
}
const repoUrl = positional[1] as string;
if (!validation.isValidGitHubUrl(repoUrl)) {
throw new GrepoValidationError({ message: "Invalid GitHub URL", field: "repoUrl" });
}
const geminiApiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
if (!validation.isValidGeminiApiKey(geminiApiKey)) {
throw new GrepoValidationError({ message: "GEMINI_API_KEY is missing or invalid" });
}
const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
const shouldApply = !!options.apply;
const shouldPush = !!options.push;
const shouldMerge = !!options.merge;
if ((shouldApply || shouldPush || shouldMerge) && !githubToken) {
throw new GrepoValidationError({
message: "GitHub token (GITHUB_TOKEN or GH_TOKEN) is required for mutation operations",
});
}
const format = (options.format as string) || "md";
return {
geminiApiKey,
githubToken,
command: command as Command,
repoUrl,
outputFormat: format as OutputFormat,
outputFile: (options.output as string) || `README.${format}`,
style: ((options.style as string) || "standard") as DocumentationStyle,
shouldApply,
shouldPush,
shouldMerge,
isDryRun: !!options["dry-run"],
branch: (options.branch as string) || "main",
};
}

shared.ts

File Type: TS Lines: 139 Size: 4.8 KB Generated: 3/15/2026, 3:50:20 AM

Basic Analysis

This is a ts file containing 139 lines of code.

Content Preview

import { Schema } from "effect";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { parseArgs } from "../cli";
import * as validation from "../validation";
import { Grepo...

Note: AI-powered analysis was unavailable. This is a basic fallback description.

import {
createCliRenderer,
BoxRenderable,
TextRenderable,
InputRenderable,
SelectRenderable,
ScrollBoxRenderable,
} from "@opentui/core";
import type { CliRenderer } from "@opentui/core";
import { Effect, Layer } from "effect";
import { GeminiLive, GitHubLive, Gemini, GitHub, fetchRepo } from "./services";
import type { RepoData } from "./services";
import type { Command, DocumentationStyle } from "./shared";
import { loadEnv } from "./shared";
import * as validation from "../validation";
// ── Palette (GitHub dark) ──
const C = {
bg: "#0d1117",
surface: "#161b22",
border: "#30363d",
text: "#e6edf3",
muted: "#7d8590",
accent: "#58a6ff",
success: "#3fb950",
hint: "#484f58",
} as const;
// ── Types ──
const COMMANDS: { name: string; description: string; value: Command }[] = [
{ name: "readme", description: "Generate README documentation", value: "readme" },
{ name: "topics", description: "Generate and apply repository topics", value: "topics" },
{ name: "describe", description: "Generate description and homepage URL", value: "describe" },
{ name: "summary", description: "Summarize repository", value: "summary" },
{ name: "tech", description: "List technologies used", value: "tech" },
{ name: "improve", description: "Suggest improvements", value: "improve" },
];
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const ACTION_LABELS: Record<Command, string> = {
readme: "[s] save [p] save & push [q] quit",
topics: "[a] apply [m] merge & apply [q] quit",
describe: "[a] apply [q] quit",
summary: "[q] quit",
tech: "[q] quit",
improve: "[q] quit",
};
const compareNumbersAscending = (left: number, right: number) => left - right;
const compareStringsAscending = (left: string, right: string) => left.localeCompare(right);
// ============================================================================
// Multi-Select Component
// ============================================================================
interface MultiSelectControl {
container: BoxRenderable;
getSelected: () => Command[];
destroy: () => void;
}
interface DescribeResult {
description: string;
homepage: string | null;
}
type ActionRunner = (() => Promise<void>) | "quit";
function getRequiredItem<T>(items: readonly T[], index: number, label: string): T {
const item = items[index];
if (item === undefined) {
throw new Error(`Missing ${label} at index ${index}`);
}
return item;
}
function getSelectedCommands(selected: Set<number>, options: typeof COMMANDS): Command[] {
return [...selected]
.sort(compareNumbersAscending)
.map((index) => getRequiredItem(options, index, "command option").value);
}
function createMultiSelect(
renderer: CliRenderer,
options: typeof COMMANDS,
onConfirm: (selected: Command[]) => void,
): MultiSelectControl {
const selected = new Set<number>();
let focusIndex = 0;
const container = new BoxRenderable(renderer, {
flexDirection: "column",
width: 56,
gap: 0,
});
const rows: { box: BoxRenderable; checkbox: TextRenderable; label: TextRenderable; desc: TextRenderable }[] = [];
for (let i = 0; i < options.length; i++) {
const opt = getRequiredItem(options, i, "command option");
const row = new BoxRenderable(renderer, {
flexDirection: "row",
width: "100%",
height: 2,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: i === 0 ? C.surface : C.bg,
});
const checkbox = new TextRenderable(renderer, {
content: "[ ]",
width: 4,
height: 1,
fg: C.muted,
});
const label = new TextRenderable(renderer, {
content: opt.name,
height: 1,
fg: i === 0 ? C.text : C.muted,
});
const desc = new TextRenderable(renderer, {
content: ` ${opt.description}`,
height: 1,
fg: C.hint,
});
const textCol = new BoxRenderable(renderer, {
flexDirection: "column",
height: 2,
});
textCol.add(label);
textCol.add(desc);
row.add(checkbox);
row.add(textCol);
container.add(row);
rows.push({ box: row, checkbox, label, desc });
}
function updateVisuals() {
for (let i = 0; i < rows.length; i++) {
const r = getRequiredItem(rows, i, "command row");
const opt = getRequiredItem(options, i, "command option");
const isFocused = i === focusIndex;
const isSelected = selected.has(i);
r.box.backgroundColor = isFocused ? C.surface : C.bg;
r.label.content = opt.name;
// @ts-ignore - fg is settable on TextRenderable
r.label.fg = isFocused ? C.text : C.muted;
r.checkbox.content = isSelected ? "[✓]" : "[ ]";
// @ts-ignore
r.checkbox.fg = isSelected ? C.success : C.muted;
}
}
function handleKey(key: any): boolean {
if (key.name === "up" || key.name === "k") {
focusIndex = (focusIndex - 1 + options.length) % options.length;
updateVisuals();
return true;
}
if (key.name === "down" || key.name === "j") {
focusIndex = (focusIndex + 1) % options.length;
updateVisuals();
return true;
}
if (key.name === "space" || key.sequence === " ") {
if (selected.has(focusIndex)) selected.delete(focusIndex);
else selected.add(focusIndex);
updateVisuals();
return true;
}
if (key.name === "return" || key.name === "enter") {
if (selected.size === 0) return true; // require at least one
const cmds = getSelectedCommands(selected, options);
onConfirm(cmds);
return true;
}
return false;
}
const keyHandler = (key: any) => handleKey(key);
renderer.keyInput.on("keypress", keyHandler);
return {
container,
getSelected: () => getSelectedCommands(selected, options),
destroy: () => renderer.keyInput.off("keypress", keyHandler),
};
}
// ============================================================================
// Spinner Helper
// ============================================================================
interface SpinnerControl {
stop: (success: boolean) => void;
}
function createSpinner(text: TextRenderable, label: string): SpinnerControl {
let frame = 0;
let running = true;
const baseText = label;
// Render first frame immediately (no 80ms blank gap)
// @ts-ignore - fg is settable on TextRenderable
text.fg = C.accent;
text.content = `${SPINNER_FRAMES[0]} ${baseText}`;
const interval = setInterval(() => {
if (!running) return;
frame++;
text.content = `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${baseText}`;
}, 80);
return {
stop: (success: boolean) => {
running = false;
clearInterval(interval);
const label = baseText.replace("...", "");
if (success) {
// @ts-ignore
text.fg = C.success;
text.content = `✓ ${label}`;
} else {
// @ts-ignore
text.fg = "#f85149";
text.content = `✗ ${label}`;
}
},
};
}
// ============================================================================
// Utilities
// ============================================================================
function waitForKey(renderer: CliRenderer, keyName: string): Promise<void> {
return new Promise((resolve) => {
const handler = (key: any) => {
if (key.name === keyName) {
renderer.keyInput.off("keypress", handler);
resolve();
}
};
renderer.keyInput.on("keypress", handler);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function parseTopics(result: string): string[] {
const jsonMatch = /\[[\s\S]*?\]/.exec(result);
if (!jsonMatch) {
throw new Error("Could not parse topics");
}
return (JSON.parse(jsonMatch[0]) as string[]).map((topic) => topic.toLowerCase().trim().replaceAll(/\s+/g, "-"));
}
function parseDescribeResult(result: string): DescribeResult {
const jsonMatch = /\{[\s\S]*?\}/.exec(result);
if (!jsonMatch) {
throw new Error("Could not parse description");
}
return JSON.parse(jsonMatch[0]) as DescribeResult;
}
async function writeReadme(result: string): Promise<void> {
const { writeFile } = await import("node:fs/promises");
await writeFile("README.md", result);
}
async function pushReadme(ctx: ExecuteContext, result: string): Promise<void> {
await Effect.runPromise(
Effect.gen(function* () {
const github = yield* GitHub;
yield* github.pushFile(ctx.owner, ctx.repo, "README.md", result, "docs: update README with AI-generated content", "main");
}).pipe(Effect.provide(ctx.layers)),
);
}
async function getExistingTopics(ctx: ExecuteContext): Promise<readonly string[]> {
return Effect.runPromise(
Effect.gen(function* () {
const github = yield* GitHub;
return yield* github.getTopics(ctx.owner, ctx.repo);
}).pipe(Effect.provide(ctx.layers)),
);
}
async function updateTopics(ctx: ExecuteContext, topics: string[]): Promise<void> {
await Effect.runPromise(
Effect.gen(function* () {
const github = yield* GitHub;
yield* github.setTopics(ctx.owner, ctx.repo, topics);
}).pipe(Effect.provide(ctx.layers)),
);
}
async function applyRepoDescription(
ctx: ExecuteContext,
data: { description?: string; homepage?: string },
): Promise<void> {
await Effect.runPromise(
Effect.gen(function* () {
const github = yield* GitHub;
yield* github.updateRepo(ctx.owner, ctx.repo, data);
}).pipe(Effect.provide(ctx.layers)),
);
}
async function saveReadmeMutation(
renderer: CliRenderer,
main: BoxRenderable,
result: string,
): Promise<void> {
await performMutation(renderer, main, "Save README to README.md?", "README saved to README.md", async () => {
await writeReadme(result);
});
}
async function saveAndPushReadmeMutation(
renderer: CliRenderer,
main: BoxRenderable,
result: string,
ctx: ExecuteContext,
): Promise<void> {
await performMutation(
renderer,
main,
`Save & push README to ${ctx.owner}/${ctx.repo}?`,
`README saved and pushed to ${ctx.owner}/${ctx.repo}`,
async () => {
await writeReadme(result);
await pushReadme(ctx, result);
},
);
}
async function applyTopicsMutation(
renderer: CliRenderer,
main: BoxRenderable,
result: string,
ctx: ExecuteContext,
merge: boolean,
): Promise<void> {
const confirmMessage = `${merge ? "Merge & apply" : "Apply"} topics to ${ctx.owner}/${ctx.repo}?`;
const successMessage = `Topics applied to ${ctx.owner}/${ctx.repo}`;
await performMutation(renderer, main, confirmMessage, successMessage, async () => {
let topics = parseTopics(result);
if (merge && ctx.githubToken) {
const current = await getExistingTopics(ctx);
topics = [...new Set([...current, ...topics])].sort(compareStringsAscending);
}
await updateTopics(ctx, topics);
});
}
async function applyDescriptionMutation(
renderer: CliRenderer,
main: BoxRenderable,
result: string,
ctx: ExecuteContext,
): Promise<void> {
await performMutation(
renderer,
main,
`Apply description to ${ctx.owner}/${ctx.repo}?`,
`Description applied to ${ctx.owner}/${ctx.repo}`,
async () => {
const parsed = parseDescribeResult(result);
const data: { description?: string; homepage?: string } = { description: parsed.description };
if (parsed.homepage) {
data.homepage = parsed.homepage;
}
await applyRepoDescription(ctx, data);
},
);
}
function getActionRunner(
renderer: CliRenderer,
main: BoxRenderable,
command: Command,
keyName: string | undefined,
result: string,
ctx: ExecuteContext,
): ActionRunner | undefined {
if (keyName === "q") {
return "quit";
}
if (command === "readme" && keyName === "s") {
return () => saveReadmeMutation(renderer, main, result);
}
if (command === "readme" && keyName === "p") {
return () => saveAndPushReadmeMutation(renderer, main, result, ctx);
}
if (command === "topics" && (keyName === "a" || keyName === "m")) {
return () => applyTopicsMutation(renderer, main, result, ctx, keyName === "m");
}
if (command === "describe" && keyName === "a") {
return () => applyDescriptionMutation(renderer, main, result, ctx);
}
return undefined;
}
// ============================================================================
// Prompts (TUI needs these directly for fine-grained loading control)
// ============================================================================
interface ExecuteContext {
commands: Command[];
repoUrl: string;
owner: string;
repo: string;
readmeStyle: DocumentationStyle;
layers: Layer.Layer<Gemini | GitHub>;
githubToken?: string;
}
function extractExistingReadme(content: string): string | null {
// gitingest output includes file paths — look for README content
const readmeMatch = /(?:^|\n)(?:={3,}|─{3,})?\s*README\.md\s*(?:={3,}|─{3,})?\n([\s\S]*?)(?=\n(?:={3,}|─{3,})\s*\S+\.\S+|$)/.exec(content);
if (readmeMatch?.[1] && readmeMatch[1].trim().length > 50) {
return readmeMatch[1].trim();
}
return null;
}
function getReadmePrompt(repoData: RepoData, style: DocumentationStyle): string {
const styleGuidance: Record<DocumentationStyle, string> = {
minimal: "Keep total README under 300 lines; prioritize scannability. Include ONLY: title, one-liner description, install command, and single quick-start example.",
standard: "Target 400-800 lines; balance depth with readability. Include: badges, description, installation, usage examples (2-3), API overview, and contributing basics.",
comprehensive: "No line limit; prioritize thoroughness. Include all sections: badges, description, features list, installation, API reference, architecture, etc.",
};
const existingReadme = extractExistingReadme(repoData.content);
const existingSection = existingReadme
? `\n<existing_readme>
The repository already has a README. Build upon and improve it — preserve any accurate content, links, badges, and structure that are still relevant. Enhance with better organization, missing sections, and more detail based on the actual codebase. Do NOT discard good existing content.
${existingReadme.slice(0, 4000)}
</existing_readme>`
: "";
return `You are an expert technical writer. Generate production-ready MD documentation for this repository.
<output_rules>
- Return ONLY raw MD content—no preamble, no markdown fences
- First line must be the H1 title
</output_rules>
<style_profile>
Style: ${style.toUpperCase()}
${styleGuidance[style]}
</style_profile>
<structure>
Provide: Header, TOC, Overview, Features, Architecture (with Mermaid), Quick Start, Usage, Configuration, API, Development, Contributing, Roadmap, License.
</structure>
${existingSection}
<repository_context>
URL: ${repoData.repo_url}
Summary: ${repoData.summary}
Structure: ${repoData.tree}
Content Sample: ${repoData.content.slice(0, 8000)}
</repository_context>`;
}
const TOPICS_PROMPT = `Analyze this repository and suggest 5-8 relevant GitHub topics.
Return ONLY a JSON array of lowercase, hyphenated strings. Example: ["typescript", "cli-tool", "ai-powered"]`;
const DESCRIBE_PROMPT = `Analyze this repository and generate:
1. A concise repository description (max 350 characters) suitable for GitHub's "About" section
2. A homepage URL if you can detect one from the repository content
Look for homepage URLs in:
- package.json "homepage" field
- docs site configurations (docusaurus, vitepress, mkdocs, etc.)
- deployment configs referencing domains (vercel.json, netlify.toml, CNAME files)
- GitHub Pages configuration
- README badges or links pointing to live demos, docs, or package registries
Return ONLY a JSON object: { "description": "...", "homepage": "https://... or null" }`;
const ANALYSIS_PROMPTS: Record<string, string> = {
summary: "Provide a comprehensive 2-3 paragraph summary of this repository.",
tech: "List all technologies, frameworks, and tools used in this repository as a categorized markdown list.",
improve: "Suggest 5 specific, actionable improvements for this repository.",
};
function getPromptForCommand(command: Command, repoData: RepoData, style: DocumentationStyle): string {
if (command === "readme") return getReadmePrompt(repoData, style);
if (command === "topics") return `${TOPICS_PROMPT}\n\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 4000)}`;
if (command === "describe") return `${DESCRIBE_PROMPT}\n\nURL: ${repoData.repo_url}\nSummary: ${repoData.summary}\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 6000)}`;
return `${ANALYSIS_PROMPTS[command]}\n\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 4000)}`;
}
// ============================================================================
// Result Formatting
// ============================================================================
function formatResult(command: Command, raw: string): string {
if (command === "topics") {
const jsonMatch = /\[[\s\S]*?\]/.exec(raw);
if (jsonMatch) {
try {
const topics = JSON.parse(jsonMatch[0]) as string[];
const topicList = topics.map((topic) => ` • ${topic}`).join("\n");
return `Suggested Topics:\n\n${topicList}`;
} catch { /* fall through */ }
}
return raw;
}
if (command === "describe") {
const jsonMatch = /\{[\s\S]*?\}/.exec(raw);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]) as DescribeResult;
let out = `Description:\n ${parsed.description}`;
if (parsed.homepage) out += `\n\nHomepage:\n ${parsed.homepage}`;
return out;
} catch { /* fall through */ }
}
return raw;
}
// readme, summary, tech, improve — show raw
return raw;
}
// ============================================================================
// Mutation Handling
// ============================================================================
async function performMutation(
renderer: CliRenderer,
main: BoxRenderable,
confirmMessage: string,
successMessage: string,
action: () => Promise<void>,
): Promise<void> {
// Show confirmation per spec: "Apply topics to owner/repo? [enter] confirm [esc] cancel"
const confirm = new TextRenderable(renderer, {
content: `${confirmMessage} [enter] confirm [esc] cancel`,
height: 1,
fg: C.text,
marginTop: 1,
});
main.add(confirm);
renderer.requestRender();
const confirmed = await new Promise<boolean>((resolve) => {
const handler = (key: any) => {
if (key.name === "return" || key.name === "enter") {
renderer.keyInput.off("keypress", handler);
resolve(true);
} else if (key.name === "escape") {
renderer.keyInput.off("keypress", handler);
resolve(false);
}
};
renderer.keyInput.on("keypress", handler);
});
main.remove(confirm.id);
if (!confirmed) {
const cancelled = new TextRenderable(renderer, {
content: "Cancelled",
height: 1,
fg: C.muted,
});
main.add(cancelled);
renderer.requestRender();
await sleep(800);
main.remove(cancelled.id);
return;
}
// Execute
const status = new TextRenderable(renderer, {
content: "Applying...",
height: 1,
fg: C.accent,
});
main.add(status);
renderer.requestRender();
try {
await action();
// Per spec: "✓ Topics applied to owner/repo" with just [q] quit
status.content = `✓ ${successMessage}`;
// @ts-ignore
status.fg = C.success;
renderer.requestRender();
// Show [q] quit and wait for user
const doneBar = new TextRenderable(renderer, {
content: "[q] quit",
height: 1,
fg: C.muted,
marginTop: 1,
});
main.add(doneBar);
renderer.requestRender();
await waitForKey(renderer, "q");
main.remove(doneBar.id);
} catch (err) {
status.content = `✗ Failed: ${err instanceof Error ? err.message : String(err)}`;
// @ts-ignore
status.fg = "#f85149";
renderer.requestRender();
await waitForKey(renderer, "q");
}
main.remove(status.id);
}
// ============================================================================
// Action Bars
// ============================================================================
async function showActionBar(
renderer: CliRenderer,
main: BoxRenderable,
command: Command,
result: string,
ctx: ExecuteContext,
): Promise<void> {
const actionBar = new TextRenderable(renderer, {
content: ACTION_LABELS[command],
height: 1,
fg: C.muted,
marginTop: 1,
});
main.add(actionBar);
renderer.requestRender();
await new Promise<void>((resolve) => {
const handler = async (key: any) => {
const action = getActionRunner(renderer, main, command, key.name, result, ctx);
if (!action) {
return;
}
renderer.keyInput.off("keypress", handler);
if (action === "quit") {
main.remove(actionBar.id);
resolve();
return;
}
await action();
main.remove(actionBar.id);
resolve();
};
renderer.keyInput.on("keypress", handler);
});
}
// ============================================================================
// Execution Phase
// ============================================================================
async function executeCommands(
renderer: CliRenderer,
main: BoxRenderable,
ctx: ExecuteContext,
): Promise<void> {
// Fetch repo data once
const fetchStatus = new TextRenderable(renderer, {
content: "Fetching repository content",
height: 1,
fg: C.muted,
});
main.add(fetchStatus);
renderer.requestRender();
const fetchSpinner = createSpinner(fetchStatus, "Fetching repository content");
let repoData: RepoData;
try {
repoData = await Effect.runPromise(fetchRepo(ctx.repoUrl));
fetchSpinner.stop(true);
} catch (err) {
fetchSpinner.stop(false);
fetchStatus.content = `✗ Failed to fetch: ${err instanceof Error ? err.message : String(err)}`;
renderer.requestRender();
await waitForKey(renderer, "q");
main.remove(fetchStatus.id);
return;
}
renderer.requestRender();
// Execute each command sequentially
for (let i = 0; i < ctx.commands.length; i++) {
const command = getRequiredItem(ctx.commands, i, "command");
const cmdHeader = new TextRenderable(renderer, {
content: `${command} ${i + 1}/${ctx.commands.length} › ${ctx.owner}/${ctx.repo}`,
height: 1,
fg: C.accent,
marginTop: 1,
marginBottom: 1,
});
main.add(cmdHeader);
// Generate
const genStatus = new TextRenderable(renderer, {
content: "Generating via Gemini...",
height: 1,
fg: C.muted,
});
main.add(genStatus);
renderer.requestRender();
const genSpinner = createSpinner(genStatus, "Generating via Gemini...");
let result: string;
try {
const prompt = getPromptForCommand(command, repoData, ctx.readmeStyle);
result = await Effect.runPromise(
Effect.gen(function* () {
const gemini = yield* Gemini;
return yield* gemini.generateContent(prompt);
}).pipe(Effect.provide(ctx.layers)),
);
genSpinner.stop(true);
} catch (err) {
genSpinner.stop(false);
genStatus.content = `✗ Generation failed: ${err instanceof Error ? err.message : String(err)}`;
renderer.requestRender();
await waitForKey(renderer, "q");
main.remove(cmdHeader.id);
main.remove(genStatus.id);
continue;
}
renderer.requestRender();
// Display results in ScrollBox
const resultBox = new ScrollBoxRenderable(renderer, {
width: 58,
height: 12,
scrollY: true,
border: true,
borderColor: C.border,
borderStyle: "single",
backgroundColor: C.surface,
});
const resultText = new TextRenderable(renderer, {
content: formatResult(command, result),
fg: C.text,
width: 54,
});
resultBox.add(resultText);
main.add(resultBox);
renderer.requestRender();
// Show action bar and wait for user action
await showActionBar(renderer, main, command, result, ctx);
// Clean up results for this command before next
main.remove(resultBox.id);
main.remove(cmdHeader.id);
main.remove(genStatus.id);
renderer.requestRender();
}
// Remove fetch status
main.remove(fetchStatus.id);
renderer.requestRender();
}
// ============================================================================
// Phase Helpers
// ============================================================================
function promptForUrl(renderer: CliRenderer, main: BoxRenderable): Promise<string> {
return new Promise((resolve) => {
const inputLabel = new TextRenderable(renderer, {
content: "Repository URL",
height: 1,
fg: C.muted,
marginBottom: 1,
});
const urlInput = new InputRenderable(renderer, {
placeholder: "https://github.com/owner/repo",
maxLength: 200,
width: 56,
backgroundColor: C.surface,
textColor: C.text,
focusedBackgroundColor: C.surface,
focusedTextColor: C.text,
placeholderColor: C.hint,
});
const inputHint = new TextRenderable(renderer, {
content: "enter submit esc quit",
height: 1,
fg: C.hint,
marginTop: 1,
});
main.add(inputLabel);
main.add(urlInput);
main.add(inputHint);
urlInput.focus();
renderer.requestRender();
urlInput.on("enter", () => {
const url = urlInput.value.trim();
if (!url) return;
main.remove(inputLabel.id);
main.remove(urlInput.id);
main.remove(inputHint.id);
const urlBreadcrumb = new TextRenderable(renderer, {
content: `Repo: ${url}`,
height: 1,
fg: C.success,
marginBottom: 1,
});
main.add(urlBreadcrumb);
renderer.requestRender();
resolve(url);
});
});
}
function promptForReadmeStyle(renderer: CliRenderer, main: BoxRenderable): Promise<DocumentationStyle> {
return new Promise((resolve) => {
const styleLabel = new TextRenderable(renderer, {
content: "README style",
height: 1,
fg: C.muted,
marginBottom: 1,
});
const styleSelect = new SelectRenderable(renderer, {
options: [
{ name: "minimal", description: "Short, scannable (~300 lines)" },
{ name: "standard", description: "Balanced depth and readability (~400-800 lines)" },
{ name: "comprehensive", description: "Thorough, no line limit" },
],
selectedIndex: 1,
width: 56,
height: 9,
showDescription: true,
itemSpacing: 1,
backgroundColor: C.bg,
textColor: C.muted,
focusedBackgroundColor: C.bg,
focusedTextColor: C.muted,
selectedBackgroundColor: C.surface,
selectedTextColor: C.accent,
descriptionColor: C.hint,
selectedDescriptionColor: C.muted,
wrapSelection: true,
});
const styleHint = new TextRenderable(renderer, {
content: "↑↓ navigate enter select",
height: 1,
fg: C.hint,
marginTop: 1,
});
main.add(styleLabel);
main.add(styleSelect);
main.add(styleHint);
styleSelect.focus();
renderer.requestRender();
styleSelect.on("itemSelected", () => {
const opt = styleSelect.getSelectedOption();
if (!opt) return;
const style = opt.name as DocumentationStyle;
main.remove(styleLabel.id);
main.remove(styleSelect.id);
main.remove(styleHint.id);
const styleBreadcrumb = new TextRenderable(renderer, {
content: `Style: ${style}`,
height: 1,
fg: C.success,
marginBottom: 1,
});
main.add(styleBreadcrumb);
renderer.requestRender();
resolve(style);
});
});
}
function promptNextRepo(renderer: CliRenderer, main: BoxRenderable): Promise<boolean> {
return new Promise((resolve) => {
const prompt = new TextRenderable(renderer, {
content: "[n] next repo [q] quit",
height: 1,
fg: C.muted,
marginTop: 2,
});
main.add(prompt);
renderer.requestRender();
const handler = (key: any) => {
if (key.name === "n") {
renderer.keyInput.off("keypress", handler);
main.remove(prompt.id);
renderer.requestRender();
resolve(true);
} else if (key.name === "q") {
renderer.keyInput.off("keypress", handler);
main.remove(prompt.id);
resolve(false);
}
};
renderer.keyInput.on("keypress", handler);
});
}
// ============================================================================
// Main Entry Point
// ============================================================================
export async function launchTui(): Promise<void> {
await loadEnv();
const geminiApiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
const layers = Layer.merge(GeminiLive(geminiApiKey), GitHubLive(githubToken));
const renderer = await createCliRenderer({
exitOnCtrlC: true,
useAlternateScreen: true,
backgroundColor: C.bg,
});
// ── Main container ──
const main = new BoxRenderable(renderer, {
flexDirection: "column",
padding: 2,
gap: 0,
width: "100%",
height: "100%",
backgroundColor: C.bg,
});
// ── Header ──
const title = new TextRenderable(renderer, { content: "grepo", height: 1, fg: C.text });
const subtitle = new TextRenderable(renderer, {
content: "AI-powered GitHub repository tools",
height: 1,
fg: C.muted,
marginBottom: 1,
});
const divider = new TextRenderable(renderer, {
content: "─".repeat(56),
height: 1,
fg: C.border,
marginBottom: 1,
});
main.add(title);
main.add(subtitle);
main.add(divider);
renderer.root.add(main);
// ── Global escape handler ──
renderer.keyInput.on("keypress", (key: any) => {
if (key.name === "escape") {
renderer.destroy();
process.exit(0);
}
});
// ── Phase 1: Command Select ──
const selectedCommands = await new Promise<Command[]>((resolve) => {
const selectLabel = new TextRenderable(renderer, {
content: "Select commands (space to toggle, enter to confirm)",
height: 1,
fg: C.muted,
marginBottom: 1,
});
const multiSelect = createMultiSelect(renderer, COMMANDS, (cmds) => {
multiSelect.destroy();
main.remove(selectLabel.id);
main.remove(multiSelect.container.id);
main.remove(selectHint.id);
resolve(cmds);
});
const selectHint = new TextRenderable(renderer, {
content: "↑↓ navigate space toggle enter confirm esc quit",
height: 1,
fg: C.hint,
marginTop: 1,
});
main.add(selectLabel);
main.add(multiSelect.container);
main.add(selectHint);
renderer.start();
});
// Show breadcrumb of selected commands
const breadcrumb = new TextRenderable(renderer, {
content: `Commands: ${selectedCommands.join(", ")}`,
height: 1,
fg: C.success,
marginBottom: 1,
});
main.add(breadcrumb);
// ── Phase 2+ : URL input loop ──
let continueLoop = true;
while (continueLoop) {
const repoUrl = await promptForUrl(renderer, main);
const { owner, repo } = validation.parseGitHubUrl(repoUrl);
// Phase 3: Options (readme style)
let readmeStyle: DocumentationStyle = "standard";
if (selectedCommands.includes("readme")) {
readmeStyle = await promptForReadmeStyle(renderer, main);
}
// Phase 4: Execute all commands for this repo
await executeCommands(renderer, main, {
commands: selectedCommands,
repoUrl,
owner,
repo,
readmeStyle,
layers,
githubToken,
});
// Phase 5: Next repo prompt
continueLoop = await promptNextRepo(renderer, main);
}
renderer.destroy();
process.exit(0);
}

tui.ts

File Type: TS Lines: 1069 Size: 31.3 KB Generated: 3/15/2026, 3:46:41 AM


Purpose

The tui.ts file implements a Terminal User Interface (TUI) for an AI-powered GitHub repository management tool. It provides an interactive, visual environment for users to generate documentation (READMEs), suggest repository topics, create summaries, and analyze codebases using Gemini AI and the GitHub API.

Key elements

  • @opentui/core Components: Utilizes BoxRenderable, TextRenderable, InputRenderable, and ScrollBoxRenderable to build a structured, reactive terminal UI.
  • MultiSelectControl: A custom interactive component allowing users to navigate and toggle multiple commands (e.g., "readme", "topics", "tech") using keyboard shortcuts.
  • Effect & Layer: Employs the effect library for functional dependency injection and managing side effects related to AI (Gemini) and Git (GitHub) services.
  • GitHub Dark Palette (C): A constant object defining a specific color scheme (backgrounds, accents, borders) to mimic the GitHub web interface within the terminal.
  • createSpinner: A utility for providing visual feedback during asynchronous AI generation or API calls.
  • Command Definitions: A structured list of supported operations (readme, topics, describe, summary, tech, improve) with associated metadata and action labels.

Runtime & dependencies

  • Runtime: Node.js (implied by terminal interaction and environment variable loading).
  • Language: TypeScript.
  • External Dependencies:
    • @opentui/core: The underlying rendering engine for the terminal UI.
    • effect: Used for structured concurrency and service management.
  • Internal Dependencies:
    • services.ts: Provides Gemini and GitHub service implementations.
    • shared.ts: Contains shared types, command definitions, and environment loaders.
    • validation.ts: Handles input/data validation.

How it works

  1. Initialization: Loads environment variables and initializes the CliRenderer from @opentui/core.
  2. Command Selection: Displays a multi-select menu where the user chooses which AI tasks to perform on a repository.
  3. Data Fetching: Uses the GitHub service to fetch repository metadata and file contents (via fetchRepo).
  4. Execution Loop: Iterates through the selected commands. For each command:
    • Displays a spinner while the Gemini service generates content.
    • Parses the AI output (often extracting JSON from markdown blocks).
  5. Interactive Results: Presents the generated content (e.g., a new README or suggested topics) to the user.
  6. Action Handling: Listens for specific keypresses (e.g., s to save, p to push, a to apply) to commit the AI-generated changes back to the local filesystem or GitHub.

Usage

This file is intended to be the entry point for the interactive TUI mode of the application. It is typically executed via a CLI command (e.g., ts-node tui.ts or through a compiled binary). It requires valid environment variables (like GITHUB_TOKEN and GEMINI_API_KEY) to function, as it interacts with live external APIs.


Description generated using AI analysis

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment