fix(cli): detect API key from env instead of checking repo secrets (#1116)

Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Charles Packer <cpacker@users.noreply.github.com>
This commit is contained in:
Sarah Wooders
2026-02-24 12:19:31 -08:00
committed by GitHub
parent d30d2992b9
commit a086957407
3 changed files with 318 additions and 160 deletions

View File

@@ -50,13 +50,11 @@ export interface GhPreflightResult {
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;
@@ -301,8 +299,7 @@ export function getDefaultWorkflowPath(workflowExists: boolean): string {
export function getRepoSetupState(repo: string): RepoSetupState {
const workflowExists = checkRemoteFileExists(repo, DEFAULT_WORKFLOW_PATH);
const secretExists = hasRepositorySecret(repo, "LETTA_API_KEY");
return { workflowExists, secretExists };
return { workflowExists };
}
export function hasRepositorySecret(repo: string, secretName: string): boolean {
@@ -455,7 +452,6 @@ export async function installGithubApp(
const {
repo,
workflowPath,
reuseExistingSecret,
apiKey,
agentMode,
agentId: providedAgentId,
@@ -467,24 +463,18 @@ export async function installGithubApp(
throw new Error("Repository must be in owner/repo format.");
}
if (!apiKey && (!reuseExistingSecret || agentMode === "create")) {
if (!apiKey) {
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)
// Create agent if needed
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);
const agent = await createLettaAgent(apiKey, agentName);
resolvedAgentId = agent.id;
}
@@ -502,6 +492,15 @@ export async function installGithubApp(
progress(onProgress, "Creating workflow files");
const changed = writeWorkflow(repoDir, workflowPath, workflowContent);
// Always set the secret from the locally-available key
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);
}
if (!changed) {
progress(onProgress, "Workflow already up to date.");
return {
@@ -511,7 +510,7 @@ export async function installGithubApp(
pullRequestUrl: null,
pullRequestCreateMode: "created",
committed: false,
secretAction,
secretAction: "set",
agentId: resolvedAgentId,
agentUrl: resolvedAgentId
? `https://app.letta.com/agents/${resolvedAgentId}`
@@ -522,16 +521,6 @@ export async function installGithubApp(
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);
@@ -549,7 +538,7 @@ export async function installGithubApp(
pullRequestUrl: pullRequest.url,
pullRequestCreateMode: pullRequest.mode,
committed: true,
secretAction,
secretAction: "set",
agentId: resolvedAgentId,
agentUrl: resolvedAgentId
? `https://app.letta.com/agents/${resolvedAgentId}`

View File

@@ -33,7 +33,6 @@ type Step =
| "checking"
| "choose-repo"
| "enter-repo"
| "choose-secret"
| "enter-api-key"
| "choose-agent"
| "enter-agent-name"
@@ -55,10 +54,9 @@ interface ProgressItem {
const SOLID_LINE = "─";
function buildProgressSteps(
export function buildProgressSteps(
agentMode: "current" | "existing" | "create",
agentName: string | null,
reuseExistingSecret: boolean,
): { key: string; label: string }[] {
const steps: { key: string; label: string }[] = [];
steps.push({
@@ -76,14 +74,11 @@ function buildProgressSteps(
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",
});
}
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({
@@ -93,13 +88,12 @@ function buildProgressSteps(
return steps;
}
function buildProgress(
export function buildProgress(
currentStatus: string,
agentMode: "current" | "existing" | "create",
agentName: string | null,
reuseExistingSecret: boolean,
): ProgressItem[] {
const steps = buildProgressSteps(agentMode, agentName, reuseExistingSecret);
const steps = buildProgressSteps(agentMode, agentName);
const normalized = currentStatus.toLowerCase();
const activeIndex = steps.findIndex((step) =>
@@ -189,12 +183,8 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
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>(
@@ -230,23 +220,6 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
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;
@@ -281,7 +254,6 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
finalAgentMode: "current" | "existing" | "create",
finalAgentId: string | null,
finalAgentName: string | null,
useExistingSecret: boolean,
key: string | null,
) => {
if (!repo) {
@@ -290,7 +262,6 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
return;
}
setReuseExistingSecret(useExistingSecret);
setAgentMode(finalAgentMode);
setStep("creating");
setStatus("Preparing setup...");
@@ -299,7 +270,6 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
const installResult = await installGithubApp({
repo,
workflowPath,
reuseExistingSecret: useExistingSecret,
apiKey: key,
agentMode: finalAgentMode,
agentId: finalAgentId,
@@ -331,21 +301,14 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
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");
}
// Already have API key from environment — skip to agent choice
setAgentChoiceIndex(0);
setStep("choose-agent");
} else {
// OAuth user — need to collect API key
// Need to collect API key
setStep("enter-api-key");
}
} catch (error) {
@@ -409,15 +372,9 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
agentId: string | null,
agentName: string | null,
) => {
void runInstall(
mode,
agentId,
agentName,
reuseExistingSecret,
availableApiKey,
);
void runInstall(mode, agentId, agentName, availableApiKey);
},
[availableApiKey, reuseExistingSecret, runInstall],
[availableApiKey, runInstall],
);
useInput((input, key) => {
@@ -454,23 +411,8 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
}
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 (envApiKey) {
if (currentRepo) {
setStep("choose-repo");
} else {
@@ -513,23 +455,6 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
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));
@@ -554,25 +479,13 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
});
// 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 handleApiKeySubmit = useCallback((value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setApiKeyInput(trimmed);
setAgentChoiceIndex(0);
setStep("choose-agent");
}, []);
const handleAgentNameSubmit = useCallback(
(value: string) => {
@@ -651,28 +564,6 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
);
}
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,
@@ -769,7 +660,6 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
status,
agentMode,
agentNameInput || null,
reuseExistingSecret,
);
return renderPanel(
solidLine,
@@ -801,9 +691,7 @@ export const InstallGithubAppFlow = memo(function InstallGithubAppFlow({
const successLines: string[] = [
"✓ GitHub Actions workflow created!",
"",
reuseExistingSecret
? "✓ Using existing LETTA_API_KEY secret"
: "✓ API key saved as LETTA_API_KEY secret",
"✓ API key saved as LETTA_API_KEY secret",
];
if (result?.agentId) {

View File

@@ -3,10 +3,19 @@ import {
buildInstallPrBody,
generateLettaWorkflowYaml,
getDefaultWorkflowPath,
type InstallGithubAppResult,
parseGitHubRepoFromRemote,
parseScopesFromGhAuthStatus,
validateRepoSlug,
} from "../../cli/commands/install-github-app";
import {
buildProgress,
buildProgressSteps,
} from "../../cli/components/InstallGithubAppFlow";
// ---------------------------------------------------------------------------
// Helper utilities
// ---------------------------------------------------------------------------
describe("install-github-app helpers", () => {
test("validateRepoSlug accepts owner/repo and rejects invalid forms", () => {
@@ -92,3 +101,275 @@ describe("install-github-app helpers", () => {
expect(body).toContain("letta-code-action");
});
});
// ---------------------------------------------------------------------------
// Wizard progress steps
// ---------------------------------------------------------------------------
describe("buildProgressSteps", () => {
test("includes all standard steps for current agent mode", () => {
const steps = buildProgressSteps("current", null);
const labels = steps.map((s) => s.label);
expect(labels).toEqual([
"Getting repository information",
"Creating branch",
"Creating workflow files",
"Setting up LETTA_API_KEY secret",
"Configuring agent",
"Opening pull request page",
]);
});
test("includes all standard steps for existing agent mode", () => {
const steps = buildProgressSteps("existing", null);
const labels = steps.map((s) => s.label);
expect(labels).toContain("Configuring agent");
expect(labels).toContain("Setting up LETTA_API_KEY secret");
expect(labels).not.toContainEqual(
expect.stringContaining("Creating agent"),
);
});
test("includes agent creation step in create mode", () => {
const steps = buildProgressSteps("create", "My Bot");
const labels = steps.map((s) => s.label);
expect(labels).toContain("Creating agent My Bot");
});
test("omits 'Configuring agent' in create mode", () => {
const steps = buildProgressSteps("create", "My Bot");
const labels = steps.map((s) => s.label);
expect(labels).not.toContain("Configuring agent");
});
test("always includes LETTA_API_KEY secret step regardless of mode", () => {
for (const mode of ["current", "existing", "create"] as const) {
const steps = buildProgressSteps(mode, mode === "create" ? "Bot" : null);
const labels = steps.map((s) => s.label);
expect(labels).toContain("Setting up LETTA_API_KEY secret");
}
});
test("steps are in the correct order", () => {
const steps = buildProgressSteps("current", null);
const labels = steps.map((s) => s.label);
const repoIdx = labels.indexOf("Getting repository information");
const branchIdx = labels.indexOf("Creating branch");
const workflowIdx = labels.indexOf("Creating workflow files");
const secretIdx = labels.indexOf("Setting up LETTA_API_KEY secret");
const agentIdx = labels.indexOf("Configuring agent");
const prIdx = labels.indexOf("Opening pull request page");
expect(repoIdx).toBeLessThan(branchIdx);
expect(branchIdx).toBeLessThan(workflowIdx);
expect(workflowIdx).toBeLessThan(secretIdx);
expect(secretIdx).toBeLessThan(agentIdx);
expect(agentIdx).toBeLessThan(prIdx);
});
});
// ---------------------------------------------------------------------------
// Progress state machine
// ---------------------------------------------------------------------------
describe("buildProgress", () => {
test("marks first step as active when status matches it", () => {
const items = buildProgress(
"Getting repository information",
"current",
null,
);
expect(items[0]?.active).toBe(true);
expect(items[0]?.done).toBe(false);
// All subsequent steps should be inactive and not done
for (const item of items.slice(1)) {
expect(item.active).toBe(false);
expect(item.done).toBe(false);
}
});
test("marks prior steps as done and current step as active", () => {
const items = buildProgress("Creating workflow files", "current", null);
const labels = items.map((i) => i.label);
const workflowIdx = labels.indexOf("Creating workflow files");
// All steps before should be done
for (let i = 0; i < workflowIdx; i++) {
expect(items[i]?.done).toBe(true);
expect(items[i]?.active).toBe(false);
}
// Current step should be active
expect(items[workflowIdx]?.active).toBe(true);
expect(items[workflowIdx]?.done).toBe(false);
// Steps after should be neither done nor active
for (let i = workflowIdx + 1; i < items.length; i++) {
expect(items[i]?.done).toBe(false);
expect(items[i]?.active).toBe(false);
}
});
test("marks all prior steps done when on last step", () => {
const items = buildProgress("Opening pull request page", "current", null);
const lastIdx = items.length - 1;
for (let i = 0; i < lastIdx; i++) {
expect(items[i]?.done).toBe(true);
}
expect(items[lastIdx]?.active).toBe(true);
expect(items[lastIdx]?.done).toBe(false);
});
test("no step is active when status doesn't match any step", () => {
const items = buildProgress("Preparing setup...", "current", null);
for (const item of items) {
expect(item.active).toBe(false);
expect(item.done).toBe(false);
}
});
test("matches status case-insensitively", () => {
const items = buildProgress(
"SETTING UP LETTA_API_KEY SECRET",
"current",
null,
);
const secretItem = items.find(
(i) => i.label === "Setting up LETTA_API_KEY secret",
);
expect(secretItem?.active).toBe(true);
});
test("includes agent creation step in create mode progress", () => {
const items = buildProgress("Creating agent My Bot", "create", "My Bot");
const agentItem = items.find((i) => i.label === "Creating agent My Bot");
expect(agentItem).toBeDefined();
expect(agentItem?.active).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Success screen / InstallGithubAppResult content
// ---------------------------------------------------------------------------
describe("success screen content", () => {
const baseResult: InstallGithubAppResult = {
repo: "letta-ai/letta-code",
workflowPath: ".github/workflows/letta.yml",
branchName: "letta/install-github-app-abc123",
pullRequestUrl: "https://github.com/letta-ai/letta-code/pull/42",
pullRequestCreateMode: "created",
committed: true,
secretAction: "set",
agentId: "agent-aaaabbbb-cccc-dddd-eeee-ffffffffffff",
agentUrl:
"https://app.letta.com/agents/agent-aaaabbbb-cccc-dddd-eeee-ffffffffffff",
};
test("agentUrl points to app.letta.com ADE", () => {
expect(baseResult.agentUrl).toBe(
`https://app.letta.com/agents/${baseResult.agentId}`,
);
});
test("agentUrl is null when agentId is null", () => {
const noAgent: InstallGithubAppResult = {
...baseResult,
agentId: null,
agentUrl: null,
};
expect(noAgent.agentUrl).toBeNull();
});
test("secretAction is always 'set' after the API key fix", () => {
// After removing reuseExistingSecret, the secret is always set
expect(baseResult.secretAction).toBe("set");
});
test("pullRequestUrl is present when workflow was committed", () => {
expect(baseResult.committed).toBe(true);
expect(baseResult.pullRequestUrl).toContain("github.com");
expect(baseResult.pullRequestUrl).toContain("/pull/");
});
test("pullRequestUrl is null when workflow was unchanged", () => {
const unchanged: InstallGithubAppResult = {
...baseResult,
committed: false,
branchName: null,
pullRequestUrl: null,
};
expect(unchanged.pullRequestUrl).toBeNull();
expect(unchanged.branchName).toBeNull();
});
// Simulate the success lines built by the component to verify content
test("success lines include expected content", () => {
const result = baseResult;
const successLines: string[] = [
"✓ GitHub Actions workflow created!",
"",
"✓ 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");
// Verify all expected content is present
const allText = successLines.join("\n");
expect(allText).toContain("GitHub Actions workflow created");
expect(allText).toContain("API key saved as LETTA_API_KEY secret");
expect(allText).toContain(`Agent configured: ${result.agentId}`);
expect(allText).toContain("Merge the PR");
expect(allText).toContain("@letta-code");
});
test("success lines omit agent line when no agent configured", () => {
const result: InstallGithubAppResult = {
...baseResult,
agentId: null,
agentUrl: null,
};
const successLines: string[] = [
"✓ GitHub Actions workflow created!",
"",
"✓ API key saved as LETTA_API_KEY secret",
];
if (result.agentId) {
successLines.push("");
successLines.push(`✓ Agent configured: ${result.agentId}`);
}
const allText = successLines.join("\n");
expect(allText).not.toContain("Agent configured");
});
test("agent URL uses correct ADE format for any agent ID", () => {
const agentId = "agent-12345678-abcd-efgh-ijkl-123456789012";
const expectedUrl = `https://app.letta.com/agents/${agentId}`;
// This mirrors the logic in installGithubApp
const agentUrl = agentId ? `https://app.letta.com/agents/${agentId}` : null;
expect(agentUrl).toBe(expectedUrl);
expect(agentUrl).toContain("app.letta.com/agents/");
expect(agentUrl).toContain(agentId);
});
});