feat(cli): add /install-github-app setup wizard (#1097)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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
|
||||
|
||||
561
src/cli/commands/install-github-app.ts
Normal file
561
src/cli/commands/install-github-app.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
883
src/cli/components/InstallGithubAppFlow.tsx
Normal file
883
src/cli/components/InstallGithubAppFlow.tsx
Normal 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";
|
||||
94
src/tests/cli/install-github-app.test.ts
Normal file
94
src/tests/cli/install-github-app.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user