#!/usr/bin/env node
import React, { useState, useEffect, useCallback } from "react";
import { render, Box, Text, useInput } from "ink";
import { Command } from "commander";
import {
startAuthServer,
getAccessToken,
getInstallationId,
} from "./auth-server.js";
import open from "open";
import { v4 as uuidv4 } from "uuid";
import {
MANAGER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import { Client, StreamMode } from "@langchain/langgraph-sdk";
import { submitFeedback } from "./utils.js";
import { StreamingService } from "./streaming.js";
import { OPEN_SWE_CLI_VERSION } from "./constants.js";
const GITHUB_LOGIN_URL =
process.env.GITHUB_LOGIN_URL || "http://localhost:3000/api/auth/github/login";
// Set up Commander.js
const program = new Command();
program
.name("open-swe")
.description("Open SWE CLI")
.version(OPEN_SWE_CLI_VERSION)
.option(
"--local",
"Work directly on local codebase without GitHub authentication",
)
.helpOption("-h, --help", "Display help for command")
.parse();
const options = program.opts();
const isLocalMode = options.local;
startAuthServer();
const LoadingSpinner: React.FC<{ text: string }> = ({ text }) => {
const [dots, setDots] = useState("");
useEffect(() => {
const interval = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? "" : prev + "."));
}, 500);
return () => clearInterval(interval);
}, []);
return (
{text}
{dots}
);
};
// eslint-disable-next-line no-unused-vars
const CustomInput: React.FC<{ onSubmit: (value: string) => void }> = ({
onSubmit,
}) => {
const [input, setInput] = useState("");
const [isSubmitted, setIsSubmitted] = useState(false);
useInput((inputChar: string, key: { [key: string]: any }) => {
if (isSubmitted) return;
if (key.return) {
if (input.trim()) {
// Only submit if there's actual content
setIsSubmitted(true);
onSubmit(input);
// Reset for next input
setTimeout(() => {
setInput("");
setIsSubmitted(false);
}, 100);
}
} else if (key.backspace || key.delete) {
setInput((prev) => prev.slice(0, -1));
} else if (inputChar) {
setInput((prev) => prev + inputChar);
}
});
return (
> {input}
);
};
async function fetchUserRepos(token: string) {
const allRepos = [];
let page = 1;
const perPage = 100;
while (true) {
const res = await fetch(
`https://api.github.com/user/repos?per_page=${perPage}&page=${page}`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "open-swe-cli",
},
},
);
if (!res.ok) throw new Error("Failed to fetch repos");
const repos = await res.json();
allRepos.push(...repos);
if (repos.length < perPage) break;
page++;
}
return allRepos;
}
const RepoSearchSelect: React.FC<{
repos: any[];
// eslint-disable-next-line no-unused-vars
onSelect: (repo: any) => void;
}> = ({ repos, onSelect }) => {
const [search, setSearch] = useState("");
const [highlighted, setHighlighted] = useState(0);
const [isMessage, setIsMessage] = useState(false);
const filtered = repos.filter((repo) =>
repo.full_name.toLowerCase().includes(search.toLowerCase()),
);
const shown = filtered.slice(0, 10);
useInput((input: string, key: { [key: string]: any }) => {
if (isMessage) return;
if (key.return) {
if (shown.length > 0) {
setIsMessage(true);
onSelect(shown[highlighted]);
}
} else if (key.upArrow) {
setHighlighted((h) => (h - 1 + shown.length) % shown.length);
} else if (key.downArrow) {
setHighlighted((h) => (h + 1) % shown.length);
} else if (key.backspace || key.delete) {
setSearch((prev) => prev.slice(0, -1));
setHighlighted(0);
} else if (input && !key.ctrl && !key.meta) {
setSearch((prev) => prev + input);
setHighlighted(0);
}
});
if (isMessage) return null;
return (
Search repositories: {search}
{shown.length === 0 ? (
No matches found.
) : (
{shown.map((_, idx) => (
{idx === highlighted ? "> " : " "}
{shown[idx].full_name}
))}
)}
Use ↑/↓ to navigate, Enter to select
);
};
const App: React.FC = () => {
const [authPrompt, setAuthPrompt] = useState(null);
const [authInput, setAuthInput] = useState("");
const [exit, setExit] = useState(false);
const [authStarted, setAuthStarted] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(isLocalMode);
const [repos, setRepos] = useState([]);
const [selectedRepo, setSelectedRepo] = useState(null);
const [selectingRepo, setSelectingRepo] = useState(false);
const [waitingForInstall, setWaitingForInstall] = useState(false);
const [installChecked, setInstallChecked] = useState(false);
const [installError, setInstallError] = useState(null);
const [appSlug, setAppSlug] = useState(process.env.GITHUB_APP_NAME || "");
const INSTALLATION_CALLBACK_URL = process.env.GITHUB_CALLBACK_URL || "";
const [pollingForToken, setPollingForToken] = useState(false);
const [logs, setLogs] = useState([]);
const [plannerFeedback, setPlannerFeedback] = useState(null);
const [streamingPhase, setStreamingPhase] = useState<
"streaming" | "awaitingFeedback" | "done"
>("streaming");
const [threadId, setThreadId] = useState(null);
const [plannerThreadId, setPlannerThreadId] = useState(null);
const [hasStartedChat, setHasStartedChat] = useState(false);
const [client, setClient] = useState(null);
const [loadingRepos, setLoadingRepos] = useState(false);
const [loadingLogs, setLoadingLogs] = useState(false);
const sendInterruptMessage = useCallback(
async (message: string) => {
if (!client || !threadId || !selectedRepo) {
return;
}
setLogs((prev) => [...prev, `📤 Interrupt Response: "${message}"`]);
try {
const [owner, repoName] = selectedRepo.full_name.split("/");
const interruptInput = {
messages: [
{
id: uuidv4(),
type: "human",
content: [{ type: "text", text: message }],
},
],
targetRepository: {
owner,
repo: repoName,
branch: selectedRepo.default_branch || "main",
},
};
await client.runs.create(threadId, MANAGER_GRAPH_ID, {
input: interruptInput,
config: {
recursion_limit: 400,
},
ifNotExists: "create",
streamResumable: true,
multitaskStrategy: "enqueue",
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
});
// Just submit the interrupt - existing planner session will pick it up automatically
setLogs((prev) => [...prev, `✅ Interrupt sent to existing session`]);
} catch (err: any) {
setLogs((prev) => [...prev, `Error sending interrupt: ${err.message}`]);
}
},
[client, threadId, selectedRepo, setLogs],
);
// On mount, check for existing token
useEffect(() => {
if (isLocalMode) {
setIsLoggedIn(true);
setLoadingRepos(false); // Ensure no loading state in local mode
// Set up local mode defaults
setSelectedRepo({
full_name: process.env.OPEN_SWE_LOCAL_PROJECT_PATH || "",
clone_url: process.env.OPEN_SWE_LOCAL_PROJECT_PATH || "",
default_branch: "main",
});
setInstallChecked(true);
return;
}
const token = getAccessToken();
if (token) {
setIsLoggedIn(true);
}
}, []);
// After login, fetch and store user repos
useEffect(() => {
if (isLocalMode) return;
if (isLoggedIn && repos.length === 0 && !loadingRepos) {
const token = getAccessToken();
if (token) {
setLoadingRepos(true);
fetchUserRepos(token)
.then((repos) => {
setRepos(repos);
setSelectingRepo(true);
setLoadingRepos(false);
})
.catch((err) => {
console.error("Failed to fetch repos:", err);
setLoadingRepos(false);
});
}
}
}, [isLoggedIn, repos.length, loadingRepos]);
// Poll for installation_id after opening install page
useEffect(() => {
if (isLocalMode) return;
let interval: ReturnType;
if (waitingForInstall) {
interval = setInterval(() => {
// Check if installation_id is present in config file
const installationId = getInstallationId();
if (installationId) {
setInstallChecked(true);
setWaitingForInstall(false);
}
}, 1500);
}
return () => clearInterval(interval);
}, [waitingForInstall]);
// Listen for Cmd+C/Ctrl+C to re-select repo
useInput((input: string, key: { [key: string]: any }) => {
if (installChecked && !waitingForInstall && key.return) {
setInstallChecked(false);
setSelectingRepo(false);
}
if (selectedRepo && (key.ctrl || key.meta) && input.toLowerCase() === "c") {
setSelectingRepo(true);
setSelectedRepo(null);
}
});
// Handle yes/no input for auth prompt
useInput((input: string, key: { [key: string]: any }) => {
if (authPrompt === null && !isLoggedIn) {
if (key.return) {
if (authInput.toLowerCase() === "y") {
setAuthPrompt(true);
} else if (authInput.toLowerCase() === "n") {
setAuthPrompt(false);
setExit(true);
}
} else if (key.backspace || key.delete) {
setAuthInput((prev) => prev.slice(0, -1));
} else if (input && authInput.length < 1) {
setAuthInput(input);
}
}
});
// Exit the process safely after render
useEffect(() => {
if (exit) {
process.exit(0);
}
}, [exit]);
// Start auth server and open browser when user says yes
useEffect(() => {
if (authPrompt === true && !authStarted) {
setAuthStarted(true);
startAuthServer();
open(GITHUB_LOGIN_URL);
setPollingForToken(true);
}
}, [authPrompt, authStarted]);
// Poll for token after auth flow starts
useEffect(() => {
if (isLocalMode || !pollingForToken || isLoggedIn) return;
const interval = setInterval(() => {
const token = getAccessToken();
if (token) {
setIsLoggedIn(true);
setPollingForToken(false);
}
}, 1000);
return () => clearInterval(interval);
}, [pollingForToken, isLoggedIn]);
// Poll for token after auth flow starts
useEffect(() => {
if (pollingForToken && !isLoggedIn) {
const interval = setInterval(() => {
const token = getAccessToken();
if (token) {
setIsLoggedIn(true);
setPollingForToken(false);
}
}, 1000);
return () => clearInterval(interval);
}
}, [pollingForToken, isLoggedIn]);
const PlannerFeedbackInput: React.FC = () => {
const [selectedOption, setSelectedOption] = useState<
"approve" | "deny" | null
>(null);
useInput((inputChar: string, key: { [key: string]: any }) => {
if (streamingPhase !== "awaitingFeedback") return;
if (key.return && selectedOption) {
setPlannerFeedback(selectedOption);
setSelectedOption(null);
} else if (key.leftArrow) {
setSelectedOption("approve");
} else if (key.rightArrow) {
setSelectedOption("deny");
}
});
if (streamingPhase !== "awaitingFeedback") {
return null;
}
return (
Plan feedback:
{selectedOption === "approve" ? "▶ " : " "}Approve
{selectedOption === "deny" ? "▶ " : " "}Deny
(Use ←/→ to select, Enter to confirm)
);
};
// Add this where we handle planner feedback
useEffect(() => {
if (
streamingPhase === "awaitingFeedback" &&
plannerFeedback &&
plannerThreadId
) {
(async () => {
await submitFeedback({
plannerFeedback,
plannerThreadId,
selectedRepo,
setLogs,
setPlannerFeedback: () => setPlannerFeedback(null),
setStreamingPhase,
});
})();
}
}, [streamingPhase, plannerFeedback, selectedRepo, plannerThreadId]);
// Loading repos after login
if (isLoggedIn && loadingRepos) {
return (
);
}
// Repo selection UI
if (
isLoggedIn &&
repos.length > 0 &&
(selectingRepo || !selectedRepo) &&
!isLocalMode
) {
return (
LangChain Open SWE CLI
Select a repository to work with (type to search):
{
let slug = appSlug;
const installationId = getInstallationId();
setSelectedRepo(repo);
setSelectingRepo(false);
if (installationId) {
setInstallChecked(true);
setWaitingForInstall(false);
setInstallError(null);
return;
}
if (!slug) {
console.log(
"Please enter your GitHub App slug (as in https://github.com/apps/):",
);
process.stdin.resume();
process.stdin.setEncoding("utf8");
slug = await new Promise((resolve) => {
process.stdin.once("data", (data) =>
resolve(String(data).trim()),
);
});
setAppSlug(slug);
}
const installUrl = `https://github.com/apps/${slug}/installations/new?redirect_uri=${encodeURIComponent(INSTALLATION_CALLBACK_URL)}`;
console.log(
"Opening GitHub App installation page in your browser...",
);
await open(installUrl);
setWaitingForInstall(true);
setInstallChecked(false);
setInstallError(null);
}}
/>
{waitingForInstall && (
Waiting for GitHub App installation to complete...
After installing the app, return here to continue.
)}
{installChecked && !waitingForInstall && (
GitHub App installation detected! You can now proceed.
Press Enter to continue.
)}
{installError && (
{installError}
)}
);
}
// Main UI: logs area + input prompt
if (isLoggedIn && selectedRepo) {
const modeIndicator = isLocalMode ? (
🏠 Local Mode - Working on {process.env.OPEN_SWE_LOCAL_PROJECT_PATH}
) : null;
// Calculate available space for logs based on whether welcome message is shown
const headerHeight = 0; // Welcome message is now above input bar, not at top
const inputHeight = 4; // Fixed input area height (increased due to padding)
const welcomeHeight = hasStartedChat ? 0 : 8; // Welcome message height when shown
const paddingHeight = 3; // Extra padding to prevent overlap
const availableLogHeight = Math.max(
5,
process.stdout.rows -
headerHeight -
inputHeight -
welcomeHeight -
paddingHeight,
);
// Always show the most recent logs (auto-scroll to bottom)
const visibleLogs =
logs.length > availableLogHeight ? logs.slice(-availableLogHeight) : logs;
return (
{modeIndicator}
{/* Auto-scrolling logs area - strict boundary container */}
{loadingLogs && logs.length === 0 ? (
) : (
visibleLogs.map((log, index) => (
{log}
))
)}
{/* Welcome message right above input bar */}
{!hasStartedChat ? (
{`
## ### ## ## ###### ###### ## ## ### #### ## ##
## ## ## ### ## ## ## ## ## ## ## ## ## ## ### ##
## ## ## #### ## ## ## ## ## ## ## ## #### ##
## ## ## ## ## ## ## #### ## ######### ## ## ## ## ## ##
## ######### ## #### ## ## ## ## ## ######### ## ## ####
## ## ## ## ### ## ## ## ## ## ## ## ## ## ## ###
######## ## ## ## ## ###### ###### ## ## ## ## #### ## ##
`}
Describe your coding problem. It'll run in the sandbox and a PR
will be created.
) : (
)}
{/* Fixed input area at bottom */}
{streamingPhase === "awaitingFeedback" ? (
) : !hasStartedChat ? (
{
setHasStartedChat(true);
setPlannerFeedback(null);
const streamingService = new StreamingService({
setLogs,
setPlannerThreadId,
setStreamingPhase,
setLoadingLogs,
setClient,
setThreadId,
});
streamingService.startNewSession(value, selectedRepo);
}}
/>
) : (
{
sendInterruptMessage(value);
}}
/>
)}
);
}
// Auth prompt UI
if (!isLoggedIn && authPrompt === null && !isLocalMode) {
return (
Do you want to start the GitHub authentication flow? (y/n){" "}
{authInput}
);
}
// Fallback
return ;
};
render();