From 6813167a2a5db41acc79e8cf5d90ffc780b27a16 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Mon, 23 Feb 2026 10:38:21 -0800 Subject: [PATCH] feat(cli): add /install-github-app setup wizard (#1097) Co-authored-by: Letta --- src/cli/App.tsx | 92 ++ src/cli/commands/install-github-app.ts | 561 +++++++++++++ src/cli/commands/registry.ts | 9 + src/cli/components/InstallGithubAppFlow.tsx | 883 ++++++++++++++++++++ src/tests/cli/install-github-app.test.ts | 94 +++ 5 files changed, 1639 insertions(+) create mode 100644 src/cli/commands/install-github-app.ts create mode 100644 src/cli/components/InstallGithubAppFlow.tsx create mode 100644 src/tests/cli/install-github-app.test.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 39cea8e..38663dc 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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" && ( + { + 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" && ( 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 { + 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 }); + } +} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index f958126..92633b1 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -344,6 +344,15 @@ export const commands: Record = { 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": { diff --git a/src/cli/components/InstallGithubAppFlow.tsx b/src/cli/components/InstallGithubAppFlow.tsx new file mode 100644 index 0000000..8811f28 --- /dev/null +++ b/src/cli/components/InstallGithubAppFlow.tsx @@ -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; + +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 ( + + {"> /install-github-app"} + {solidLine} + + + + {title} + + {subtitle} + + {body} + + + ); +} + +function ChoiceList({ + choices, + selectedIndex, +}: { + choices: { label: string }[]; + selectedIndex: number; +}) { + return ( + + {choices.map((choice, index) => { + const selected = index === selectedIndex; + return ( + + + {selected ? "> " : " "} + {choice.label} + + + ); + })} + + ); +} + +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("checking"); + const [status, setStatus] = useState( + "Checking GitHub CLI prerequisites...", + ); + const [errorMessage, setErrorMessage] = useState(""); + + // Repo state + const [currentRepo, setCurrentRepo] = useState(null); + const [repoChoiceIndex, setRepoChoiceIndex] = useState(0); + const [repoInput, setRepoInput] = useState(""); + const [repo, setRepo] = useState(""); + const [repoError, setRepoError] = useState(""); + + // Secret state + const [secretExists, setSecretExists] = useState(false); + const [secretChoiceIndex, setSecretChoiceIndex] = useState(0); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [envApiKey, setEnvApiKey] = useState(null); + const [reuseExistingSecret, setReuseExistingSecret] = + useState(false); + + // Agent state + const [currentAgentId, setCurrentAgentIdState] = useState( + null, + ); + const [agentChoiceIndex, setAgentChoiceIndex] = useState(0); + const [agentMode, setAgentMode] = useState<"current" | "existing" | "create">( + "current", + ); + const [agentNameInput, setAgentNameInput] = useState(""); + const [agentIdInput, setAgentIdInput] = useState(""); + + // Workflow + result state + const [workflowPath, setWorkflowPath] = useState( + ".github/workflows/letta.yml", + ); + const [result, setResult] = useState(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", + + {status} + , + ); + } + + if (step === "choose-repo") { + return renderPanel( + solidLine, + "Install GitHub App", + "Select GitHub repository", + <> + + + โ†‘/โ†“ to select ยท Enter to continue ยท Esc to cancel + , + ); + } + + if (step === "enter-repo") { + return renderPanel( + solidLine, + "Install GitHub App", + "Enter a different repository", + <> + + {">"} + + { + setRepoInput(next); + setRepoError(""); + }} + onSubmit={(value) => { + void resolveRepo(value); + }} + placeholder="owner/repo" + /> + + {repoError ? ( + + {repoError} + + ) : null} + + Enter to continue ยท Esc to go back + , + ); + } + + if (step === "choose-secret") { + return renderPanel( + solidLine, + "Install GitHub App", + "Configure LETTA_API_KEY", + <> + + Repository: + {repo} + + + Workflow: + {workflowPath} + + + + + โ†‘/โ†“ to select ยท Enter to continue ยท Esc to go back + , + ); + } + + if (step === "enter-api-key") { + return renderPanel( + solidLine, + "Install GitHub App", + "Enter LETTA_API_KEY", + <> + + {">"} + + + + + Enter to continue ยท Esc to go back + , + ); + } + + if (step === "choose-agent") { + return renderPanel( + solidLine, + "Install GitHub App", + "Configure agent", + <> + + Repository: + {repo} + + + + + โ†‘/โ†“ to select ยท Enter to continue ยท Esc to go back + , + ); + } + + if (step === "enter-agent-name") { + return renderPanel( + solidLine, + "Install GitHub App", + "Create a new agent", + <> + + Agent name: + + + {">"} + + + + + Enter to continue ยท Esc to go back + , + ); + } + + if (step === "enter-agent-id") { + return renderPanel( + solidLine, + "Install GitHub App", + "Use an existing agent", + <> + + Agent ID: + + + {">"} + + + + + Enter to continue ยท Esc to go back + , + ); + } + + if (step === "creating") { + const progressItems = buildProgress( + status, + agentMode, + agentNameInput || null, + reuseExistingSecret, + ); + return renderPanel( + solidLine, + "Install GitHub App", + "Create GitHub Actions workflow", + + {progressItems.map((item) => ( + + {item.done ? ( + + {"โœ“"} {item.label} + + ) : item.active ? ( + + {"โ€ข"} {item.label}โ€ฆ + + ) : ( + + {"โ€ข"} {item.label} + + )} + + ))} + , + ); + } + + 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) => ( + + {line.startsWith("โœ“") ? ( + {line} + ) : ( + {line || " "} + )} + + ))} + {result?.agentUrl ? ( + <> + + + Agent: + + {result.agentUrl} + + + + ) : null} + {result?.pullRequestUrl ? ( + + PR: + + {result.pullRequestUrl} + + + ) : null} + + Press any key to exit + , + ); + } + + return renderPanel( + solidLine, + "Install GitHub App", + "Error", + <> + + Error: {errorMessage.split("\n")[0] || "Unknown error"} + + + {errorMessage + .split("\n") + .slice(1) + .filter((line) => line.trim().length > 0) + .map((line, idx) => ( + + {line} + + ))} + + Esc to close + , + ); +}); + +InstallGithubAppFlow.displayName = "InstallGithubAppFlow"; diff --git a/src/tests/cli/install-github-app.test.ts b/src/tests/cli/install-github-app.test.ts new file mode 100644 index 0000000..d3b66f5 --- /dev/null +++ b/src/tests/cli/install-github-app.test.ts @@ -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"); + }); +});