From 7ef6b2a3846812b29fa7d130094388f104d56cfd Mon Sep 17 00:00:00 2001 From: Kevin Kern Date: Tue, 25 Nov 2025 23:24:02 +0100 Subject: [PATCH] feat(cli): add open command clipboard workflow --- packages/cli/package.json | 1 + packages/cli/src/args.ts | 2 + packages/cli/src/cli.ts | 1 + packages/cli/src/commands/default.ts | 15 ++ packages/cli/src/commands/open.ts | 273 ++++++++++++++++++++ packages/cli/src/help-prompt.ts | 24 ++ packages/cli/src/utils/browser.ts | 73 ++++++ packages/cli/src/utils/clipboard.ts | 90 +++++++ packages/cli/test/unit/args.test.ts | 14 + packages/cli/test/unit/browser.test.ts | 151 +++++++++++ packages/cli/test/unit/clipboard.test.ts | 154 +++++++++++ packages/cli/test/unit/help-prompt.test.ts | 13 + packages/cli/test/unit/open-command.test.ts | 168 ++++++++++++ pnpm-lock.yaml | 25 ++ 14 files changed, 1004 insertions(+) create mode 100644 packages/cli/src/commands/open.ts create mode 100644 packages/cli/src/utils/browser.ts create mode 100644 packages/cli/src/utils/clipboard.ts create mode 100644 packages/cli/test/unit/browser.test.ts create mode 100644 packages/cli/test/unit/clipboard.test.ts create mode 100644 packages/cli/test/unit/open-command.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 3a13d48..7081fce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,6 +39,7 @@ "cli": "node ./dist/cli.mjs" }, "dependencies": { + "@clack/prompts": "^0.11.0", "c12": "^2.0.1", "codefetch-sdk": "workspace:*", "consola": "^3.3.3", diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 52af52b..4b419bb 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -27,6 +27,7 @@ export function parseArgs(args: string[]) { "ignore-cors", "project-tree-skip-ignore-files", "exclude-markdown", + "copy", ], string: [ "output", @@ -220,5 +221,6 @@ export function parseArgs(args: string[]) { stdout: isStdout, projectTreeSkipIgnoreFiles: Boolean(argv["project-tree-skip-ignore-files"]), excludeMarkdown: Boolean(argv["exclude-markdown"]), + copy: Boolean(argv.copy), }; } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 40ce82a..3e1413f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,7 @@ type CommandModule = { const subCommands: Record Promise> = { _default: () => import("./commands/default"), init: () => import("./commands/init"), + open: () => import("./commands/open"), }; async function main() { diff --git a/packages/cli/src/commands/default.ts b/packages/cli/src/commands/default.ts index c12099d..517f2c6 100644 --- a/packages/cli/src/commands/default.ts +++ b/packages/cli/src/commands/default.ts @@ -16,6 +16,7 @@ import { } from "codefetch-sdk"; import { printHelp, parseArgs, loadCodefetchConfig } from ".."; import { formatModelInfo } from "../format-model-info"; +import { copyToClipboard } from "../utils/clipboard"; import type { TokenEncoder, TokenLimiter } from "codefetch-sdk"; // Helper to determine prompt file path @@ -298,6 +299,20 @@ export default async function defaultMain(rawArgs: Argv) { } } + // Copy to clipboard if --copy flag is set + if (args.copy) { + try { + const textToCopy = + typeof output === "string" ? output : JSON.stringify(output, null, 2); + await copyToClipboard(textToCopy); + logger.success("Output copied to clipboard"); + } catch (error) { + logger.error( + error instanceof Error ? error.message : "Failed to copy to clipboard" + ); + } + } + if (!config.noSummary) { let message = `Current Codebase: ${totalTokens.toLocaleString()} tokens`; diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts new file mode 100644 index 0000000..19c1e36 --- /dev/null +++ b/packages/cli/src/commands/open.ts @@ -0,0 +1,273 @@ +import { existsSync, promises as fsp } from "node:fs"; +import mri from "mri"; +import { resolve, join } from "pathe"; +import { spinner } from "@clack/prompts"; +import ignore from "ignore"; +import { + collectFiles, + generateMarkdown, + DEFAULT_IGNORE_PATTERNS, + findProjectRoot, + countTokens, + VALID_PROMPTS, + type CodefetchConfig, +} from "codefetch-sdk"; +import { loadCodefetchConfig, parseArgs } from ".."; +import { copyToClipboard } from "../utils/clipboard"; +import { openBrowser, buildChatUrl } from "../utils/browser"; +import type { TokenEncoder, TokenLimiter } from "codefetch-sdk"; + +// Default values for open command +const DEFAULTS = { + url: "chatgpt.com", + model: "gpt-4.1-pro", + prompt: + "Your codebase is in the clipboard - remove this text and paste it here", +}; + +// Helper to determine prompt file path +function getPromptFile( + config: CodefetchConfig & { inlinePrompt?: string } +): string | undefined { + if (config.inlinePrompt) { + return undefined; + } + if (VALID_PROMPTS.has(config.defaultPromptFile)) { + return config.defaultPromptFile; + } + return resolve(config.outputPath, "prompts", config.defaultPromptFile); +} + +// Parse open-specific args and separate codefetch args +function parseOpenArgs(args: string[]) { + const argv = mri(args, { + alias: { + o: "output", + e: "extension", + v: "verbose", + t: "project-tree", + d: "dry-run", + p: "prompt", + }, + string: [ + "chat-url", + "chat-model", + "chat-prompt", + // Standard codefetch options + "output", + "dir", + "extension", + "include-files", + "exclude-files", + "include-dir", + "exclude-dir", + "max-tokens", + "output-path", + "token-encoder", + "token-limiter", + "prompt", + "var", + "format", + ], + boolean: [ + "dry-run", + "enable-line-numbers", + "summary", + "project-tree-skip-ignore-files", + "exclude-markdown", + ], + }); + + // Handle --no-browser (mri converts --no-X to X: false) + // Also check for explicit --no-browser in args + const noBrowser = argv.browser === false || args.includes("--no-browser"); + + return { + // Open-specific args + chatUrl: (argv["chat-url"] as string) || DEFAULTS.url, + chatModel: (argv["chat-model"] as string) || DEFAULTS.model, + chatPrompt: (argv["chat-prompt"] as string) || DEFAULTS.prompt, + noBrowser, + // Pass through raw argv for codefetch args processing + rawArgv: argv, + }; +} + +// Generate codebase markdown using SDK +async function generateCodebase( + source: string, + config: CodefetchConfig & { inlinePrompt?: string } +): Promise { + const ig = ignore().add( + DEFAULT_IGNORE_PATTERNS.split("\n").filter( + (line: string) => line && !line.startsWith("#") + ) + ); + + const defaultIgnorePath = join(source, ".gitignore"); + if (existsSync(defaultIgnorePath)) { + const gitignoreContent = await fsp.readFile(defaultIgnorePath, "utf8"); + ig.add(gitignoreContent); + } + + const codefetchIgnorePath = join(source, ".codefetchignore"); + if (existsSync(codefetchIgnorePath)) { + const codefetchIgnoreContent = await fsp.readFile( + codefetchIgnorePath, + "utf8" + ); + ig.add(codefetchIgnoreContent); + } + + if (config.excludeMarkdown) { + ig.add(["*.md", "*.markdown", "*.mdx"]); + } + + const files = await collectFiles(source, { + ig, + extensionSet: config.extensions ? new Set(config.extensions) : null, + excludeFiles: config.excludeFiles || null, + includeFiles: config.includeFiles || null, + excludeDirs: config.excludeDirs || null, + includeDirs: config.includeDirs || null, + verbose: 0, // Suppress verbose output in open command + }); + + const markdown = await generateMarkdown(files, { + maxTokens: config.maxTokens ? Number(config.maxTokens) : null, + verbose: 0, + projectTree: Number(config.projectTree || 0), + tokenEncoder: (config.tokenEncoder as TokenEncoder) || "cl100k", + disableLineNumbers: config.disableLineNumbers !== false, + tokenLimiter: (config.tokenLimiter as TokenLimiter) || "truncated", + promptFile: getPromptFile(config), + inlinePrompt: config.inlinePrompt, + templateVars: config.templateVars, + projectTreeBaseDir: source, + projectTreeSkipIgnoreFiles: Boolean(config.projectTreeSkipIgnoreFiles), + }); + + return markdown; +} + +function printOpenHelp() { + console.log(` +Usage: codefetch open [options] + +Generates codebase, copies to clipboard, and opens browser to an AI chat. + +Options: + --chat-url AI chat URL (default: chatgpt.com) + --chat-model Model parameter for URL (default: gpt-4.1-pro) + --chat-prompt Message shown after opening + --no-browser Skip opening browser, just copy to clipboard + -h, --help Display this help message + +All standard codefetch options are also supported (e.g., -e, -t, --exclude-dir) + +Examples: + # Default: opens ChatGPT with gpt-4.1-pro + codefetch open + + # Custom AI chat URL and model + codefetch open --chat-url claude.ai --chat-model claude-3.5-sonnet + + # Combine with codefetch options + codefetch open -e .ts,.js --exclude-dir node_modules -t 3 + + # Just copy to clipboard without opening browser + codefetch open --no-browser +`); +} + +export default async function openCommand(rawArgs: mri.Argv) { + // Handle help flag + if (rawArgs.help || rawArgs.h) { + printOpenHelp(); + return; + } + + const args = parseOpenArgs(process.argv.slice(3)); // Skip 'node', 'cli', 'open' + const cliOverrides = parseArgs(process.argv.slice(3)); + + // Determine source directory + const isPromptMode = args.rawArgv.p || args.rawArgv.prompt; + const promptArg = args.rawArgv.p || args.rawArgv.prompt; + const hasPromptMessage = + isPromptMode && + typeof promptArg === "string" && + VALID_PROMPTS.has(promptArg) && + args.rawArgv._.length > 0; + + const source = resolve( + hasPromptMessage ? "" : args.rawArgv._[0] || args.rawArgv.dir || "" + ); + + // Check project root + const projectRoot = findProjectRoot(source); + if (projectRoot !== source && !process.env.CI) { + console.log( + `Note: Running from ${source}, project root detected at ${projectRoot}` + ); + } + + // Change to source directory + process.chdir(source); + + // Load codefetch config with CLI overrides + const config = await loadCodefetchConfig(source, cliOverrides); + + const s = spinner(); + + try { + // Step 1: Generate codebase + s.start("Generating codebase..."); + const output = await generateCodebase(source, config); + + // Count tokens for display + const totalTokens = await countTokens(output, config.tokenEncoder); + + // Step 2: Copy to clipboard + s.message("Copying to clipboard..."); + await copyToClipboard(output); + + // Step 3: Open browser (unless --no-browser) + if (!args.noBrowser) { + s.message("Opening browser..."); + const chatUrl = buildChatUrl(args.chatUrl, args.chatModel); + + try { + await openBrowser(chatUrl); + } catch { + // Browser opening failed, but clipboard succeeded + s.stop("Ready! (browser could not be opened automatically)"); + console.log(""); + console.log( + `📋 Codebase copied to clipboard (${totalTokens.toLocaleString()} tokens)` + ); + console.log(""); + console.log(`🌐 Open this URL manually: ${chatUrl}`); + console.log(""); + console.log(`💡 ${args.chatPrompt}`); + return; + } + } + + // Success + s.stop("Ready!"); + + console.log(""); + console.log( + `📋 Codebase copied to clipboard (${totalTokens.toLocaleString()} tokens)` + ); + if (!args.noBrowser) { + console.log(`🌐 Browser opened to ${args.chatUrl}`); + } + console.log(""); + console.log(`💡 ${args.chatPrompt}`); + console.log(""); + } catch (error) { + s.stop("Failed"); + throw error; + } +} diff --git a/packages/cli/src/help-prompt.ts b/packages/cli/src/help-prompt.ts index 7550768..1b2129f 100644 --- a/packages/cli/src/help-prompt.ts +++ b/packages/cli/src/help-prompt.ts @@ -4,6 +4,7 @@ Usage: codefetch [command] [options] Commands: init Initialize a new codefetch project + open Generate codebase, copy to clipboard, and open AI chat in browser Options: -o, --output Specify output filename (defaults to codebase.md) @@ -22,6 +23,7 @@ Options: --enable-line-numbers Enable line numbers in output (disabled by default to save tokens) --exclude-markdown Exclude markdown files (*.md, *.markdown, *.mdx) from output --format Output format (markdown, json) (default: markdown) + --copy Copy output to clipboard (works on macOS, Windows, and Linux) -h, --help Display this help message -p, --prompt Add a prompt: built-in (fix, improve, codegen, testgen), file (.md/.txt), or inline text @@ -63,5 +65,27 @@ Examples: # Analyze from GitLab or Bitbucket codefetch --url https://gitlab.com/user/repo codefetch --url https://bitbucket.org/user/repo + +Open Command (codefetch open): + Generates codebase, copies to clipboard, and opens browser to an AI chat. + + Options: + --chat-url AI chat URL (default: chatgpt.com) + --chat-model Model parameter for URL (default: gpt-4.1-pro) + --chat-prompt Message shown after opening (default: "Your codebase is in the clipboard...") + --no-browser Skip opening browser, just copy to clipboard + + Examples: + # Default: opens ChatGPT with gpt-4.1-pro + codefetch open + + # Custom AI chat URL and model + codefetch open --chat-url claude.ai --chat-model claude-3.5-sonnet + + # Combine with codefetch options + codefetch open -e .ts,.js --exclude-dir node_modules -t 3 + + # Just copy to clipboard without opening browser + codefetch open --no-browser `); } diff --git a/packages/cli/src/utils/browser.ts b/packages/cli/src/utils/browser.ts new file mode 100644 index 0000000..75a73ff --- /dev/null +++ b/packages/cli/src/utils/browser.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; + +/** + * Build a chat URL with model parameter + */ +export function buildChatUrl(baseUrl: string, model: string): string { + // Normalize URL - add https:// if no protocol + let url = baseUrl; + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = `https://${url}`; + } + + // Remove trailing slash for consistency + url = url.replace(/\/+$/, ""); + + // Add model parameter + const urlObj = new URL(url); + urlObj.searchParams.set("model", model); + + return urlObj.toString(); +} + +/** + * Open the default browser with the given URL + * Cross-platform: macOS (open), Windows (start), Linux (xdg-open) + */ +export async function openBrowser(url: string): Promise { + const platform = process.platform; + + let command: string; + let args: string[]; + + if (platform === "darwin") { + // macOS + command = "open"; + args = [url]; + } else if (platform === "win32") { + // Windows - use start command through cmd + command = "cmd"; + args = ["/c", "start", "", url]; + } else { + // Linux - use xdg-open + command = "xdg-open"; + args = [url]; + } + + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { + stdio: "ignore", + detached: true, + }); + + proc.on("error", (err) => { + if (platform === "linux") { + reject( + new Error( + "Could not open browser. On Linux, please install xdg-utils:\n" + + " Ubuntu/Debian: sudo apt-get install xdg-utils\n" + + " Fedora: sudo dnf install xdg-utils" + ) + ); + } else { + reject(new Error(`Failed to open browser: ${err.message}`)); + } + }); + + // Don't wait for the browser to close + proc.unref(); + + // Give it a moment to spawn, then resolve + setTimeout(resolve, 100); + }); +} diff --git a/packages/cli/src/utils/clipboard.ts b/packages/cli/src/utils/clipboard.ts new file mode 100644 index 0000000..cc42085 --- /dev/null +++ b/packages/cli/src/utils/clipboard.ts @@ -0,0 +1,90 @@ +import { spawn } from "node:child_process"; + +/** + * Cross-platform clipboard copy function + * Supports macOS (pbcopy), Windows (PowerShell), and Linux (xclip/xsel) + */ +export async function copyToClipboard(text: string): Promise { + const platform = process.platform; + + let command: string; + let args: string[]; + + if (platform === "darwin") { + // macOS + command = "pbcopy"; + args = []; + } else if (platform === "win32") { + // Windows - use PowerShell's Set-Clipboard for better Unicode support + command = "powershell"; + args = ["-NoProfile", "-Command", "Set-Clipboard", "-Value", "$input"]; + } else { + // Linux - try xclip first, fall back to xsel + command = "xclip"; + args = ["-selection", "clipboard"]; + } + + const tryClipboard = (cmd: string, cmdArgs: string[]): Promise => { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, cmdArgs, { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stderr = ""; + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(stderr || `exit code ${code}`)); + } + }); + + proc.on("error", (err) => { + reject(err); + }); + + // Write data to stdin and close it + proc.stdin?.write(text, (err) => { + if (err) { + reject(err); + } else { + proc.stdin?.end(); + } + }); + }); + }; + + try { + await tryClipboard(command, args); + } catch (error) { + // On Linux, if xclip fails, try xsel + if (platform === "linux" && command === "xclip") { + try { + await tryClipboard("xsel", ["--clipboard", "--input"]); + } catch { + throw new Error( + "Clipboard copy failed. On Linux, please install xclip or xsel:\n" + + " Ubuntu/Debian: sudo apt-get install xclip\n" + + " Fedora: sudo dnf install xclip\n" + + " Arch: sudo pacman -S xclip" + ); + } + } else if (platform === "linux") { + throw new Error( + "Clipboard copy failed. On Linux, please install xclip or xsel:\n" + + " Ubuntu/Debian: sudo apt-get install xclip\n" + + " Fedora: sudo dnf install xclip\n" + + " Arch: sudo pacman -S xclip" + ); + } else { + throw new Error( + `Clipboard copy failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/packages/cli/test/unit/args.test.ts b/packages/cli/test/unit/args.test.ts index 7daf2ae..cdfe741 100644 --- a/packages/cli/test/unit/args.test.ts +++ b/packages/cli/test/unit/args.test.ts @@ -185,4 +185,18 @@ describe("parseArgs", () => { verbose: 2, }); }); + + it("should parse --copy flag", () => { + const args = ["--copy"]; + const result = parseArgs(args); + + expect(result.copy).toBe(true); + }); + + it("should default copy to false when not specified", () => { + const args = ["-o", "output.md"]; + const result = parseArgs(args); + + expect(result.copy).toBe(false); + }); }); diff --git a/packages/cli/test/unit/browser.test.ts b/packages/cli/test/unit/browser.test.ts new file mode 100644 index 0000000..a027714 --- /dev/null +++ b/packages/cli/test/unit/browser.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { buildChatUrl, openBrowser } from "../../src/utils/browser"; +import { spawn } from "node:child_process"; + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +const spawnMock = vi.mocked(spawn); + +type BrowserChild = { + on: ReturnType; + unref: ReturnType; + emitError: (error: Error) => void; +}; + +function createBrowserChild(): BrowserChild { + const events = new Map void>(); + return { + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + events.set(event, handler); + }), + unref: vi.fn(), + emitError: (error: Error) => { + const handler = events.get("error"); + if (handler) handler(error); + }, + }; +} + +let platformSpy: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + spawnMock.mockReset(); + platformSpy = vi.spyOn(process, "platform", "get"); +}); + +afterEach(() => { + vi.useRealTimers(); + platformSpy.mockRestore(); +}); + +describe("browser utils", () => { + describe("buildChatUrl", () => { + it("should build URL with model parameter", () => { + const url = buildChatUrl("chatgpt.com", "gpt-4.1-pro"); + expect(url).toBe("https://chatgpt.com/?model=gpt-4.1-pro"); + }); + + it("should add https:// if no protocol provided", () => { + const url = buildChatUrl("claude.ai", "claude-3.5-sonnet"); + expect(url).toBe("https://claude.ai/?model=claude-3.5-sonnet"); + }); + + it("should preserve http:// if provided", () => { + const url = buildChatUrl("http://localhost:3000", "test-model"); + expect(url).toBe("http://localhost:3000/?model=test-model"); + }); + + it("should preserve https:// if provided", () => { + const url = buildChatUrl("https://api.openai.com", "gpt-4"); + expect(url).toBe("https://api.openai.com/?model=gpt-4"); + }); + + it("should remove trailing slashes before adding params", () => { + const url = buildChatUrl("chatgpt.com/", "gpt-4"); + expect(url).toBe("https://chatgpt.com/?model=gpt-4"); + }); + + it("should handle URLs with existing paths", () => { + const url = buildChatUrl("example.com/chat", "model-x"); + expect(url).toBe("https://example.com/chat?model=model-x"); + }); + + it("should handle URLs with existing query params", () => { + const url = buildChatUrl("example.com?foo=bar", "model-x"); + expect(url).toBe("https://example.com/?foo=bar&model=model-x"); + }); + + it("should encode special characters in model name", () => { + const url = buildChatUrl("chatgpt.com", "model with spaces"); + expect(url).toBe("https://chatgpt.com/?model=model+with+spaces"); + }); + }); + + describe("openBrowser", () => { + it("should use macOS open command", async () => { + platformSpy.mockReturnValue("darwin"); + const child = createBrowserChild(); + spawnMock.mockReturnValue(child as any); + + const promise = openBrowser("https://example.com"); + await vi.advanceTimersByTimeAsync(150); + await promise; + + expect(spawnMock).toHaveBeenCalledWith( + "open", + ["https://example.com"], + expect.objectContaining({ stdio: "ignore", detached: true }) + ); + expect(child.unref).toHaveBeenCalled(); + }); + + it("should use Windows start command", async () => { + platformSpy.mockReturnValue("win32"); + const child = createBrowserChild(); + spawnMock.mockReturnValue(child as any); + + const promise = openBrowser("https://example.com"); + await vi.advanceTimersByTimeAsync(150); + await promise; + + expect(spawnMock).toHaveBeenCalledWith( + "cmd", + ["/c", "start", "", "https://example.com"], + expect.objectContaining({ stdio: "ignore", detached: true }) + ); + }); + + it("should use xdg-open on Linux", async () => { + platformSpy.mockReturnValue("linux"); + const child = createBrowserChild(); + spawnMock.mockReturnValue(child as any); + + const promise = openBrowser("https://example.com"); + await vi.advanceTimersByTimeAsync(150); + await promise; + + expect(spawnMock).toHaveBeenCalledWith( + "xdg-open", + ["https://example.com"], + expect.objectContaining({ stdio: "ignore", detached: true }) + ); + }); + + it("should surface helpful error on Linux when open fails", async () => { + platformSpy.mockReturnValue("linux"); + const child = createBrowserChild(); + spawnMock.mockReturnValue(child as any); + + const promise = openBrowser("https://example.com"); + const error = new Error("xdg missing"); + child.emitError(error); + + await expect(promise).rejects.toThrow( + "Could not open browser. On Linux, please install xdg-utils" + ); + }); + }); +}); diff --git a/packages/cli/test/unit/clipboard.test.ts b/packages/cli/test/unit/clipboard.test.ts new file mode 100644 index 0000000..d9acb71 --- /dev/null +++ b/packages/cli/test/unit/clipboard.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { copyToClipboard } from "../../src/utils/clipboard"; +import { spawn } from "node:child_process"; + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +const spawnMock = vi.mocked(spawn); + +type ClipboardChild = { + on: ReturnType; + stderr: { on: ReturnType }; + stdin: { + write: ReturnType; + end: ReturnType; + }; + emit: (event: string, value: number | Error) => void; + emitStderr: (data: string) => void; +}; + +function createClipboardChild(): ClipboardChild { + const events = new Map void>(); + const stderrEvents = new Map void>(); + return { + on: vi.fn((event: string, handler: (value: any) => void) => { + events.set(event, handler); + }), + stderr: { + on: vi.fn((event: string, handler: (value: any) => void) => { + stderrEvents.set(event, handler); + }), + }, + stdin: { + write: vi.fn((_text: string, cb?: (err?: Error | null) => void) => { + cb?.(null); + }), + end: vi.fn(), + }, + emit: (event: string, value: number | Error) => { + const handler = events.get(event); + if (handler) handler(value); + }, + emitStderr: (data: string) => { + const handler = stderrEvents.get("data"); + if (handler) handler(Buffer.from(data)); + }, + }; +} + +let platformSpy: ReturnType; +let children: ClipboardChild[] = []; + +beforeEach(() => { + children = []; + spawnMock.mockImplementation(() => { + const child = createClipboardChild(); + children.push(child); + return child as any; + }); + platformSpy = vi.spyOn(process, "platform", "get"); +}); + +afterEach(() => { + platformSpy.mockRestore(); + spawnMock.mockReset(); +}); + +describe("clipboard utils", () => { + it("should use pbcopy on macOS", async () => { + platformSpy.mockReturnValue("darwin"); + + const promise = copyToClipboard("hello world"); + const child = children[0]; + child.emit("close", 0); + await promise; + + expect(spawnMock).toHaveBeenCalledWith( + "pbcopy", + [], + expect.objectContaining({ + stdio: ["pipe", "pipe", "pipe"], + }) + ); + expect(child.stdin.write).toHaveBeenCalledWith( + "hello world", + expect.any(Function) + ); + }); + + it("should use PowerShell on Windows", async () => { + platformSpy.mockReturnValue("win32"); + + const promise = copyToClipboard("win text"); + const child = children[0]; + child.emit("close", 0); + await promise; + + expect(spawnMock).toHaveBeenCalledWith( + "powershell", + ["-NoProfile", "-Command", "Set-Clipboard", "-Value", "$input"], + expect.objectContaining({ + stdio: ["pipe", "pipe", "pipe"], + }) + ); + }); + + it("should fall back to xsel when xclip fails on Linux", async () => { + platformSpy.mockReturnValue("linux"); + + const promise = copyToClipboard("linux text"); + const first = children[0]; + first.emit("close", 1); // xclip fails + + await Promise.resolve(); + expect(children.length).toBeGreaterThanOrEqual(2); + const second = children[1]; + second.emit("close", 0); // xsel succeeds + + await promise; + expect(spawnMock).toHaveBeenNthCalledWith( + 1, + "xclip", + ["-selection", "clipboard"], + expect.objectContaining({ + stdio: ["pipe", "pipe", "pipe"], + }) + ); + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + "xsel", + ["--clipboard", "--input"], + expect.objectContaining({ + stdio: ["pipe", "pipe", "pipe"], + }) + ); + }); + + it("should throw helpful error when both xclip and xsel fail", async () => { + platformSpy.mockReturnValue("linux"); + + const promise = copyToClipboard("linux fail"); + const first = children[0]; + first.emit("close", 1); + + await Promise.resolve(); + const second = children[1]; + second.emit("close", 1); + + await expect(promise).rejects.toThrow( + "Clipboard copy failed. On Linux, please install xclip or xsel" + ); + }); +}); diff --git a/packages/cli/test/unit/help-prompt.test.ts b/packages/cli/test/unit/help-prompt.test.ts index d63bd7a..31401b4 100644 --- a/packages/cli/test/unit/help-prompt.test.ts +++ b/packages/cli/test/unit/help-prompt.test.ts @@ -25,6 +25,18 @@ describe("help-prompt", () => { expect(helpText).toContain("Git Repository Options:"); expect(helpText).toContain("Web Crawling Options:"); expect(helpText).toContain("Examples:"); + expect(helpText).toContain("Open Command"); + }); + + it("should include open command documentation", () => { + printHelp(); + const helpText = consoleSpy.mock.calls[0][0]; + + expect(helpText).toContain("open"); + expect(helpText).toContain("--chat-url"); + expect(helpText).toContain("--chat-model"); + expect(helpText).toContain("--no-browser"); + expect(helpText).toContain("codefetch open"); }); it("should include all main options", () => { @@ -42,6 +54,7 @@ describe("help-prompt", () => { expect(helpText).toContain("-t, --project-tree"); expect(helpText).toContain("--enable-line-numbers"); expect(helpText).toContain("-p, --prompt"); + expect(helpText).toContain("--copy"); }); it("should include git repository options", () => { diff --git a/packages/cli/test/unit/open-command.test.ts b/packages/cli/test/unit/open-command.test.ts new file mode 100644 index 0000000..d65472c --- /dev/null +++ b/packages/cli/test/unit/open-command.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "pathe"; +import type { Argv } from "mri"; + +// Create mock functions +const mockCopyToClipboard = vi.fn().mockResolvedValue(undefined); +const mockOpenBrowser = vi.fn().mockResolvedValue(undefined); +const mockBuildChatUrl = vi.fn( + (url, model) => `https://${url}/?model=${model}` +); +const mockSpinner = vi.fn(() => ({ + start: vi.fn(), + message: vi.fn(), + stop: vi.fn(), +})); + +const createRawArgs = (overrides: Partial = {}): Argv => + ({ + _: [], + ...overrides, + }) as Argv; + +// Mock the clipboard and browser utilities +vi.mock("../../src/utils/clipboard", () => ({ + copyToClipboard: mockCopyToClipboard, +})); + +vi.mock("../../src/utils/browser", () => ({ + openBrowser: mockOpenBrowser, + buildChatUrl: mockBuildChatUrl, +})); + +// Mock @clack/prompts spinner +vi.mock("@clack/prompts", () => ({ + spinner: mockSpinner, +})); + +describe("open command", () => { + let tempDir: string; + let originalCwd: string; + let originalArgv: string[]; + let consoleSpy: ReturnType; + + beforeEach(async () => { + // Clear all mocks before each test + mockCopyToClipboard.mockClear(); + mockOpenBrowser.mockClear(); + mockBuildChatUrl.mockClear(); + mockSpinner.mockClear(); + + tempDir = await mkdtemp(join(tmpdir(), "codefetch-open-test-")); + originalCwd = process.cwd(); + originalArgv = [...process.argv]; + + // Create a minimal test project + await writeFile(join(tempDir, "index.ts"), 'export const hello = "world";'); + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ name: "test-project" }) + ); + + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + process.chdir(originalCwd); + process.argv = originalArgv; + consoleSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("should copy codebase to clipboard and open browser", async () => { + // Set up argv for open command + process.argv = ["node", "cli.ts", "open"]; + process.chdir(tempDir); + + const openCommand = (await import("../../src/commands/open")).default; + await openCommand(createRawArgs()); + + // Verify clipboard was called + expect(mockCopyToClipboard).toHaveBeenCalled(); + const clipboardContent = mockCopyToClipboard.mock.calls[0][0]; + expect(clipboardContent).toContain("index.ts"); + + // Verify browser was opened + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://chatgpt.com/?model=gpt-4.1-pro" + ); + }); + + it("should use custom chat-url and chat-model", async () => { + process.argv = [ + "node", + "cli.ts", + "open", + "--chat-url", + "claude.ai", + "--chat-model", + "claude-3.5-sonnet", + ]; + process.chdir(tempDir); + + const openCommand = (await import("../../src/commands/open")).default; + await openCommand(createRawArgs()); + + expect(mockBuildChatUrl).toHaveBeenCalledWith( + "claude.ai", + "claude-3.5-sonnet" + ); + expect(mockOpenBrowser).toHaveBeenCalled(); + }); + + it("should skip browser with --no-browser flag", async () => { + process.argv = ["node", "cli.ts", "open", "--no-browser"]; + process.chdir(tempDir); + + const openCommand = (await import("../../src/commands/open")).default; + await openCommand(createRawArgs()); + + // Clipboard should still be called + expect(mockCopyToClipboard).toHaveBeenCalled(); + + // Browser should NOT be called + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + it("should display custom chat-prompt message", async () => { + const customPrompt = "Custom instruction message"; + + process.argv = [ + "node", + "cli.ts", + "open", + "--chat-prompt", + customPrompt, + "--no-browser", + ]; + process.chdir(tempDir); + + const openCommand = (await import("../../src/commands/open")).default; + await openCommand(createRawArgs()); + + // Check that the custom prompt appears in console output + const allLogs = consoleSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(allLogs).toContain(customPrompt); + }); + + it("should respect codefetch options like -e extension", async () => { + // Create additional files + await writeFile(join(tempDir, "script.js"), "console.log('js');"); + + process.argv = ["node", "cli.ts", "open", "-e", ".ts", "--no-browser"]; + process.chdir(tempDir); + + const openCommand = (await import("../../src/commands/open")).default; + await openCommand(createRawArgs()); + + const clipboardContent = mockCopyToClipboard.mock.calls[0][0]; + + // Should include .ts files + expect(clipboardContent).toContain("index.ts"); + + // Should NOT include .js files (filtered by -e .ts) + expect(clipboardContent).not.toContain("script.js"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ae5491..67583fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: packages/cli: dependencies: + '@clack/prompts': + specifier: ^0.11.0 + version: 0.11.0 c12: specifier: ^2.0.1 version: 2.0.1(magicast@0.3.5) @@ -244,6 +247,12 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@cloudflare/workers-types@4.20250718.0': resolution: {integrity: sha512-RpYLgb81veUGtlLQINwGldsXQDcaK2/Z6QGeSq88yyd9o4tZYw7dzMu34sHgoCeb0QiPQWtetXiPf99PrIj+YQ==} @@ -3265,6 +3274,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smol-toml@1.4.1: resolution: {integrity: sha512-CxdwHXyYTONGHThDbq5XdwbFsuY4wlClRGejfE2NtwUtiHYsP1QtNsHb/hnj31jKYSchztJsaA8pSQoVzkfCFg==} engines: {node: '>= 18'} @@ -3856,6 +3868,17 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@cloudflare/workers-types@4.20250718.0': {} '@csstools/color-helpers@5.0.2': @@ -6824,6 +6847,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + smol-toml@1.4.1: {} source-map-js@1.2.1: {}