UNPKG

@botpress/adk-cli

Version:

Command-line interface for the Botpress Agent Development Kit (ADK)

497 lines (494 loc) 16.8 kB
// @bun import { radioButtons } from "./chunk-vn4dmn3x.js"; import { adkLogo } from "./chunk-5agyx08n.js"; import { userInput } from "./chunk-tefbm840.js"; import { createBot, findProjectNameExactMatch, getBot, getCreateNewBotAction, listBots, listWorkspaces, validateBotName, validateWorkspace, writeAgentLink } from "./chunk-33cg4pm3.js"; import { ensureJsonOnlyFormat } from "./chunk-7sfagm12.js"; import { resolveLinkContext } from "./chunk-3ahwp6fe.js"; import { bind, box, fg, getActiveTheme, mount, router, signal, t, text } from "./chunk-m2h26j5f.js"; import { createCliLogger } from "./chunk-gzwt1qdr.js"; import { CLI_VERSION } from "./chunk-nxy2ya5r.js"; import { AdkError, AgentProject } from "./chunk-p0hjqn4r.js"; // src/commands/adk-link.ts async function resolveLinkCommandContext(apiUrl) { const context = await resolveLinkContext({ apiUrl }); return { project: context.project, credentials: context.credentials, apiUrl: context.apiUrl }; } function getSetupDropdownMaxItems() { const rows = process.stdout.rows ?? 24; return Math.max(3, Math.min(6, rows - 17)); } function linkSetup(renderer, scope, deps) { const { initialWorkspaceId, initialBotId, initialDevId, apiUrl, credentials, fromInit, projectName, local, onComplete } = deps; const theme = getActiveTheme(); const devId = initialDevId; const view = signal("loading"); const messages = signal([]); const selectionVersion = signal(0); let workspaces = []; let selectedWorkspace = null; let bots = []; let selectedBot = null; let matchedBot = null; let errorMsg = ""; const maxDropdownItems = getSetupDropdownMaxItems(); const bumpSelectionVersion = () => selectionVersion.set(selectionVersion() + 1); const addMessage = (m) => messages.set([...messages(), m]); const fail = (err) => { errorMsg = err instanceof Error ? err.message : String(err); view.set("error"); deps.onError?.(errorMsg); }; const saveAgentJson = async (workspaceId, botId, devBotId, botName) => { try { view.set("loading"); if (devBotId) { try { const devBot = await getBot(devBotId, workspaceId, apiUrl, credentials); if (devBot.workspaceId !== workspaceId) { throw new AdkError({ code: "INVALID_LINK_TARGET", message: `Dev bot ${devBotId} does not belong to workspace ${workspaceId}`, expected: true }); } if (!devBot.dev) { addMessage({ text: `${theme.symbols.warning} Bot ${devBotId} is not marked as a dev bot.`, color: theme.status.warning }); } } catch (err) { addMessage({ text: `${theme.symbols.warning} Could not validate dev bot ${devBotId}: ${err}`, color: theme.status.warning }); } } const project = await AgentProject.load(process.cwd()); await writeAgentLink({ project, workspaceId, botId, devBotId, botName, apiUrl, credentials, local }); view.set("done"); onComplete({ botName: selectedBot?.name, botId: selectedBot?.id, workspaceName: selectedWorkspace?.name }); } catch (err) { fail(err); } }; const loadBots = async () => { try { view.set("loading"); const botsData = await listBots(selectedWorkspace.id, apiUrl, credentials); bots = botsData; const exactMatch = findProjectNameExactMatch(botsData, projectName); if (exactMatch) { matchedBot = exactMatch; view.set("confirm-bot"); return; } view.set("bots"); } catch (err) { fail(err); } }; const handleWorkspaceSelect = (workspace) => { selectedWorkspace = workspace; bumpSelectionVersion(); loadBots(); }; const handleBotSelect = async (bot) => { selectedBot = bot; bumpSelectionVersion(); await saveAgentJson(selectedWorkspace.id, bot.id, devId, bot.name); }; const handleBotNameSubmit = async (name) => { try { view.set("loading"); const bot = await createBot({ workspaceId: selectedWorkspace.id, name, apiUrl, credentials }); selectedBot = bot; bumpSelectionVersion(); await saveAgentJson(selectedWorkspace.id, bot.id, devId, bot.name); } catch (err) { fail(err); } }; const handleCreateNewBot = () => { const action = getCreateNewBotAction({ fromInit, projectName }); if (action.type === "auto-create") { handleBotNameSubmit(action.botName); return; } if (action.message) { addMessage({ text: action.message, color: theme.status.warning }); } view.set("create-bot"); }; (async () => { try { if (initialBotId) { const bot = await getBot(initialBotId, initialWorkspaceId, apiUrl, credentials); if (bot.dev) { throw new AdkError({ code: "INVALID_LINK_TARGET", message: "Cannot link to a dev bot. Please use a production bot.", expected: true }); } if (initialWorkspaceId && bot.workspaceId !== initialWorkspaceId) { throw new AdkError({ code: "INVALID_LINK_TARGET", message: `Bot ${initialBotId} does not belong to workspace ${initialWorkspaceId}`, expected: true }); } const workspace = await validateWorkspace(bot.workspaceId, apiUrl, credentials); selectedBot = bot; selectedWorkspace = workspace; bumpSelectionVersion(); await saveAgentJson(workspace.id, bot.id, devId, bot.name); return; } if (initialWorkspaceId) { const workspace = await validateWorkspace(initialWorkspaceId, apiUrl, credentials); selectedWorkspace = workspace; bumpSelectionVersion(); await loadBots(); return; } const ws = await listWorkspaces(apiUrl, credentials); if (ws.length === 1) { selectedWorkspace = ws[0]; bumpSelectionVersion(); addMessage({ text: `Auto-linking to ${ws[0].name}`, color: theme.status.success }); await loadBots(); return; } workspaces = ws; view.set("workspaces"); } catch (err) { fail(err); } })(); const messagesBox = box(renderer, { flexDirection: "column" }); scope.add(bind(() => { for (const child of [...messagesBox.getChildren()]) { messagesBox.remove(child.id); child.destroyRecursively(); } const msgs = messages(); if (msgs.length > 0) { messagesBox.add(box(renderer, { flexDirection: "column", marginBottom: 1 }, msgs.map((m) => text(renderer, t`${fg(m.color)(m.text)}`)))); } }, [messages])); const selectionsBox = box(renderer, { flexDirection: "column" }); const renderSelectionLine = (label, value) => text(renderer, t`${fg(theme.status.success)(theme.symbols.checkmark)} ${fg(theme.text.dim)(label)} ${fg(theme.text.primary)(value)}`); scope.add(bind(() => { for (const child of [...selectionsBox.getChildren()]) { selectionsBox.remove(child.id); child.destroyRecursively(); } if (selectedWorkspace) { selectionsBox.add(renderSelectionLine("Workspace", selectedWorkspace.name ?? selectedWorkspace.id)); } if (selectedBot) { selectionsBox.add(renderSelectionLine("Bot", `${selectedBot.name} (${selectedBot.id})`)); } if (devId) { selectionsBox.add(renderSelectionLine("Dev bot", devId)); } }, [view, selectionVersion])); const body = router(renderer, scope, view, (v, viewScope) => { if (v === "loading") { return box(renderer, { flexDirection: "column" }, [text(renderer, t`${fg(theme.status.info)("Loading\u2026")}`)]); } if (v === "error") { return box(renderer, { flexDirection: "column" }, [ text(renderer, t`${fg(theme.status.error)(`${theme.symbols.cross} ${errorMsg}`)}`), text(renderer, t`${fg(theme.text.dim)(deps.onError ? "Exiting\u2026" : "Press Ctrl-C to exit.")}`) ]); } if (v === "workspaces") { const dropdownOptions = workspaces.map((ws) => ({ id: ws.id, label: `${ws.name} (${ws.handle}) - ${ws.plan || "Free"}`, value: ws.id, description: ws.handle })); return box(renderer, { flexDirection: "column" }, [ userInput(renderer, viewScope, { prompt: "Select a workspace (type to search):", placeholder: "Start typing to search workspaces...", maxDropdownItems, dropdownConfigs: [{ trigger: "", options: dropdownOptions, behavior: "submit" }], onSubmit: (value) => { const ws = workspaces.find((w) => w.id === value); if (ws) handleWorkspaceSelect(ws); }, validate: (value) => workspaces.find((w) => w.id === value) ? null : "Please select a workspace from the dropdown" }) ]); } if (v === "bots") { const norm = projectName?.toLowerCase(); const sortedBots = [...bots].sort((a, b) => { const aMatch = norm && a.name.toLowerCase() === norm ? -1 : 0; const bMatch = norm && b.name.toLowerCase() === norm ? -1 : 0; return aMatch - bMatch; }); const dropdownOptions = [ { id: "create-new", label: "Create a new bot", value: "create-new", description: "Create a new bot in this workspace" }, ...sortedBots.map((bot) => { const shortId = `${bot.id.slice(0, 4)}...${bot.id.slice(-4)}`; const createdAt = new Date(bot.createdAt).toLocaleDateString(); const isMatch = norm && bot.name.toLowerCase() === norm; return { id: bot.id, label: isMatch ? `${theme.symbols.pointer} ${bot.name}` : bot.name, value: bot.id, description: `ID: ${shortId} \u2022 Created: ${createdAt}` }; }) ]; return box(renderer, { flexDirection: "column" }, [ userInput(renderer, viewScope, { prompt: "Select a bot or create a new one (type to search):", placeholder: "Start typing to search bots...", maxDropdownItems, dropdownConfigs: [{ trigger: "", options: dropdownOptions, behavior: "submit" }], onSubmit: (value) => { if (value === "create-new") { handleCreateNewBot(); } else { const bot = bots.find((b) => b.id === value); if (bot) handleBotSelect(bot); } }, validate: (value) => value === "create-new" || bots.find((b) => b.id === value) ? null : "Please select a bot from the dropdown", onCancel: () => view.set("workspaces") }) ]); } if (v === "create-bot") { return box(renderer, { flexDirection: "column" }, [ userInput(renderer, viewScope, { prompt: "Enter a name for your new bot:", placeholder: "My awesome bot", onSubmit: (name) => void handleBotNameSubmit(name), validate: validateBotName, onCancel: () => view.set("bots") }) ]); } if (v === "confirm-bot") { const bot = matchedBot; return box(renderer, { flexDirection: "column" }, [ text(renderer, t`${fg(theme.text.primary)("Found a bot matching your project name:")}`), text(renderer, t`${fg(theme.accent.cyan)(` Name: ${bot.name}`)}`), text(renderer, t`${fg(theme.text.dim)(` ID: ${bot.id}`)}`), radioButtons(renderer, viewScope, { options: [ { id: "yes", label: "Yes, link to this bot", value: "yes" }, { id: "no", label: "No, create a new bot or link to other bot", value: "no" } ], onSubmit: (value) => { if (value === "yes") handleBotSelect(matchedBot); else view.set("bots"); }, onCancel: () => view.set("bots") }) ]); } const doneChildren = [ text(renderer, t`${fg(theme.status.success)(`${theme.symbols.checkmark} Successfully linked local agent to remote bot!`)}`), text(renderer, t`${fg(theme.text.dim)(`Bot: ${selectedBot?.name} (${selectedBot?.id})`)}`), text(renderer, t`${fg(theme.text.dim)(`Workspace: ${selectedWorkspace?.name}`)}`) ]; if (devId) doneChildren.push(text(renderer, t`${fg(theme.text.dim)(`Dev Bot ID: ${devId}`)}`)); if (!fromInit) { doneChildren.push(box(renderer, { marginTop: 1 }, [ text(renderer, t`${fg(theme.status.info)("You can now use 'adk deploy' and other remote operations.")}`) ])); } return box(renderer, { flexDirection: "column" }, doneChildren); }); const children = []; if (!fromInit) { children.push(box(renderer, { paddingX: 1, paddingY: 1 }, [ adkLogo(renderer, { title: "Botpress ADK", subtitle: `v${CLI_VERSION} \u2022 Link project`, logoColor: theme.accent.purple, titleColor: theme.text.primary, subtitleColor: theme.text.dim }) ])); } children.push(box(renderer, { paddingX: 1, flexDirection: "column" }, [selectionsBox]), box(renderer, { paddingX: 1, flexDirection: "column" }, [messagesBox]), box(renderer, { marginTop: 1, paddingX: 1, flexDirection: "column" }, [body])); return box(renderer, { flexDirection: "column" }, children); } async function adkLink(options) { ensureJsonOnlyFormat(options.format); const logger = createCliLogger({ format: options.format }); const isJson = options.format === "json"; if (isJson) { try { if (!options.workspace || !options.bot) { throw new AdkError({ code: "INVALID_LINK_TARGET", message: "--format json requires --workspace and --bot flags", expected: true }); } const { project, credentials, apiUrl: resolvedApiUrl } = await resolveLinkCommandContext(options.apiUrl); const bot = await getBot(options.bot, options.workspace, resolvedApiUrl, credentials); if (bot.dev) { throw new AdkError({ code: "INVALID_LINK_TARGET", message: "Cannot link to a dev bot. Please use a production bot.", expected: true }); } const workspace = await validateWorkspace(options.workspace, resolvedApiUrl, credentials); await writeAgentLink({ project, workspaceId: options.workspace, botId: options.bot, devBotId: options.dev, botName: bot.name, apiUrl: options.apiUrl, credentials, local: options.local }); logger.info(`Linked to bot ${options.bot} in workspace ${workspace.id}`).result({ success: true, botId: options.bot, workspaceId: workspace.id }); } catch (error) { logger.fatal(error); } return; } try { const { project, credentials: linkCredentials, apiUrl: resolvedApiUrl } = await resolveLinkCommandContext(options.apiUrl); if (!options.force && !options.local) { if (project.agentInfo?.botId) { logger.warn("\u26A0\uFE0F agent.json already exists:"); logger.info(` Bot ID: ${project.agentInfo.botId}`, "gray"); logger.info(` Workspace ID: ${project.agentInfo.workspaceId}`, "gray"); logger.info("\uD83D\uDCA1 Use --force to overwrite, or delete agent.json manually.", "blue"); return; } } let appRef = null; let linkResult; let interactiveError; const app = await mount((renderer, scope) => linkSetup(renderer, scope, { initialWorkspaceId: options.workspace, initialBotId: options.bot, initialDevId: options.dev, apiUrl: resolvedApiUrl, credentials: linkCredentials, fromInit: options.fromInit, projectName: options.projectName, local: options.local, onComplete: (result) => { linkResult = result; setTimeout(() => appRef?.unmount(), 120); }, onError: (message) => { if (!interactiveError) { interactiveError = message; } setTimeout(() => appRef?.unmount(), 160); } }), { exitOnCtrlC: true, screenMode: "alternate-screen" }); appRef = app; await app.waitUntilExit(); if (interactiveError) { logger.error(`Error: ${interactiveError}`); process.exitCode = 1; return; } if (linkResult?.botId) { logger.info(`Linked to bot ${linkResult.botName ?? linkResult.botId} (${linkResult.botId})`); } } catch (error) { logger.fatal(error); } } export { linkSetup, adkLink };