feat(cli): add /install-github-app setup wizard (#1097)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Sarah Wooders
2026-02-23 10:38:21 -08:00
committed by GitHub
parent e93aa7b494
commit 6813167a2a
5 changed files with 1639 additions and 0 deletions

View File

@@ -143,6 +143,7 @@ import { FeedbackDialog } from "./components/FeedbackDialog";
import { HelpDialog } from "./components/HelpDialog";
import { HooksManager } from "./components/HooksManager";
import { Input } from "./components/InputRich";
import { InstallGithubAppFlow } from "./components/InstallGithubAppFlow";
import { McpConnectFlow } from "./components/McpConnectFlow";
import { McpSelector } from "./components/McpSelector";
import { MemfsTreeViewer } from "./components/MemfsTreeViewer";
@@ -1278,6 +1279,7 @@ export default function App({
| "new"
| "mcp"
| "mcp-connect"
| "install-github-app"
| "help"
| "hooks"
| "connect"
@@ -6155,6 +6157,18 @@ export default function App({
return { submitted: true };
}
// Special handling for /install-github-app command - interactive setup wizard
if (trimmed === "/install-github-app") {
startOverlayCommand(
"install-github-app",
"/install-github-app",
"Opening GitHub App installer...",
"GitHub App installer dismissed",
);
setActiveOverlay("install-github-app");
return { submitted: true };
}
// Special handling for /sleeptime command - opens reflection settings
if (trimmed === "/sleeptime") {
startOverlayCommand(
@@ -12056,6 +12070,84 @@ Plan file path: ${planFilePath}`;
/>
)}
{/* GitHub App Installer - setup Letta Code GitHub Action */}
{activeOverlay === "install-github-app" && (
<InstallGithubAppFlow
onComplete={(result) => {
const overlayCommand =
consumeOverlayCommand("install-github-app");
closeOverlay();
const cmd =
overlayCommand ??
commandRunner.start(
"/install-github-app",
"Setting up Letta Code GitHub Action...",
);
if (!result.committed) {
cmd.finish(
[
`Workflow already up to date for ${result.repo}.`,
result.secretAction === "reused"
? "Using existing LETTA_API_KEY secret."
: "Updated LETTA_API_KEY secret.",
"No pull request needed.",
].join("\n"),
true,
);
return;
}
const lines: string[] = ["Install GitHub App", "Success", ""];
lines.push("✓ GitHub Actions workflow created!");
lines.push("");
lines.push(
result.secretAction === "reused"
? "✓ Using existing LETTA_API_KEY secret"
: "✓ API key saved as LETTA_API_KEY secret",
);
if (result.agentId) {
lines.push("");
lines.push(`✓ Agent configured: ${result.agentId}`);
}
lines.push("");
lines.push("Next steps:");
if (result.pullRequestUrl) {
lines.push(
result.pullRequestCreateMode === "page-opened"
? "1. A pre-filled PR page has been created"
: "1. A pull request has been created",
);
lines.push("2. Merge the PR to enable Letta PR assistance");
lines.push(
"3. Mention @letta-code in an issue or PR to test",
);
lines.push("");
lines.push(`PR: ${result.pullRequestUrl}`);
if (result.agentUrl) {
lines.push(`Agent: ${result.agentUrl}`);
}
} else {
lines.push(
"1. Open a PR for the branch created by the installer",
);
lines.push("2. Merge the PR to enable Letta PR assistance");
lines.push(
"3. Mention @letta-code in an issue or PR to test",
);
lines.push("");
lines.push(
"Branch pushed but PR was not opened automatically. Run: gh pr create",
);
}
cmd.finish(lines.join("\n"), true);
}}
onCancel={closeOverlay}
/>
)}
{/* Provider Selector - for connecting BYOK providers */}
{activeOverlay === "connect" && (
<ProviderSelector

View File

@@ -0,0 +1,561 @@
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
const DEFAULT_WORKFLOW_PATH = ".github/workflows/letta.yml";
const ALTERNATE_WORKFLOW_PATH = ".github/workflows/letta-code.yml";
function runCommand(
command: string,
args: string[],
cwd?: string,
input?: string,
): string {
try {
return execFileSync(command, args, {
cwd,
encoding: "utf8",
stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
...(input ? { input } : {}),
}).trim();
} catch (error) {
const err = error as { stderr?: string; message?: string };
const stderr = typeof err.stderr === "string" ? err.stderr.trim() : "";
const message = stderr || err.message || `Failed to run ${command}`;
throw new Error(message);
}
}
function ensureRepoAccess(repo: string): void {
runCommand("gh", ["repo", "view", repo, "--json", "nameWithOwner"]);
}
export interface GhPreflightResult {
ok: boolean;
currentRepo: string | null;
scopes: string[];
hasRepoScope: boolean;
hasWorkflowScope: boolean;
remediation?: string;
details: string;
}
export interface RepoSetupState {
workflowExists: boolean;
secretExists: boolean;
}
export interface InstallGithubAppOptions {
repo: string;
workflowPath: string;
reuseExistingSecret: boolean;
apiKey: string | null;
agentMode: "current" | "existing" | "create";
agentId: string | null;
agentName: string | null;
onProgress?: (status: string) => void;
}
export interface InstallGithubAppResult {
repo: string;
workflowPath: string;
branchName: string | null;
pullRequestUrl: string | null;
pullRequestCreateMode: "created" | "page-opened";
committed: boolean;
secretAction: "reused" | "set";
agentId: string | null;
agentUrl: string | null;
}
function progress(fn: InstallGithubAppOptions["onProgress"], status: string) {
if (fn) {
fn(status);
}
}
export function validateRepoSlug(repo: string): boolean {
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo.trim());
}
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim();
const httpsMatch = trimmed.match(
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i,
);
if (httpsMatch?.[1] && httpsMatch[2]) {
return `${httpsMatch[1]}/${httpsMatch[2]}`;
}
const sshMatch = trimmed.match(
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i,
);
if (sshMatch?.[1] && sshMatch[2]) {
return `${sshMatch[1]}/${sshMatch[2]}`;
}
const sshUrlMatch = trimmed.match(
/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i,
);
if (sshUrlMatch?.[1] && sshUrlMatch[2]) {
return `${sshUrlMatch[1]}/${sshUrlMatch[2]}`;
}
return null;
}
export function parseScopesFromGhAuthStatus(rawStatus: string): string[] {
const lines = rawStatus.split(/\r?\n/);
const tokenScopeLine = lines.find((line) =>
line.toLowerCase().includes("token scopes:"),
);
if (!tokenScopeLine) {
return [];
}
const [, scopesRaw = ""] = tokenScopeLine.split(/token scopes:/i);
return scopesRaw
.split(",")
.map((scope) => scope.replace(/['"`]/g, "").trim())
.filter((scope) => scope.length > 0);
}
function getCurrentRepoSlug(cwd: string): string | null {
try {
runCommand("git", ["rev-parse", "--git-dir"], cwd);
} catch {
return null;
}
try {
const remote = runCommand("git", ["remote", "get-url", "origin"], cwd);
return parseGitHubRepoFromRemote(remote);
} catch {
return null;
}
}
export function runGhPreflight(cwd: string): GhPreflightResult {
try {
runCommand("gh", ["--version"]);
} catch {
return {
ok: false,
currentRepo: getCurrentRepoSlug(cwd),
scopes: [],
hasRepoScope: false,
hasWorkflowScope: false,
remediation: "Install GitHub CLI: https://cli.github.com/",
details: "GitHub CLI (gh) is not installed or not available in PATH.",
};
}
let rawStatus = "";
try {
rawStatus = runCommand("gh", ["auth", "status", "-h", "github.com"]);
} catch {
return {
ok: false,
currentRepo: getCurrentRepoSlug(cwd),
scopes: [],
hasRepoScope: false,
hasWorkflowScope: false,
remediation: "Run: gh auth login",
details: "GitHub CLI is not authenticated for github.com.",
};
}
const scopes = parseScopesFromGhAuthStatus(rawStatus);
const hasRepoScope = scopes.length === 0 ? true : scopes.includes("repo");
const hasWorkflowScope =
scopes.length === 0 ? true : scopes.includes("workflow");
if (!hasRepoScope || !hasWorkflowScope) {
return {
ok: false,
currentRepo: getCurrentRepoSlug(cwd),
scopes,
hasRepoScope,
hasWorkflowScope,
remediation: "Run: gh auth refresh -h github.com -s repo,workflow",
details:
"GitHub CLI authentication is missing required scopes: repo and workflow.",
};
}
return {
ok: true,
currentRepo: getCurrentRepoSlug(cwd),
scopes,
hasRepoScope,
hasWorkflowScope,
details: "GitHub CLI is ready.",
};
}
export function generateLettaWorkflowYaml(options?: {
includeAgentId?: boolean;
}): string {
const lines = [
"name: Letta Code",
"on:",
" issues:",
" types: [opened, labeled]",
" issue_comment:",
" types: [created]",
" pull_request:",
" types: [opened, labeled]",
" pull_request_review_comment:",
" types: [created]",
"",
"jobs:",
" letta:",
" runs-on: ubuntu-latest",
" permissions:",
" contents: write",
" issues: write",
" pull-requests: write",
" steps:",
" - uses: actions/checkout@v4",
" - uses: letta-ai/letta-code-action@v0",
" with:",
" letta_api_key: $" + "{{ secrets.LETTA_API_KEY }}",
" github_token: $" + "{{ secrets.GITHUB_TOKEN }}",
];
if (options?.includeAgentId) {
lines.push(" agent_id: $" + "{{ vars.LETTA_AGENT_ID }}");
}
return lines.join("\n");
}
export function buildInstallPrBody(workflowPath: string): string {
return [
"## 👾 Add Letta Code GitHub Workflow",
"",
`This PR adds [\`${workflowPath}\`](${workflowPath}), a GitHub Actions workflow that enables [Letta Code](https://docs.letta.com/letta-code) integration in this repository.`,
"",
"### What is Letta Code?",
"",
"[Letta Code](https://docs.letta.com/letta-code) is a stateful AI coding agent that can help with:",
"- Bug fixes and improvements",
"- Documentation updates",
"- Implementing new features",
"- Code reviews and suggestions",
"- Writing tests",
"- And more!",
"",
"### How it works",
"",
"Once this PR is merged, you can interact with Letta Code by mentioning `@letta-code` in a pull request or issue comment.",
"",
"When triggered, Letta Code will analyze the comment and surrounding context and execute on the request in a GitHub Action. Because Letta agents are **stateful**, every interaction builds on the same persistent memory \u2014 the agent learns your codebase and preferences over time.",
"",
"### Conversations",
"",
"Each issue and PR gets its own **conversation** with the same underlying agent:",
"- Commenting `@letta-code` on a new issue or PR starts a new conversation",
"- Additional comments on the **same issue or PR** continue the existing conversation \u2014 the agent remembers the full thread",
'- If the agent opens a PR from an issue (e.g. via "Fixes #N"), follow-up comments on the PR continue the **issue\'s conversation** automatically',
"- Use `@letta-code [--new]` to force a fresh conversation while keeping the same agent",
"",
"You can also specify a particular agent: `@letta-code [--agent agent-xxx]`",
"",
"View agent runs and conversations at [app.letta.com](https://app.letta.com).",
"",
"### Important Notes",
"",
"- **This workflow won't take effect until this PR is merged**",
"- **`@letta-code` mentions won't work until after the merge is complete**",
"- The workflow runs automatically whenever Letta Code is mentioned in PR or issue comments",
"- Letta Code gets access to the entire PR or issue context including files, diffs, and previous comments",
"",
"There's more information in the [Letta Code Action repo](https://github.com/letta-ai/letta-code-action).",
"",
"After merging this PR, try mentioning `@letta-code` in a comment on any PR to get started!",
].join("\n");
}
function checkRemoteFileExists(repo: string, path: string): boolean {
try {
runCommand("gh", ["api", `repos/${repo}/contents/${path}`]);
return true;
} catch {
return false;
}
}
export function getDefaultWorkflowPath(workflowExists: boolean): string {
return workflowExists ? ALTERNATE_WORKFLOW_PATH : DEFAULT_WORKFLOW_PATH;
}
export function getRepoSetupState(repo: string): RepoSetupState {
const workflowExists = checkRemoteFileExists(repo, DEFAULT_WORKFLOW_PATH);
const secretExists = hasRepositorySecret(repo, "LETTA_API_KEY");
return { workflowExists, secretExists };
}
export function hasRepositorySecret(repo: string, secretName: string): boolean {
const output = runCommand("gh", ["secret", "list", "--repo", repo]);
const lines = output.split(/\r?\n/).map((line) => line.trim());
return lines.some((line) => line.split(/\s+/)[0] === secretName);
}
export function setRepositorySecret(
repo: string,
secretName: string,
value: string,
): void {
runCommand(
"gh",
["secret", "set", secretName, "--repo", repo],
undefined,
value,
);
}
export function setRepositoryVariable(
repo: string,
name: string,
value: string,
): void {
runCommand("gh", ["variable", "set", name, "--repo", repo, "--body", value]);
}
export async function createLettaAgent(
apiKey: string,
name: string,
): Promise<{ id: string; name: string }> {
const response = await fetch("https://api.letta.com/v1/agents", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to create agent: ${response.status} ${text}`);
}
const data = (await response.json()) as { id: string; name: string };
return { id: data.id, name: data.name };
}
function cloneRepoToTemp(repo: string): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(join(tmpdir(), "letta-install-github-app-"));
const repoDir = join(tempDir, "repo");
runCommand("gh", ["repo", "clone", repo, repoDir, "--", "--depth=1"]);
return { tempDir, repoDir };
}
function createBranchName(): string {
return `letta/install-github-app-${Date.now().toString(36)}`;
}
function runGit(args: string[], cwd: string): string {
return runCommand("git", args, cwd);
}
function writeWorkflow(
repoDir: string,
workflowPath: string,
content: string,
): boolean {
const absolutePath = join(repoDir, workflowPath);
if (!existsSync(dirname(absolutePath))) {
mkdirSync(dirname(absolutePath), { recursive: true });
}
const next = `${content.trimEnd()}\n`;
if (existsSync(absolutePath)) {
const previous = readFileSync(absolutePath, "utf8");
if (previous === next) {
return false;
}
}
writeFileSync(absolutePath, next, "utf8");
return true;
}
function getDefaultBaseBranch(repoDir: string): string {
try {
const headRef = runGit(
["symbolic-ref", "refs/remotes/origin/HEAD"],
repoDir,
);
return headRef.replace("refs/remotes/origin/", "").trim() || "main";
} catch {
return "main";
}
}
function createPullRequest(
repo: string,
branchName: string,
workflowPath: string,
repoDir: string,
): { url: string; mode: "created" | "page-opened" } {
const title = "Add Letta Code GitHub Workflow";
const body = buildInstallPrBody(workflowPath);
const base = getDefaultBaseBranch(repoDir);
try {
const url = runCommand("gh", [
"pr",
"create",
"--repo",
repo,
"--head",
branchName,
"--base",
base,
"--title",
title,
"--body",
body,
"--web",
]);
return { url, mode: "page-opened" };
} catch {
const url = runCommand("gh", [
"pr",
"create",
"--repo",
repo,
"--head",
branchName,
"--base",
base,
"--title",
title,
"--body",
body,
]);
return { url, mode: "created" };
}
}
export async function installGithubApp(
options: InstallGithubAppOptions,
): Promise<InstallGithubAppResult> {
const {
repo,
workflowPath,
reuseExistingSecret,
apiKey,
agentMode,
agentId: providedAgentId,
agentName,
onProgress,
} = options;
if (!validateRepoSlug(repo)) {
throw new Error("Repository must be in owner/repo format.");
}
if (!apiKey && (!reuseExistingSecret || agentMode === "create")) {
throw new Error("LETTA_API_KEY is required.");
}
const secretAction: "reused" | "set" = reuseExistingSecret ? "reused" : "set";
let resolvedAgentId: string | null = providedAgentId;
progress(onProgress, "Getting repository information");
ensureRepoAccess(repo);
// Create agent if needed (requires API key)
if (agentMode === "create" && agentName) {
const keyForAgent = apiKey;
if (!keyForAgent) {
throw new Error("LETTA_API_KEY is required to create an agent.");
}
progress(onProgress, `Creating agent ${agentName}`);
const agent = await createLettaAgent(keyForAgent, agentName);
resolvedAgentId = agent.id;
}
progress(onProgress, "Creating branch");
const { tempDir, repoDir } = cloneRepoToTemp(repo);
try {
const workflowContent = generateLettaWorkflowYaml({
includeAgentId: resolvedAgentId != null,
});
const branchName = createBranchName();
runGit(["checkout", "-b", branchName], repoDir);
progress(onProgress, "Creating workflow files");
const changed = writeWorkflow(repoDir, workflowPath, workflowContent);
if (!changed) {
progress(onProgress, "Workflow already up to date.");
return {
repo,
workflowPath,
branchName: null,
pullRequestUrl: null,
pullRequestCreateMode: "created",
committed: false,
secretAction,
agentId: resolvedAgentId,
agentUrl: resolvedAgentId
? `https://app.letta.com/agents/${resolvedAgentId}`
: null,
};
}
runGit(["add", workflowPath], repoDir);
runGit(["commit", "-m", "Add Letta Code GitHub Workflow"], repoDir);
if (!reuseExistingSecret && apiKey) {
progress(onProgress, "Setting up LETTA_API_KEY secret");
setRepositorySecret(repo, "LETTA_API_KEY", apiKey);
}
if (resolvedAgentId) {
progress(onProgress, "Configuring agent");
setRepositoryVariable(repo, "LETTA_AGENT_ID", resolvedAgentId);
}
progress(onProgress, "Opening pull request page");
runGit(["push", "-u", "origin", branchName], repoDir);
const pullRequest = createPullRequest(
repo,
branchName,
workflowPath,
repoDir,
);
return {
repo,
workflowPath,
branchName,
pullRequestUrl: pullRequest.url,
pullRequestCreateMode: pullRequest.mode,
committed: true,
secretAction,
agentId: resolvedAgentId,
agentUrl: resolvedAgentId
? `https://app.letta.com/agents/${resolvedAgentId}`
: null,
};
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}

View File

@@ -344,6 +344,15 @@ export const commands: Record<string, Command> = {
return `Installed Shift+Enter keybinding for ${terminalName}\nLocation: ${keybindingsPath}`;
},
},
"/install-github-app": {
desc: "Setup Letta Code GitHub Action in this repo",
order: 38,
noArgs: true,
handler: () => {
// Handled specially in App.tsx
return "Opening GitHub App installer...";
},
},
// === Session management (order 40-49) ===
"/plan": {

View File

@@ -0,0 +1,883 @@
import { Box, useInput } from "ink";
import Link from "ink-link";
import RawTextInput from "ink-text-input";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { getCurrentAgentId } from "../../agent/context";
import { settingsManager } from "../../settings-manager";
import {
getDefaultWorkflowPath,
getRepoSetupState,
type InstallGithubAppResult,
installGithubApp,
runGhPreflight,
validateRepoSlug,
} from "../commands/install-github-app";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { PasteAwareTextInput } from "./PasteAwareTextInput";
import { Text } from "./Text";
type TextInputProps = {
value: string;
onChange: (value: string) => void;
onSubmit?: (value: string) => void;
placeholder?: string;
focus?: boolean;
mask?: string;
};
const TextInput =
RawTextInput as unknown as React.ComponentType<TextInputProps>;
type Step =
| "checking"
| "choose-repo"
| "enter-repo"
| "choose-secret"
| "enter-api-key"
| "choose-agent"
| "enter-agent-name"
| "enter-agent-id"
| "creating"
| "success"
| "error";
interface InstallGithubAppFlowProps {
onComplete: (result: InstallGithubAppResult) => void;
onCancel: () => void;
}
interface ProgressItem {
label: string;
done: boolean;
active: boolean;
}
const SOLID_LINE = "─";
function buildProgressSteps(
agentMode: "current" | "existing" | "create",
agentName: string | null,
reuseExistingSecret: boolean,
): { key: string; label: string }[] {
const steps: { key: string; label: string }[] = [];
steps.push({
key: "Getting repository information",
label: "Getting repository information",
});
if (agentMode === "create" && agentName) {
steps.push({
key: "Creating agent",
label: `Creating agent ${agentName}`,
});
}
steps.push({ key: "Creating branch", label: "Creating branch" });
steps.push({
key: "Creating workflow files",
label: "Creating workflow files",
});
if (!reuseExistingSecret) {
steps.push({
key: "Setting up LETTA_API_KEY secret",
label: "Setting up LETTA_API_KEY secret",
});
}
if (agentMode !== "create") {
// create mode sets variable inline during agent creation step
steps.push({ key: "Configuring agent", label: "Configuring agent" });
}
steps.push({
key: "Opening pull request page",
label: "Opening pull request page",
});
return steps;
}
function buildProgress(
currentStatus: string,
agentMode: "current" | "existing" | "create",
agentName: string | null,
reuseExistingSecret: boolean,
): ProgressItem[] {
const steps = buildProgressSteps(agentMode, agentName, reuseExistingSecret);
const normalized = currentStatus.toLowerCase();
const activeIndex = steps.findIndex((step) =>
normalized.includes(step.key.toLowerCase()),
);
return steps.map((step, index) => ({
label: step.label,
done: activeIndex > index,
active: activeIndex === index,
}));
}
function renderPanel(
solidLine: string,
title: string,
subtitle: string,
body: React.ReactNode,
) {
return (
<Box flexDirection="column" width="100%">
<Text dimColor>{"> /install-github-app"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Box
borderStyle="round"
borderColor={colors.approval.border}
width="100%"
flexDirection="column"
paddingX={1}
>
<Text bold color={colors.approval.header}>
{title}
</Text>
<Text dimColor>{subtitle}</Text>
<Box height={1} />
{body}
</Box>
</Box>
);
}
function ChoiceList({
choices,
selectedIndex,
}: {
choices: { label: string }[];
selectedIndex: number;
}) {
return (
<Box flexDirection="column">
{choices.map((choice, index) => {
const selected = index === selectedIndex;
return (
<Box key={choice.label}>
<Text
color={selected ? colors.selector.itemHighlighted : undefined}
>
{selected ? "> " : " "}
{choice.label}
</Text>
</Box>
);
})}
</Box>
);
}
export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
onComplete,
onCancel,
}: InstallGithubAppFlowProps) {
const terminalWidth = useTerminalWidth();
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
const [step, setStep] = useState<Step>("checking");
const [status, setStatus] = useState<string>(
"Checking GitHub CLI prerequisites...",
);
const [errorMessage, setErrorMessage] = useState<string>("");
// Repo state
const [currentRepo, setCurrentRepo] = useState<string | null>(null);
const [repoChoiceIndex, setRepoChoiceIndex] = useState<number>(0);
const [repoInput, setRepoInput] = useState<string>("");
const [repo, setRepo] = useState<string>("");
const [repoError, setRepoError] = useState<string>("");
// Secret state
const [secretExists, setSecretExists] = useState<boolean>(false);
const [secretChoiceIndex, setSecretChoiceIndex] = useState<number>(0);
const [apiKeyInput, setApiKeyInput] = useState<string>("");
const [envApiKey, setEnvApiKey] = useState<string | null>(null);
const [reuseExistingSecret, setReuseExistingSecret] =
useState<boolean>(false);
// Agent state
const [currentAgentId, setCurrentAgentIdState] = useState<string | null>(
null,
);
const [agentChoiceIndex, setAgentChoiceIndex] = useState<number>(0);
const [agentMode, setAgentMode] = useState<"current" | "existing" | "create">(
"current",
);
const [agentNameInput, setAgentNameInput] = useState<string>("");
const [agentIdInput, setAgentIdInput] = useState<string>("");
// Workflow + result state
const [workflowPath, setWorkflowPath] = useState<string>(
".github/workflows/letta.yml",
);
const [result, setResult] = useState<InstallGithubAppResult | null>(null);
// Choices
const repoChoices = useMemo(() => {
if (currentRepo) {
return [
{
label: `Use current repository: ${currentRepo}`,
value: "current" as const,
},
{
label: "Enter a different repository",
value: "manual" as const,
},
];
}
return [{ label: "Enter a repository", value: "manual" as const }];
}, [currentRepo]);
const secretChoices = useMemo(
() =>
secretExists
? [
{
label: "Use existing LETTA_API_KEY secret (recommended)",
value: "reuse" as const,
},
{
label: "Set or overwrite LETTA_API_KEY secret",
value: "set" as const,
},
]
: [{ label: "Set LETTA_API_KEY secret", value: "set" as const }],
[secretExists],
);
const agentChoices = useMemo(() => {
const choices: {
label: string;
value: "current" | "existing" | "create";
}[] = [];
if (currentAgentId) {
choices.push({
label: `Use current agent (${currentAgentId.slice(0, 20)}...)`,
value: "current",
});
}
choices.push({
label: "Create a new agent",
value: "create",
});
choices.push({
label: "Use an existing agent",
value: "existing",
});
return choices;
}, [currentAgentId]);
// Determine what API key we have available
const availableApiKey = useMemo(() => {
if (apiKeyInput.trim()) return apiKeyInput.trim();
if (envApiKey) return envApiKey;
return null;
}, [apiKeyInput, envApiKey]);
const runInstall = useCallback(
async (
finalAgentMode: "current" | "existing" | "create",
finalAgentId: string | null,
finalAgentName: string | null,
useExistingSecret: boolean,
key: string | null,
) => {
if (!repo) {
setErrorMessage("Repository not set.");
setStep("error");
return;
}
setReuseExistingSecret(useExistingSecret);
setAgentMode(finalAgentMode);
setStep("creating");
setStatus("Preparing setup...");
try {
const installResult = await installGithubApp({
repo,
workflowPath,
reuseExistingSecret: useExistingSecret,
apiKey: key,
agentMode: finalAgentMode,
agentId: finalAgentId,
agentName: finalAgentName,
onProgress: (message) => setStatus(message),
});
setResult(installResult);
setStep("success");
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error));
setStep("error");
}
},
[repo, workflowPath],
);
const resolveRepo = useCallback(
async (repoSlug: string) => {
const trimmed = repoSlug.trim();
if (!validateRepoSlug(trimmed)) {
setRepoError("Repository must be in owner/repo format.");
return;
}
setRepoError("");
setStatus("Inspecting repository setup...");
try {
const setup = getRepoSetupState(trimmed);
setRepo(trimmed);
setSecretExists(setup.secretExists);
setWorkflowPath(getDefaultWorkflowPath(setup.workflowExists));
setSecretChoiceIndex(0);
if (envApiKey) {
// Already have API key from environment — skip entry, go to secret/agent choice
if (setup.secretExists) {
setStep("choose-secret");
} else {
setReuseExistingSecret(false);
setAgentChoiceIndex(0);
setStep("choose-agent");
}
} else {
// OAuth user — need to collect API key
setStep("enter-api-key");
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error));
setStep("error");
}
},
[envApiKey],
);
// Preflight check
useEffect(() => {
if (step !== "checking") return;
try {
const preflight = runGhPreflight(process.cwd());
if (!preflight.ok) {
const lines = [preflight.details];
if (preflight.remediation) {
lines.push("");
lines.push("How to fix:");
lines.push(` ${preflight.remediation}`);
}
setErrorMessage(lines.join("\n"));
setStep("error");
return;
}
if (preflight.currentRepo) {
setCurrentRepo(preflight.currentRepo);
setRepoInput(preflight.currentRepo);
}
// Check for existing API key in environment
const settings = settingsManager.getSettings();
const existingKey =
process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY || null;
if (existingKey) {
setEnvApiKey(existingKey);
}
// Try to get current agent ID
try {
const agentId = getCurrentAgentId();
setCurrentAgentIdState(agentId);
} catch {
// No agent context — that's fine
}
setStep("choose-repo");
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error));
setStep("error");
}
}, [step]);
// After agent selection, proceed to install — API key is always available at this point
const proceedFromAgent = useCallback(
(
mode: "current" | "existing" | "create",
agentId: string | null,
agentName: string | null,
) => {
void runInstall(
mode,
agentId,
agentName,
reuseExistingSecret,
availableApiKey,
);
},
[availableApiKey, reuseExistingSecret, runInstall],
);
useInput((input, key) => {
if (key.ctrl && input === "c") {
onCancel();
return;
}
if (step === "success") {
if (key.return || key.escape || input.length > 0) {
if (result) {
onComplete(result);
} else {
onCancel();
}
}
return;
}
if (key.escape) {
if (step === "choose-repo") {
onCancel();
return;
}
if (step === "enter-repo") {
setStep("choose-repo");
return;
}
if (step === "enter-api-key") {
if (currentRepo) {
setStep("choose-repo");
} else {
setStep("enter-repo");
}
return;
}
if (step === "choose-secret") {
if (envApiKey) {
// Skipped API key entry — go back to repo
if (currentRepo) {
setStep("choose-repo");
} else {
setStep("enter-repo");
}
} else {
setStep("enter-api-key");
}
return;
}
if (step === "choose-agent") {
if (secretExists) {
setStep("choose-secret");
} else if (envApiKey) {
if (currentRepo) {
setStep("choose-repo");
} else {
setStep("enter-repo");
}
} else {
setStep("enter-api-key");
}
return;
}
if (step === "enter-agent-name" || step === "enter-agent-id") {
setStep("choose-agent");
return;
}
if (step === "error") {
onCancel();
return;
}
onCancel();
return;
}
if (step === "choose-repo") {
if (key.upArrow || input === "k") {
setRepoChoiceIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow || input === "j") {
setRepoChoiceIndex((prev) =>
Math.min(repoChoices.length - 1, prev + 1),
);
} else if (key.return) {
const selected = repoChoices[repoChoiceIndex] ?? repoChoices[0];
if (!selected) return;
if (selected.value === "current" && currentRepo) {
void resolveRepo(currentRepo);
} else {
setStep("enter-repo");
}
}
return;
}
if (step === "choose-secret") {
if (key.upArrow || input === "k") {
setSecretChoiceIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow || input === "j") {
setSecretChoiceIndex((prev) =>
Math.min(secretChoices.length - 1, prev + 1),
);
} else if (key.return) {
const selected = secretChoices[secretChoiceIndex] ?? secretChoices[0];
if (!selected) return;
setReuseExistingSecret(selected.value === "reuse");
setAgentChoiceIndex(0);
setStep("choose-agent");
}
return;
}
if (step === "choose-agent") {
if (key.upArrow || input === "k") {
setAgentChoiceIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow || input === "j") {
setAgentChoiceIndex((prev) =>
Math.min(agentChoices.length - 1, prev + 1),
);
} else if (key.return) {
const selected = agentChoices[agentChoiceIndex] ?? agentChoices[0];
if (!selected) return;
setAgentMode(selected.value);
if (selected.value === "current" && currentAgentId) {
proceedFromAgent("current", currentAgentId, null);
} else if (selected.value === "create") {
setStep("enter-agent-name");
} else {
setStep("enter-agent-id");
}
}
}
});
// Handlers for text input steps
const handleApiKeySubmit = useCallback(
(value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setApiKeyInput(trimmed);
if (secretExists) {
// Ask whether to reuse or overwrite the existing secret
setSecretChoiceIndex(0);
setStep("choose-secret");
} else {
// No existing secret — we'll set it during install
setReuseExistingSecret(false);
setAgentChoiceIndex(0);
setStep("choose-agent");
}
},
[secretExists],
);
const handleAgentNameSubmit = useCallback(
(value: string) => {
const name = value.trim() || "GitHub Action Agent";
setAgentNameInput(name);
proceedFromAgent("create", null, name);
},
[proceedFromAgent],
);
const handleAgentIdSubmit = useCallback(
(value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setAgentIdInput(trimmed);
proceedFromAgent("existing", trimmed, null);
},
[proceedFromAgent],
);
// === RENDER ===
if (step === "checking") {
return renderPanel(
solidLine,
"Install GitHub App",
"Checking prerequisites",
<Box flexDirection="column" paddingLeft={1}>
<Text color="yellow">{status}</Text>
</Box>,
);
}
if (step === "choose-repo") {
return renderPanel(
solidLine,
"Install GitHub App",
"Select GitHub repository",
<>
<ChoiceList choices={repoChoices} selectedIndex={repoChoiceIndex} />
<Box height={1} />
<Text dimColor>/ to select · Enter to continue · Esc to cancel</Text>
</>,
);
}
if (step === "enter-repo") {
return renderPanel(
solidLine,
"Install GitHub App",
"Enter a different repository",
<>
<Box>
<Text color={colors.selector.itemHighlighted}>{">"}</Text>
<Text> </Text>
<PasteAwareTextInput
value={repoInput}
onChange={(next) => {
setRepoInput(next);
setRepoError("");
}}
onSubmit={(value) => {
void resolveRepo(value);
}}
placeholder="owner/repo"
/>
</Box>
{repoError ? (
<Box marginTop={1}>
<Text color="red">{repoError}</Text>
</Box>
) : null}
<Box height={1} />
<Text dimColor>Enter to continue · Esc to go back</Text>
</>,
);
}
if (step === "choose-secret") {
return renderPanel(
solidLine,
"Install GitHub App",
"Configure LETTA_API_KEY",
<>
<Box>
<Text dimColor>Repository: </Text>
<Text>{repo}</Text>
</Box>
<Box>
<Text dimColor>Workflow: </Text>
<Text>{workflowPath}</Text>
</Box>
<Box height={1} />
<ChoiceList choices={secretChoices} selectedIndex={secretChoiceIndex} />
<Box height={1} />
<Text dimColor>/ to select · Enter to continue · Esc to go back</Text>
</>,
);
}
if (step === "enter-api-key") {
return renderPanel(
solidLine,
"Install GitHub App",
"Enter LETTA_API_KEY",
<>
<Box>
<Text color={colors.selector.itemHighlighted}>{">"}</Text>
<Text> </Text>
<TextInput
value={apiKeyInput}
onChange={setApiKeyInput}
onSubmit={handleApiKeySubmit}
placeholder="sk-..."
mask="*"
/>
</Box>
<Box height={1} />
<Text dimColor>Enter to continue · Esc to go back</Text>
</>,
);
}
if (step === "choose-agent") {
return renderPanel(
solidLine,
"Install GitHub App",
"Configure agent",
<>
<Box>
<Text dimColor>Repository: </Text>
<Text>{repo}</Text>
</Box>
<Box height={1} />
<ChoiceList choices={agentChoices} selectedIndex={agentChoiceIndex} />
<Box height={1} />
<Text dimColor>/ to select · Enter to continue · Esc to go back</Text>
</>,
);
}
if (step === "enter-agent-name") {
return renderPanel(
solidLine,
"Install GitHub App",
"Create a new agent",
<>
<Box>
<Text dimColor>Agent name:</Text>
</Box>
<Box>
<Text color={colors.selector.itemHighlighted}>{">"}</Text>
<Text> </Text>
<PasteAwareTextInput
value={agentNameInput}
onChange={setAgentNameInput}
onSubmit={handleAgentNameSubmit}
placeholder="GitHub Action Agent"
/>
</Box>
<Box height={1} />
<Text dimColor>Enter to continue · Esc to go back</Text>
</>,
);
}
if (step === "enter-agent-id") {
return renderPanel(
solidLine,
"Install GitHub App",
"Use an existing agent",
<>
<Box>
<Text dimColor>Agent ID:</Text>
</Box>
<Box>
<Text color={colors.selector.itemHighlighted}>{">"}</Text>
<Text> </Text>
<PasteAwareTextInput
value={agentIdInput}
onChange={setAgentIdInput}
onSubmit={handleAgentIdSubmit}
placeholder="agent-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
</Box>
<Box height={1} />
<Text dimColor>Enter to continue · Esc to go back</Text>
</>,
);
}
if (step === "creating") {
const progressItems = buildProgress(
status,
agentMode,
agentNameInput || null,
reuseExistingSecret,
);
return renderPanel(
solidLine,
"Install GitHub App",
"Create GitHub Actions workflow",
<Box flexDirection="column">
{progressItems.map((item) => (
<Box key={item.label}>
{item.done ? (
<Text color="green">
{"✓"} {item.label}
</Text>
) : item.active ? (
<Text color="yellow">
{"•"} {item.label}
</Text>
) : (
<Text dimColor>
{"•"} {item.label}
</Text>
)}
</Box>
))}
</Box>,
);
}
if (step === "success") {
const successLines: string[] = [
"✓ GitHub Actions workflow created!",
"",
reuseExistingSecret
? "✓ Using existing LETTA_API_KEY secret"
: "✓ API key saved as LETTA_API_KEY secret",
];
if (result?.agentId) {
successLines.push("");
successLines.push(`✓ Agent configured: ${result.agentId}`);
}
successLines.push("");
successLines.push("Next steps:");
successLines.push("1. A pre-filled PR page has been created");
successLines.push("2. Merge the PR to enable Letta Code PR assistance");
successLines.push("3. Mention @letta-code in an issue or PR to test");
return renderPanel(
solidLine,
"Install GitHub App",
"Success",
<>
{successLines.map((line, idx) => (
<Box key={`${idx}-${line}`}>
{line.startsWith("✓") ? (
<Text color="green">{line}</Text>
) : (
<Text dimColor={line === ""}>{line || " "}</Text>
)}
</Box>
))}
{result?.agentUrl ? (
<>
<Box height={1} />
<Box>
<Text dimColor>Agent: </Text>
<Link url={result.agentUrl}>
<Text color={colors.link.url}>{result.agentUrl}</Text>
</Link>
</Box>
</>
) : null}
{result?.pullRequestUrl ? (
<Box>
<Text dimColor>PR: </Text>
<Link url={result.pullRequestUrl}>
<Text color={colors.link.url}>{result.pullRequestUrl}</Text>
</Link>
</Box>
) : null}
<Box height={1} />
<Text dimColor>Press any key to exit</Text>
</>,
);
}
return renderPanel(
solidLine,
"Install GitHub App",
"Error",
<>
<Text color="red">
Error: {errorMessage.split("\n")[0] || "Unknown error"}
</Text>
<Box height={1} />
{errorMessage
.split("\n")
.slice(1)
.filter((line) => line.trim().length > 0)
.map((line, idx) => (
<Text key={`${idx}-${line}`} dimColor>
{line}
</Text>
))}
<Box height={1} />
<Text dimColor>Esc to close</Text>
</>,
);
});
InstallGithubAppFlow.displayName = "InstallGithubAppFlow";

