@botpress/adk-cli
Version:
Command-line interface for the Botpress Agent Development Kit (ADK)
497 lines (494 loc) • 16.8 kB
JavaScript
// @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 };