diff --git a/package-lock.json b/package-lock.json index 55fabd1..d00fac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "repofetch", "version": "0.1.1", "license": "MIT", + "dependencies": { + "commander": "^14.0.2" + }, "bin": { "repofetch": "dist/cli.js" }, @@ -29,6 +32,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index acd45f3..783d743 100644 --- a/package.json +++ b/package.json @@ -50,5 +50,8 @@ "devDependencies": { "@types/node": "^20.10.0", "typescript": "^5.3.0" + }, + "dependencies": { + "commander": "^14.0.2" } } diff --git a/src/cli.ts b/src/cli.ts index 46e440d..f293a1b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node +import { program } from "commander"; import { repofetch } from "./index.js"; import { formatOutput, formatContentOutput } from "./output.js"; import type { OutputFormat } from "./output.js"; -import type { EntryType } from "./types.js"; +import type { EntryType, RateLimitInfo } from "./types.js"; import fs from "fs"; import path from "path"; import os from "os"; @@ -15,114 +16,6 @@ const pkg = require("../package.json"); const CONFIG_PATH = path.join(os.homedir(), ".repofetch"); -const HELP = ` -repofetch - Fetch and explore remote repository structures and contents - -Usage: - repofetch [options] - -Options: - -b, --branch Branch to fetch (default: main, falls back to default branch) - -t, --token GitHub personal access token - --save-token Save token to ~/.repofetch for future use - -Filtering: - -e, --ext Filter by file extensions (comma-separated: .ts,.js) - --type Filter by entry type: file, dir, all (default: all) - --exclude Exclude patterns (comma-separated: node_modules,dist) - --include Include only matching patterns - -Content: - -c, --content Fetch file contents (use with --ext or --sha) - --sha Fetch content for specific files by SHA (comma-separated) - --max-size Max file size to fetch content (default: 1MB) - --concurrency Concurrent requests for content (default: 5) - -Output: - -f, --format Output format: ascii, json, json-pretty, paths (default: ascii) - --icons Show file/folder icons in ascii output - --size Show file sizes in ascii output - -Other: - -v, --version Show version - -h, --help Show this help - -Examples: - repofetch facebook/react - repofetch microsoft/typescript -b main --ext .ts,.tsx - repofetch owner/repo --exclude node_modules,dist --format json - repofetch owner/repo --ext .md --content --format json-pretty -`; - -interface Args { - repo?: string; - branch?: string; - token?: string; - saveToken?: string; - extensions?: string[]; - exclude?: string[]; - include?: string[]; - type?: EntryType; - content?: boolean; - shas?: string[]; - maxSize?: number; - concurrency?: number; - format?: OutputFormat; - icons?: boolean; - showSize?: boolean; - help?: boolean; - version?: boolean; -} - -function parseArgs(argv: string[]): Args { - const args: Args = {}; - let i = 0; - - while (i < argv.length) { - const arg = argv[i]; - - if (arg === "-h" || arg === "--help") { - args.help = true; - } else if (arg === "-v" || arg === "--version") { - args.version = true; - } else if (arg === "-b" || arg === "--branch") { - args.branch = argv[++i]; - } else if (arg === "-t" || arg === "--token") { - args.token = argv[++i]; - } else if (arg === "--save-token") { - args.saveToken = argv[++i]; - } else if (arg === "-e" || arg === "--ext") { - args.extensions = argv[++i]?.split(",").map((e) => e.trim()); - } else if (arg === "--exclude") { - args.exclude = argv[++i]?.split(",").map((e) => e.trim()); - } else if (arg === "--include") { - args.include = argv[++i]?.split(",").map((e) => e.trim()); - } else if (arg === "--type") { - args.type = argv[++i] as EntryType; - } else if (arg === "-c" || arg === "--content") { - args.content = true; - } else if (arg === "--sha") { - args.shas = argv[++i]?.split(",").map((s) => s.trim()); - } else if (arg === "--max-size") { - args.maxSize = parseInt(argv[++i], 10); - } else if (arg === "--concurrency") { - args.concurrency = parseInt(argv[++i], 10); - } else if (arg === "-f" || arg === "--format") { - args.format = argv[++i] as OutputFormat; - } else if (arg === "--icons") { - args.icons = true; - } else if (arg === "--size") { - args.showSize = true; - } else if (!arg.startsWith("-") && !args.repo) { - args.repo = arg; - } - - i++; - } - - return args; -} - function loadToken(): string | undefined { try { return fs.readFileSync(CONFIG_PATH, "utf-8").trim(); @@ -133,7 +26,28 @@ function loadToken(): string | undefined { function saveToken(token: string): void { fs.writeFileSync(CONFIG_PATH, token, { mode: 0o600 }); - console.log(`Token saved to ${CONFIG_PATH}`); + console.error(`Token saved to ${CONFIG_PATH}`); +} + +function formatRateLimit(rateLimit: RateLimitInfo): string { + const resetIn = Math.max( + 0, + Math.ceil((rateLimit.reset.getTime() - Date.now()) / 1000 / 60) + ); + return `Rate limit: ${rateLimit.remaining}/${rateLimit.limit} remaining (resets in ${resetIn}m)`; +} + +function shouldShowRateLimit( + isAuthenticated: boolean, + rateLimit: RateLimitInfo | undefined +): boolean { + if (!rateLimit) return false; + + // Unauthenticated: always show (to encourage adding token) + if (!isAuthenticated) return true; + + // Authenticated: only show when remaining < 1000 (approaching limit) + return rateLimit.remaining < 1000; } function copyToClipboard(text: string): boolean { @@ -151,74 +65,105 @@ function copyToClipboard(text: string): boolean { } } -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); - - if (args.version) { - console.log(pkg.version); - process.exit(0); - } - - if (args.help || !args.repo) { - console.log(HELP); - process.exit(args.help ? 0 : 1); - } - - if (args.saveToken) { - saveToken(args.saveToken); - process.exit(0); - } +function parseList(value: string): string[] { + return value.split(",").map((s) => s.trim()); +} - const token = args.token || loadToken() || process.env.GITHUB_TOKEN; - const isJsonFormat = args.format === "json" || args.format === "json-pretty"; +program + .name("repofetch") + .description("Fetch and explore remote repository structures and contents") + .version(pkg.version) + .argument("", "Repository in owner/repo format") + // Options + .option("-b, --branch ", "Branch to fetch (default: main)") + .option("-t, --token ", "GitHub personal access token") + .option("--save-token ", "Save token to ~/.repofetch for future use") + // Filtering + .option("-e, --ext ", "Filter by file extensions (comma-separated)", parseList) + .option("--type ", "Filter by entry type: file, dir, all", "all") + .option("--exclude ", "Exclude patterns (comma-separated)", parseList) + .option("--include ", "Include only matching patterns", parseList) + // Content + .option("-c, --content", "Fetch file contents") + .option("--sha ", "Fetch content for specific files by SHA (comma-separated)", parseList) + .option("--max-size ", "Max file size to fetch content (default: 1MB)", parseInt) + .option("--concurrency ", "Concurrent requests for content (default: 5)", parseInt) + // Output + .option("-f, --format ", "Output format: ascii, json, json-pretty, paths", "ascii") + .option("--icons", "Show file/folder icons in ascii output") + .option("--size", "Show file sizes in ascii output") + .action(async (repo: string, options) => { + // Save token if provided + if (options.saveToken) { + saveToken(options.saveToken); + } - if (!isJsonFormat) { - console.error(`\n📦 Fetching ${args.repo}...`); - } + const token = + options.saveToken || options.token || loadToken() || process.env.GITHUB_TOKEN; + const isJsonFormat = options.format === "json" || options.format === "json-pretty"; - try { - const result = await repofetch(args.repo, { - branch: args.branch, - token, - extensions: args.extensions, - exclude: args.exclude, - include: args.include, - type: args.type, - content: args.content, - shas: args.shas, - maxFileSize: args.maxSize, - concurrency: args.concurrency, - }); - - const output = formatOutput(result, { - format: args.format || "ascii", - icons: args.icons, - showSize: args.showSize, - showContent: args.content, - }); - - console.log(output); - - // For ascii output, also show content separately if fetched - if (!isJsonFormat && args.content) { - console.log(formatContentOutput(result)); + if (!isJsonFormat) { + console.error(`\n📦 Fetching ${repo}...`); } - // Copy to clipboard for ascii format - if (!isJsonFormat) { - if (copyToClipboard(output)) { - console.error(`\n✨ Tree copied to clipboard!`); + try { + const result = await repofetch(repo, { + branch: options.branch, + token, + extensions: options.ext, + exclude: options.exclude, + include: options.include, + type: options.type as EntryType, + content: options.content, + shas: options.sha, + maxFileSize: options.maxSize, + concurrency: options.concurrency, + }); + + const output = formatOutput(result, { + format: options.format as OutputFormat, + icons: options.icons, + showSize: options.size, + showContent: options.content, + }); + + console.log(output); + + // For ascii output, also show content separately if fetched + if (!isJsonFormat && options.content) { + console.log(formatContentOutput(result)); } + + // Copy to clipboard for ascii format + if (!isJsonFormat) { + if (copyToClipboard(output)) { + console.error(`\n✨ Tree copied to clipboard!`); + } + + // Show rate limit info + if (shouldShowRateLimit(result.isAuthenticated ?? false, result.rateLimit)) { + if (result.rateLimit) { + const rateLimitMsg = formatRateLimit(result.rateLimit); + if (!result.isAuthenticated) { + console.error(`\n⚠️ ${rateLimitMsg}`); + console.error( + ` Tip: Use --token or --save-token to increase limit to 5000/hour` + ); + } else { + console.error(`\n⚠️ ${rateLimitMsg}`); + } + } + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (isJsonFormat) { + console.log(JSON.stringify({ error: message })); + } else { + console.error(`\n❌ Error: ${message}`); + } + process.exit(1); } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (isJsonFormat) { - console.log(JSON.stringify({ error: message })); - } else { - console.error(`\n❌ Error: ${message}`); - } - process.exit(1); - } -} + }); -main(); +program.parse(); diff --git a/src/github.ts b/src/github.ts index 55951aa..b6b32c1 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,26 +1,52 @@ -import type { TreeResponse, BlobResponse } from "./types.js"; +import type { TreeResponse, BlobResponse, RateLimitInfo } from "./types.js"; const API_BASE = "https://api.github.com"; export class GitHubClient { private token?: string; private headers: Record; + private _rateLimit: RateLimitInfo | null = null; constructor(token?: string) { this.token = token; this.headers = { Accept: "application/vnd.github.v3+json", - "User-Agent": "gtree-cli", + "User-Agent": "repofetch-cli", }; if (token) { this.headers["Authorization"] = `Bearer ${token}`; } } + get isAuthenticated(): boolean { + return !!this.token; + } + + get rateLimit(): RateLimitInfo | null { + return this._rateLimit; + } + + private updateRateLimit(res: Response): void { + const limit = res.headers.get("X-RateLimit-Limit"); + const remaining = res.headers.get("X-RateLimit-Remaining"); + const used = res.headers.get("X-RateLimit-Used"); + const reset = res.headers.get("X-RateLimit-Reset"); + + if (limit && remaining && used && reset) { + this._rateLimit = { + limit: parseInt(limit, 10), + remaining: parseInt(remaining, 10), + used: parseInt(used, 10), + reset: new Date(parseInt(reset, 10) * 1000), + }; + } + } + async getDefaultBranch(repo: string): Promise { const res = await fetch(`${API_BASE}/repos/${repo}`, { headers: this.headers, }); + this.updateRateLimit(res); if (!res.ok) { if (res.status === 404) { @@ -42,6 +68,7 @@ export class GitHubClient { let res = await fetch(`${API_BASE}/repos/${repo}/commits/${targetBranch}`, { headers: this.headers, }); + this.updateRateLimit(res); // If branch not found, try default branch if (!res.ok && (res.status === 404 || res.status === 422)) { @@ -49,6 +76,7 @@ export class GitHubClient { res = await fetch(`${API_BASE}/repos/${repo}/commits/${targetBranch}`, { headers: this.headers, }); + this.updateRateLimit(res); } if (!res.ok) { @@ -66,6 +94,7 @@ export class GitHubClient { `${API_BASE}/repos/${repo}/git/trees/${treeSha}?recursive=1`, { headers: this.headers } ); + this.updateRateLimit(treeRes); if (!treeRes.ok) { throw new Error(`Failed to get tree: ${treeRes.status} ${treeRes.statusText}`); @@ -79,6 +108,7 @@ export class GitHubClient { const res = await fetch(`${API_BASE}/repos/${repo}/git/blobs/${sha}`, { headers: this.headers, }); + this.updateRateLimit(res); if (!res.ok) { throw new Error(`Failed to get blob: ${res.status} ${res.statusText}`); diff --git a/src/index.ts b/src/index.ts index 3f6ff99..777cf27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export type { RepoFetchResult, RepoFetchOptions, EntryType, + RateLimitInfo, } from "./types.js"; export type { FilterOptions } from "./filter.js"; export type { FetchContentOptions } from "./content.js"; @@ -70,6 +71,8 @@ export async function repofetch( branch: actualBranch, truncated: tree.truncated, files: entries, + rateLimit: client.rateLimit ?? undefined, + isAuthenticated: client.isAuthenticated, }; } diff --git a/src/types.ts b/src/types.ts index 17a18ca..c8e8cba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,10 +33,19 @@ export interface RepoFetchResult { branch: string; truncated: boolean; files: FileEntry[]; + rateLimit?: RateLimitInfo; + isAuthenticated?: boolean; } export type EntryType = "file" | "dir" | "all"; +export interface RateLimitInfo { + limit: number; + remaining: number; + used: number; + reset: Date; +} + export interface RepoFetchOptions { branch?: string; token?: string;