View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test";
import {
buildInstallPrBody,
generateLettaWorkflowYaml,
getDefaultWorkflowPath,
parseGitHubRepoFromRemote,
parseScopesFromGhAuthStatus,
validateRepoSlug,
} from "../../cli/commands/install-github-app";
describe("install-github-app helpers", () => {
test("validateRepoSlug accepts owner/repo and rejects invalid forms", () => {
expect(validateRepoSlug("letta-ai/letta-code")).toBe(true);
expect(validateRepoSlug("owner/repo-name_1")).toBe(true);
expect(validateRepoSlug("letta-ai")).toBe(false);
expect(validateRepoSlug("/letta-code")).toBe(false);
expect(validateRepoSlug("letta-ai/")).toBe(false);
expect(validateRepoSlug("https://github.com/letta-ai/letta-code")).toBe(
false,
);
});
test("parseGitHubRepoFromRemote handles https and ssh remotes", () => {
expect(
parseGitHubRepoFromRemote("https://github.com/letta-ai/letta-code.git"),
).toBe("letta-ai/letta-code");
expect(
parseGitHubRepoFromRemote("git@github.com:letta-ai/letta-code.git"),
).toBe("letta-ai/letta-code");
expect(
parseGitHubRepoFromRemote("ssh://git@github.com/letta-ai/letta-code.git"),
).toBe("letta-ai/letta-code");
expect(
parseGitHubRepoFromRemote("https://gitlab.com/letta-ai/letta-code.git"),
).toBeNull();
});
test("parseScopesFromGhAuthStatus extracts token scopes", () => {
const status = [
"github.com",
" ✓ Logged in to github.com account test (keyring)",
" - Active account: true",
" - Git operations protocol: https",
" - Token: ghp_xxx",
" - Token scopes: gist, read:org, repo, workflow",
].join("\n");
expect(parseScopesFromGhAuthStatus(status)).toEqual([
"gist",
"read:org",
"repo",
"workflow",
]);
});
test("getDefaultWorkflowPath chooses alternate path when existing workflow found", () => {
expect(getDefaultWorkflowPath(false)).toBe(".github/workflows/letta.yml");
expect(getDefaultWorkflowPath(true)).toBe(
".github/workflows/letta-code.yml",
);
});
test("generateLettaWorkflowYaml includes required action configuration", () => {
const yaml = generateLettaWorkflowYaml();
expect(yaml).toContain("uses: letta-ai/letta-code-action@v0");
expect(yaml).toContain("letta_api_key: $" + "{{ secrets.LETTA_API_KEY }}");
expect(yaml).toContain("github_token: $" + "{{ secrets.GITHUB_TOKEN }}");
expect(yaml).toContain("pull-requests: write");
expect(yaml).not.toContain("agent_id");
});
test("generateLettaWorkflowYaml includes agent_id when requested", () => {
const yaml = generateLettaWorkflowYaml({ includeAgentId: true });
expect(yaml).toContain("agent_id: $" + "{{ vars.LETTA_AGENT_ID }}");
expect(yaml).toContain("uses: letta-ai/letta-code-action@v0");
});
test("buildInstallPrBody references workflow path and trigger phrase", () => {
const body = buildInstallPrBody(".github/workflows/letta.yml");
expect(body).toContain("Add Letta Code GitHub Workflow");
expect(body).toContain(".github/workflows/letta.yml");
expect(body).toContain("@letta-code");
expect(body).toContain("stateful");
expect(body).toContain("app.letta.com");
expect(body).toContain("letta-code-action");
});
});