@terragon-labs/cli
Version:
 [![npm]](https://www.npmjs.com/package/@terragon-labs/cli)
1,141 lines (1,117 loc) • 42.4 kB
JavaScript
// 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();