UNPKG

@terragon-labs/cli

Version:

![](https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat-square) [![npm]](https://www.npmjs.com/package/@terragon-labs/cli)

1,141 lines (1,117 loc) 42.4 kB
#!/usr/bin/env node // src/index.tsx import { program } from "commander"; import { render } from "ink"; import React9 from "react"; import { readFile } from "fs/promises"; import { fileURLToPath as fileURLToPath2 } from "url"; import { dirname as dirname2, join as join4 } from "path"; // src/commands/auth.tsx import React, { useState, useEffect } from "react"; import { Box, Text } from "ink"; import Spinner from "ink-spinner"; import TextInput from "ink-text-input"; import { createServer } from "http"; import { exec } from "child_process"; // src/hooks/useApi.ts import { useQuery, useMutation } from "@tanstack/react-query"; // src/utils/apiClient.ts import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; // src/utils/config.ts import { promises as fs } from "fs"; import { homedir } from "os"; import { join } from "path"; var CONFIG_DIR = join(homedir(), ".terry"); var CONFIG_FILE = join(CONFIG_DIR, "config.json"); async function readConfig() { try { const content = await fs.readFile(CONFIG_FILE, "utf-8"); return JSON.parse(content); } catch { return {}; } } async function getApiKey() { const config = await readConfig(); return config.apiKey || null; } async function saveApiKey(apiKey) { await fs.mkdir(CONFIG_DIR, { recursive: true }); const config = await readConfig(); config.apiKey = apiKey; await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); } // src/utils/apiClient.ts var link = new RPCLink({ url: `${"https://www.terragonlabs.com"}/api/cli`, headers: async () => ({ "X-Daemon-Token": await getApiKey() ?? "" }) }); var apiClient = createORPCClient(link); // src/hooks/useApi.ts import { safe, isDefinedError } from "@orpc/client"; async function fetchThreads(repo) { const [error, result] = await safe( apiClient.threads.list({ repo }) ); if (isDefinedError(error)) { switch (error.code) { case "UNAUTHORIZED": throw new Error("Authentication failed. Try running 'terry auth'."); case "NOT_FOUND": throw new Error("No tasks found"); case "INTERNAL_ERROR": throw new Error("Internal server error"); case "RATE_LIMIT_EXCEEDED": throw new Error("Rate limit exceeded. Please try again later."); default: const _exhaustiveCheck = error; throw new Error(`Unknown error: ${_exhaustiveCheck}`); } } else if (error) { throw new Error("Failed to fetch tasks"); } return result; } async function fetchThreadDetail(threadId) { const [error, result] = await safe( apiClient.threads.detail({ threadId }) ); if (isDefinedError(error)) { switch (error.code) { case "UNAUTHORIZED": throw new Error("Authentication failed. Try running 'terry auth'."); case "NOT_FOUND": throw new Error("Task not found"); case "INTERNAL_ERROR": throw new Error("Internal server error"); case "RATE_LIMIT_EXCEEDED": throw new Error("Rate limit exceeded. Please try again later."); default: const _exhaustiveCheck = error; throw new Error(`Unknown error: ${_exhaustiveCheck}`); } } else if (error) { throw new Error("Failed to fetch task detail"); } return result; } function useThreads(repo) { return useQuery({ queryKey: ["threads", repo], queryFn: () => fetchThreads(repo) }); } function useThreadDetail(threadId) { return useQuery({ queryKey: ["thread", threadId], queryFn: () => fetchThreadDetail(threadId), enabled: !!threadId }); } function useSaveApiKey() { return useMutation({ mutationFn: async (apiKey) => { await saveApiKey(apiKey); } }); } // src/commands/auth.tsx var AUTH_PORT = 8742; var TERRAGON_WEB_URL = "https://www.terragonlabs.com"; function openBrowser(url) { const start = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; exec(`${start} ${url}`, (error) => { if (error) { console.error("Failed to open browser:", error); } }); } function createAuthServer({ onError, onApiKeyReceived }) { return createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; } if (req.method === "POST" && req.url === "/auth") { let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", async () => { try { const { apiKey } = JSON.parse(body); if (!apiKey) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "API key required" })); return; } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: true })); onApiKeyReceived(apiKey); } catch (error) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Failed to process API key" })); onError(error instanceof Error ? error : new Error("Unknown error")); } }); } else { res.writeHead(404); res.end(); } }); } function AuthCommand({ apiKey: providedApiKey }) { const [status, setStatus] = useState("waiting"); const [message, setMessage] = useState(""); const [manualApiKey, setManualApiKey] = useState(""); const [browserOpened, setBrowserOpened] = useState(false); const saveApiKeyMutation = useSaveApiKey(); const handleApiKey = async (apiKey) => { setStatus("authenticating"); try { await saveApiKeyMutation.mutateAsync(apiKey); setStatus("success"); setMessage("API key saved successfully!"); setTimeout(() => { process.exit(0); }, 2e3); } catch (error) { setStatus("error"); setMessage( `Failed to save API key: ${error instanceof Error ? error.message : "Unknown error"}` ); } }; useEffect(() => { if (providedApiKey) { handleApiKey(providedApiKey); return; } if (status !== "waiting") { return; } const server = createAuthServer({ onError: (error) => { }, onApiKeyReceived: handleApiKey }); server.listen(AUTH_PORT, () => { setBrowserOpened(true); openBrowser(`${TERRAGON_WEB_URL}/cli/auth`); }); server.on("error", (error) => { if (error.code === "EADDRINUSE") { } }); return () => { server.close(); }; }, [providedApiKey, status]); return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", paddingY: 1 }, status === "success" ? /* @__PURE__ */ React.createElement(Text, { color: "green" }, "\u2713 ", message) : /* @__PURE__ */ React.createElement(React.Fragment, null, browserOpened && /* @__PURE__ */ React.createElement(Box, { marginBottom: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, null, "A browser window should open for automatic authentication."), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "If auto auth doesn't work, you can manually paste the code below.")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Visit: ", /* @__PURE__ */ React.createElement(Text, { color: "blue" }, TERRAGON_WEB_URL, "/cli/auth"))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, null, "Paste the code from the browser: ")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement( TextInput, { value: manualApiKey, onChange: setManualApiKey, onSubmit: () => { if (manualApiKey.trim()) { handleApiKey(manualApiKey.trim()); } }, placeholder: "ter_..." } )), status === "authenticating" && /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, /* @__PURE__ */ React.createElement(Spinner, { type: "dots" }), " Authenticating...")), status === "error" && /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { color: "red" }, "\u2717 ", message)))); } // src/commands/pull.tsx import React3, { useEffect as useEffect2, useState as useState3 } from "react"; import { Box as Box3, Text as Text3 } from "ink"; import Spinner2 from "ink-spinner"; import { promisify as promisify2 } from "util"; import { exec as execCallback2 } from "child_process"; import { promises as fs2 } from "fs"; import { join as join2 } from "path"; import { homedir as homedir2 } from "os"; // src/components/ThreadSelector.tsx import React2, { useState as useState2 } from "react"; import { Box as Box2, Text as Text2, useInput } from "ink"; import SelectInput from "ink-select-input"; function getTimeAgo(date) { const seconds = Math.floor(((/* @__PURE__ */ new Date()).getTime() - date.getTime()) / 1e3); if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 7) return `${days}d ago`; const weeks = Math.floor(days / 7); if (weeks < 4) return `${weeks}w ago`; const months = Math.floor(days / 30); if (months < 12) return `${months}mo ago`; const years = Math.floor(days / 365); return `${years}y ago`; } function ThreadSelector({ onSelect, currentRepo }) { const [page, setPage] = useState2(0); const { data: threads = [], isLoading, error } = useThreads(currentRepo); const ITEMS_PER_PAGE = 10; const totalPages = Math.ceil(threads.length / ITEMS_PER_PAGE); const startIndex = page * ITEMS_PER_PAGE; const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, threads.length); const visibleThreads = threads.slice(startIndex, endIndex); const items = visibleThreads.map((thread) => { const unreadIndicator = thread.isUnread ? "\u25CF " : " "; const threadName = thread.name || "Untitled"; const date = new Date(thread.updatedAt); const timeAgo = getTimeAgo(date); const label = `${unreadIndicator}${threadName} \u2022 ${timeAgo}`; return { label, value: thread.id }; }); useInput((_input, key) => { if (!isLoading && !error && threads.length > 0) { if (key.leftArrow && page > 0) { setPage(page - 1); } else if (key.rightArrow && page < totalPages - 1) { setPage(page + 1); } } }); if (isLoading) { return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React2.createElement(Text2, null, "Loading tasks...")); } if (error) { return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React2.createElement(Text2, { color: "red" }, error instanceof Error ? error.message : String(error))); } if (threads.length === 0) { return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React2.createElement(Text2, null, currentRepo ? "No tasks found for the current repository." : "No tasks found.")); } return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React2.createElement(Box2, { marginBottom: 1 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true }, "Select a task to pull:")), /* @__PURE__ */ React2.createElement(SelectInput, { items, onSelect: (item) => onSelect(item.value) }), totalPages > 1 && /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "Page ", page + 1, " of ", totalPages, " (\u2190 \u2192 to navigate pages, \u2191 \u2193 to select)"))); } // src/utils/claude.ts import { spawn, execSync } from "child_process"; function launchClaude(sessionId) { try { let claudeCommand = "claude"; try { const parentShell = process.env.SHELL || "/bin/sh"; console.log(`Using parent shell: ${parentShell}`); const shellCommand = `${parentShell} -lic 'which claude 2>/dev/null || type -p claude 2>/dev/null || command -v claude 2>/dev/null'`; const result = execSync(shellCommand, { encoding: "utf8", timeout: 5e3 }).trim(); if (result) { const aliasMatch = result.match(/aliased to (.+)/); if (aliasMatch && aliasMatch[1]) { claudeCommand = aliasMatch[1].trim(); } else { claudeCommand = result; } console.log(`Found Claude CLI at: ${claudeCommand}`); } } catch (error) { console.error( "Could not locate claude via shell, attempting direct execution" ); } const claudeProcess = spawn(claudeCommand, ["--resume", sessionId], { stdio: "inherit", env: process.env, cwd: process.cwd() }); claudeProcess.on("error", (err) => { if (err.message.includes("ENOENT")) { console.error( "Error: 'claude' command not found. Please ensure Claude CLI is installed and in your PATH." ); } else { console.error(`Failed to launch Claude: ${err.message}`); } process.exit(1); }); const signals = ["SIGINT", "SIGTERM", "SIGHUP"]; signals.forEach((signal) => { process.on(signal, () => { claudeProcess.kill(signal); }); }); claudeProcess.on("exit", (code, signal) => { if (signal) { process.kill(process.pid, signal); } else { process.exit(code || 0); } }); } catch (err) { console.error( `Failed to launch Claude: ${err instanceof Error ? err.message : String(err)}` ); process.exit(1); } } // src/hooks/useGitInfo.ts import { useQuery as useQuery2 } from "@tanstack/react-query"; import { promisify } from "util"; import { exec as execCallback } from "child_process"; var exec2 = promisify(execCallback); async function getCurrentGitHubRepo() { try { const { stdout } = await exec2("git config --get remote.origin.url", { encoding: "utf8" }); const remoteUrl = stdout.trim(); const match = remoteUrl.match(/github\.com[:/]([^/]+\/[^.]+)(\.git)?$/); if (match && match[1]) { return match[1]; } return null; } catch (err) { return null; } } async function getCurrentBranch() { try { const { stdout } = await exec2("git branch --show-current", { encoding: "utf8" }); return stdout.trim() || null; } catch (err) { return null; } } function useCurrentGitHubRepo() { return useQuery2({ queryKey: ["git", "repo"], queryFn: getCurrentGitHubRepo, staleTime: Infinity // Git repo doesn't change during session }); } function useCurrentBranch() { return useQuery2({ queryKey: ["git", "branch"], queryFn: getCurrentBranch, staleTime: 5e3 // Refresh every 5 seconds in case branch changes }); } function useGitInfo() { const repoQuery = useCurrentGitHubRepo(); const branchQuery = useCurrentBranch(); return { repo: repoQuery.data, branch: branchQuery.data, isLoading: repoQuery.isLoading || branchQuery.isLoading, error: repoQuery.error || branchQuery.error }; } // src/commands/pull.tsx var exec3 = promisify2(execCallback2); async function findGitRoot() { try { const { stdout } = await exec3("git rev-parse --show-toplevel", { encoding: "utf8" }); const gitRoot = stdout.trim(); return { gitRoot }; } catch (err) { return { error: "Not in a git repository" }; } } async function switchToBranch(branchName) { try { await exec3("git fetch", { encoding: "utf8" }); let branchExists = false; try { await exec3(`git rev-parse --verify ${branchName}`, { encoding: "utf8" }); branchExists = true; } catch { } if (branchExists) { await exec3(`git checkout ${branchName}`, { encoding: "utf8" }); await exec3("git pull", { encoding: "utf8" }); } else { try { await exec3(`git checkout -b ${branchName} origin/${branchName}`, { encoding: "utf8" }); } catch { return { success: false, error: `Branch ${branchName} not found on remote` }; } } return { success: true }; } catch (err) { return { success: false, error: `Failed to pull branch: ${err instanceof Error ? err.message : String(err)}` }; } } async function saveSessionData(sessionId, cwdWithHyphens, jsonl) { const claudeDir = join2(homedir2(), ".claude", "projects", cwdWithHyphens); await fs2.mkdir(claudeDir, { recursive: true }); const jsonlPath = join2(claudeDir, `${sessionId}.jsonl`); const jsonlContent = jsonl.map((item) => JSON.stringify(item)).join("\n"); await fs2.writeFile(jsonlPath, jsonlContent); return jsonlPath; } async function processSessionData(data, setProcessingStatus, onComplete) { try { setProcessingStatus("Finding git repository root..."); const { gitRoot, error: gitError } = await findGitRoot(); if (gitError || !gitRoot) { return { success: false, error: gitError }; } process.chdir(gitRoot); setProcessingStatus(`Changed to git root: ${gitRoot}`); if (data.branchName) { setProcessingStatus( `Pulling latest version of branch: ${data.branchName}` ); const { success, error: branchError } = await switchToBranch( data.branchName ); if (!success) { return { success: false, error: branchError }; } setProcessingStatus( `Successfully switched to branch: ${data.branchName}` ); } const cwd = process.cwd(); const cwdWithHyphens = cwd.replace(/\//g, "-"); setProcessingStatus(`Project directory: ${cwdWithHyphens}`); if (data.jsonl && data.jsonl.length > 0) { const jsonlPath = await saveSessionData( data.sessionId, cwdWithHyphens, data.jsonl ); setProcessingStatus(`Saved session data to: ${jsonlPath}`); } setProcessingStatus(`Session ready: ${data.sessionId}`); setTimeout(() => { onComplete(data.sessionId); }, 1e3); return { success: true, gitRoot, cwdWithHyphens, sessionId: data.sessionId }; } catch (err) { return { success: false, error: `Processing error: ${err instanceof Error ? err.message : String(err)}` }; } } function PullCommand({ threadId, resume }) { const [selectedThreadId, setSelectedThreadId] = useState3( threadId ); const [processingStatus, setProcessingStatus] = useState3(""); const [isProcessing, setIsProcessing] = useState3(false); const [completedSessionId, setCompletedSessionId] = useState3( null ); const repoQuery = useCurrentGitHubRepo(); const currentRepo = repoQuery.data; const { data: sessionData, isLoading, error } = useThreadDetail(selectedThreadId); useEffect2(() => { if (!sessionData) return; const process2 = async () => { setIsProcessing(true); const result = await processSessionData( sessionData, setProcessingStatus, (sessionId) => { setCompletedSessionId(sessionId); setIsProcessing(false); if (resume) { setTimeout(() => { launchClaude(sessionId); }, 100); } } ); if (!result.success) { console.error(result.error || "Unknown error during processing"); setIsProcessing(false); } }; process2(); }, [sessionData, resume]); const handleThreadSelect = (threadId2) => { setSelectedThreadId(threadId2); }; if (!selectedThreadId) { return /* @__PURE__ */ React3.createElement( ThreadSelector, { onSelect: handleThreadSelect, currentRepo: currentRepo || void 0 } ); } if (error) { return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, "Error: ", error instanceof Error ? error.message : String(error))); } return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Box3, null, isLoading ? /* @__PURE__ */ React3.createElement(React3.Fragment, null, /* @__PURE__ */ React3.createElement(Text3, null, /* @__PURE__ */ React3.createElement(Spinner2, { type: "dots" })), /* @__PURE__ */ React3.createElement(Text3, null, " Fetching session for task ", selectedThreadId, "...")) : sessionData ? /* @__PURE__ */ React3.createElement(Text3, { color: "green" }, "\u2713 Session fetched successfully") : /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, "Error: No session data")), sessionData && !isLoading && /* @__PURE__ */ React3.createElement(Box3, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Box3, { width: 15 }, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "Name")), /* @__PURE__ */ React3.createElement(Text3, null, sessionData.name)), /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Box3, { width: 15 }, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "Branch")), /* @__PURE__ */ React3.createElement(Text3, null, sessionData.branchName || "N/A")), /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Box3, { width: 15 }, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "Repository")), /* @__PURE__ */ React3.createElement(Text3, null, sessionData.githubRepoFullName || "N/A")), /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Box3, { width: 15 }, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "PR Number")), /* @__PURE__ */ React3.createElement(Text3, null, sessionData.githubPRNumber ? `#${sessionData.githubPRNumber}` : "N/A"))), completedSessionId ? /* @__PURE__ */ React3.createElement(Box3, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React3.createElement(Text3, null, " "), resume ? /* @__PURE__ */ React3.createElement(Text3, { color: "green" }, "Launching Claude...") : sessionData?.agent === "claudeCode" ? /* @__PURE__ */ React3.createElement(React3.Fragment, null, /* @__PURE__ */ React3.createElement(Text3, { color: "yellow" }, "To continue this session, run:"), /* @__PURE__ */ React3.createElement( Box3, { marginLeft: 2, borderStyle: "round", borderColor: "cyan", paddingX: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, "claude --resume ", completedSessionId) )) : /* @__PURE__ */ React3.createElement(Text3, { color: "green" }, "Session ready")) : isProcessing && processingStatus && /* @__PURE__ */ React3.createElement(Box3, { marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text3, null, /* @__PURE__ */ React3.createElement(Spinner2, { type: "dots" })), /* @__PURE__ */ React3.createElement(Text3, null, " ", processingStatus))); } // src/commands/create.tsx import React4, { useState as useState4, useEffect as useEffect3 } from "react"; import { Box as Box4, Text as Text4 } from "ink"; import Spinner3 from "ink-spinner"; import { useMutation as useMutation2 } from "@tanstack/react-query"; function CreateCommand({ message, repo, branch, createNewBranch = true, mode = "execute", model }) { const [error, setError] = useState4(null); const gitInfo = useGitInfo(); const createMutation = useMutation2({ mutationFn: async () => { const finalRepo = repo || gitInfo.repo; if (!finalRepo) { throw new Error( "No repository specified and could not detect from current directory" ); } const finalBranch = branch || gitInfo.branch || "main"; const normalizedMode = mode === "plan" ? "plan" : "execute"; const result = await apiClient.threads.create({ message, githubRepoFullName: finalRepo, repoBaseBranchName: finalBranch, createNewBranch, mode: normalizedMode, model }); return result; }, onError: (error2) => { console.error("Error creating thread:", error2); setError(error2.message || "Failed to create thread"); } }); useEffect3(() => { if (!gitInfo.isLoading) { createMutation.mutate(); } }, [gitInfo.isLoading]); if (gitInfo.isLoading || createMutation.isPending) { return /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Text4, null, /* @__PURE__ */ React4.createElement(Spinner3, { type: "dots" }), " ", gitInfo.isLoading ? "Detecting repository..." : "Creating new task...")); } if (createMutation.isError || error) { return /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Text4, { color: "red" }, "\u274C Error: ", error || "Failed to create thread")); } if (createMutation.isSuccess && createMutation.data) { const finalRepo = repo || gitInfo.repo; return /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Text4, { color: "green" }, "\u2713 Task created successfully!"), /* @__PURE__ */ React4.createElement(Text4, null, "Repository: ", finalRepo), /* @__PURE__ */ React4.createElement(Text4, null, "Thread ID: ", createMutation.data.threadId), createMutation.data.branchName && /* @__PURE__ */ React4.createElement(Text4, null, "Branch: ", createMutation.data.branchName), /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, "Visit https://www.terragonlabs.com/task/", createMutation.data.threadId, " ", "to view your task")); } return null; } // src/commands/list.tsx import React5, { useEffect as useEffect4, useState as useState5 } from "react"; import { Box as Box5, Text as Text5, useApp } from "ink"; function ListCommand() { const { exit } = useApp(); const repoQuery = useCurrentGitHubRepo(); const currentRepo = repoQuery.data; const { data: threads = [], isLoading, error } = useThreads(currentRepo || void 0); const [authError, setAuthError] = useState5(null); useEffect4(() => { const checkAuth = async () => { const apiKey = await getApiKey(); if (!apiKey) { setAuthError("Not authenticated. Run 'terry auth' first."); } }; checkAuth(); }, []); useEffect4(() => { if (authError) { exit(); } }, [authError, exit]); useEffect4(() => { if (error) { exit(); } }, [error, exit]); useEffect4(() => { if (!isLoading && threads) { exit(); } }, [threads, isLoading, exit]); if (authError) { return /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, "Error: ", authError)); } if (error) { return /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { color: "red" }, "Error: ", error instanceof Error ? error.message : String(error))); } if (isLoading) { return /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, null, "Loading tasks...")); } return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column" }, threads.map((thread, index) => /* @__PURE__ */ React5.createElement(Box5, { key: thread.id, flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Task ID "), /* @__PURE__ */ React5.createElement(Text5, null, thread.id)), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Name "), /* @__PURE__ */ React5.createElement(Text5, null, thread.name || "Untitled")), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Branch "), /* @__PURE__ */ React5.createElement(Text5, null, thread.branchName || "N/A")), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "Repository "), /* @__PURE__ */ React5.createElement(Text5, null, thread.githubRepoFullName || "N/A")), /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "PR Number "), /* @__PURE__ */ React5.createElement(Text5, null, thread.githubPRNumber ? `#${thread.githubPRNumber}` : "N/A")))), /* @__PURE__ */ React5.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React5.createElement(Text5, null, "Total: ", threads.length, " task", threads.length !== 1 ? "s" : ""))); } // src/providers/QueryProvider.tsx import React6 from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; var queryClient = new QueryClient({ defaultOptions: { queries: { retry: 2, staleTime: 5 * 60 * 1e3, // 5 minutes refetchOnMount: false, refetchOnWindowFocus: false } } }); function QueryProvider({ children }) { return /* @__PURE__ */ React6.createElement(QueryClientProvider, { client: queryClient }, children); } // src/components/RootLayout.tsx import React8 from "react"; import { Box as Box7 } from "ink"; // src/components/UpdateNotifier.tsx import React7 from "react"; import { Box as Box6, Text as Text6 } from "ink"; import { useQuery as useQuery3 } from "@tanstack/react-query"; import updateNotifier from "update-notifier"; // package.json var package_default = { name: "@terragon-labs/cli", version: "0.1.17", type: "module", bin: { terry: "./dist/index.js" }, scripts: { dev: "tsup --watch", build: "tsup", start: "node dist/index.js", "tsc-watch": "tsc --watch --noEmit --preserveWatchOutput", "tsc-check": "tsc --noEmit", format: "prettier --write .", "format-check": "prettier --check .", lint: "tsc --noEmit", "install:dev": "./scripts/install-cli-dev.sh", "uninstall:dev": "./scripts/uninstall-cli.sh", release: "./scripts/release.sh" }, dependencies: { "@modelcontextprotocol/sdk": "^1.0.6", "@orpc/client": "^1.6.0", "@orpc/contract": "1.6.0", "@tanstack/react-query": "^5.76.1", commander: "^12.1.0", ink: "^6.0.0", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "node-fetch": "^3.3.2", react: "^19.1.0", "update-notifier": "^7.3.1" }, devDependencies: { "@terragon/cli-api-contract": "workspace:*", "@types/node": "^22.6.1", "@types/react": "^19.1.8", "@types/update-notifier": "^6.0.8", dotenv: "^16.5.0", tsup: "^8.3.5", tsx: "^4.19.1", typescript: "^5.7.2" }, publishConfig: { access: "public", registry: "https://registry.npmjs.org/" }, files: [ "dist", "package.json", "README.md" ], keywords: [ "terragon", "cli", "terry", "ai", "coding-assistant" ], author: "Terragon Labs", license: "MIT", repository: { type: "git", url: "https://github.com/terragon-labs/terragon.git", directory: "apps/cli" }, homepage: "https://www.terragonlabs.com", bugs: { url: "https://github.com/terragon-labs/terragon/issues" } }; // src/components/UpdateNotifier.tsx async function checkForUpdate() { if (process.env.TERRY_NO_UPDATE_CHECK === "1") { return null; } const notifier = updateNotifier({ pkg: package_default, updateCheckInterval: 1e3 * 60 * 60 // 1 hour }); const info = await notifier.fetchInfo(); if (info.latest !== info.current) { return { current: info.current, latest: info.latest }; } return null; } function UpdateNotifier() { const { data: updateInfo } = useQuery3({ queryKey: ["update-check"], queryFn: checkForUpdate, staleTime: 1e3 * 60 * 60, // 1 hour retry: false // Don't retry update checks }); if (!updateInfo) { return null; } return /* @__PURE__ */ React7.createElement(Box6, { marginBottom: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: "yellow" }, "Update available: ", updateInfo.current, " \u2192 ", updateInfo.latest), /* @__PURE__ */ React7.createElement(Text6, { color: "gray" }, " Run "), /* @__PURE__ */ React7.createElement(Text6, { color: "cyan" }, "npm install -g @terragon-labs/cli"), /* @__PURE__ */ React7.createElement(Text6, { color: "gray" }, " to update")); } // src/components/RootLayout.tsx function RootLayout({ children }) { return /* @__PURE__ */ React8.createElement(Box7, { flexDirection: "column" }, /* @__PURE__ */ React8.createElement(UpdateNotifier, null), children); } // src/mcp-server/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { spawnSync } from "child_process"; import { existsSync } from "fs"; import { join as join3, dirname } from "path"; import { fileURLToPath } from "url"; function getTerryPath() { try { const result = spawnSync("which", ["terry"], { encoding: "utf-8", stdio: "pipe" }); if (result.status === 0) { return "terry"; } } catch { } const __filename3 = fileURLToPath(import.meta.url); const __dirname3 = dirname(__filename3); const cliPath = join3(__dirname3, "../index.js"); if (existsSync(cliPath)) { return cliPath; } throw new Error("Terry CLI not found"); } async function executeTerryCommand(command, args = []) { const terryPath = getTerryPath(); const commandArgs = [command, ...args]; const spawnOptions = { encoding: "utf-8", env: { ...process.env, // Ensure the CLI runs in non-interactive mode CI: "true" } }; let executable; let execArgs; if (terryPath === "terry") { executable = "terry"; execArgs = commandArgs; } else { executable = "node"; execArgs = [terryPath, ...commandArgs]; } const result = spawnSync(executable, execArgs, spawnOptions); if (result.error) { throw new Error( `Failed to execute terry ${command}: ${result.error.message}` ); } if (result.status !== 0) { const stderr = result.stderr?.toString().trim() || ""; const stdout = result.stdout?.toString().trim() || ""; const errorMessage = stderr || stdout || `Command failed with exit code ${result.status}`; throw new Error(`terry ${command} failed: ${errorMessage}`); } return result.stdout?.toString().trim() || ""; } async function startMCPServer() { const server = new Server( { name: "terry-mcp-server", version: "0.1.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "terry_list", description: "List all tasks in Terragon (calls 'terry list')", inputSchema: { type: "object", properties: {} } }, { name: "terry_create", description: "Create a new task in Terragon (calls 'terry create')", inputSchema: { type: "object", properties: { message: { type: "string", description: "The task message/description" }, repo: { type: "string", description: "GitHub repository (optional, uses current repo if not specified)" }, branch: { type: "string", description: "Base branch name (optional, uses current branch if not specified)" }, createNewBranch: { type: "boolean", description: "Whether to create a new branch (default: true)", default: true } }, required: ["message"] } }, { name: "terry_pull", description: "Pull/fetch session data for a task (calls 'terry pull')", inputSchema: { type: "object", properties: { threadId: { type: "string", description: "The thread/task ID to pull (optional, shows interactive selection if not provided)" } } } } ] }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case "terry_list": const listOutput = await executeTerryCommand("list"); return { content: [ { type: "text", text: listOutput || "No tasks found" } ] }; case "terry_create": const createParams = args; if (!createParams.message) { throw new Error("Message is required for creating a task"); } const createArgs = [createParams.message]; if (createParams.repo) { createArgs.push("-r", createParams.repo); } if (createParams.branch) { createArgs.push("-b", createParams.branch); } if (createParams.createNewBranch === false) { createArgs.push("--no-new-branch"); } const createOutput = await executeTerryCommand("create", createArgs); return { content: [ { type: "text", text: createOutput || "Task created successfully" } ] }; case "terry_pull": const pullParams = args; const pullArgs = []; if (pullParams.threadId) { pullArgs.push(pullParams.threadId); } const pullOutput = await executeTerryCommand("pull", pullArgs); return { content: [ { type: "text", text: pullOutput || "Pull completed successfully" } ] }; default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}` } ] }; } }); const transport = new StdioServerTransport(); await server.connect(transport); } // src/index.tsx process.on("uncaughtException", (error) => { console.error("Uncaught Exception:", error); console.error("Stack trace:", error.stack); process.exit(1); }); process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled Rejection at:", promise); console.error("Reason:", reason); process.exit(1); }); var __filename2 = fileURLToPath2(import.meta.url); var __dirname2 = dirname2(__filename2); var packageJson = JSON.parse( await readFile(join4(__dirname2, "../package.json"), "utf-8") ); program.name("terry").description("Terry CLI - Terragon Labs coding assistant").version(packageJson.version); program.command("auth [apiKey]").description("Authenticate with your Terragon API key").action((apiKey) => { render( /* @__PURE__ */ React9.createElement(QueryProvider, null, /* @__PURE__ */ React9.createElement(RootLayout, null, /* @__PURE__ */ React9.createElement(AuthCommand, { apiKey }))) ); }); program.command("pull [threadId]").description("Fetch session data for a task").option("-r, --resume", "Automatically launch Claude after pulling").action((threadId, options) => { render( /* @__PURE__ */ React9.createElement(QueryProvider, null, /* @__PURE__ */ React9.createElement(RootLayout, null, /* @__PURE__ */ React9.createElement(PullCommand, { threadId, resume: options.resume }))) ); }); program.command("create <message>").description("Create a new task with the given message").option( "-r, --repo <repo>", "GitHub repository (default: current repository)" ).option("-b, --branch <branch>", "Base branch name (default: current branch)").option("--no-new-branch", "Don't create a new branch").option( "-m, --model <model>", "AI model: opus | sonnet | gpt-5 | gpt-5-low | gpt-5-medium | gpt-5-high" ).option("-M, --mode <mode>", "Task mode: plan or execute (default: execute)").action( (message, options) => { const mode = options.mode === "plan" ? "plan" : "execute"; const allowedModels = [ "opus", "sonnet", "gpt-5", "gpt-5-low", "gpt-5-medium", "gpt-5-high" ]; let model; if (options.model) { if (allowedModels.includes(options.model)) { model = options.model; } else { console.warn( `Warning: Model '${options.model}' is not recognized. Valid models are: ${allowedModels.join(", ")}. Using default model.` ); model = void 0; } } render( /* @__PURE__ */ React9.createElement(QueryProvider, null, /* @__PURE__ */ React9.createElement(RootLayout, null, /* @__PURE__ */ React9.createElement( CreateCommand, { message, repo: options.repo, branch: options.branch, createNewBranch: options.newBranch, mode, model } ))) ); } ); program.command("list").description("List all tasks in a non-interactive format").action(() => { render( /* @__PURE__ */ React9.createElement(QueryProvider, null, /* @__PURE__ */ React9.createElement(RootLayout, null, /* @__PURE__ */ React9.createElement(ListCommand, null))) ); }); program.command("mcp").description("Run an MCP server for the git repository").action(async () => { try { await startMCPServer(); } catch (error) { console.error("Failed to start MCP server:", error); process.exit(1); } }); program.parse();