diff --git a/README.md b/README.md index 35a47fbe9..83d00ace3 100644 --- a/README.md +++ b/README.md @@ -30,19 +30,17 @@ cd my-bot npx @lobu/cli@latest run ``` -## Starter Skills +## Agent configuration -Install the Lobu starter skill into any local `skills/` directory: +Runtime configuration is managed through the web app or the same org-scoped REST API used by the CLI: ```bash -npx @lobu/cli@latest skills add lobu +npx @lobu/cli@latest login +npx @lobu/cli@latest org set my-org +npx @lobu/cli@latest agent list ``` -The bundled Lobu starter skill includes memory guidance. Configure local MCP clients when needed: - -```bash -npx @lobu/cli@latest memory init -``` +Local `lobu.toml` projects are still useful for `lobu validate` and `lobu apply` workflows. ### Deployment diff --git a/packages/cli/README.md b/packages/cli/README.md index 9278fe1b5..384cf001a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @lobu/cli -CLI tool for initializing and managing Lobu projects. +CLI tool for running Lobu locally and managing Lobu agents through the same REST API as the web app. ## Quick Start @@ -13,21 +13,6 @@ npx @lobu/cli@latest run Lobu boots as a single Node process. Postgres is a user-provided external (managed instance or local — `brew services start postgresql`). -## Starter Skills - -Install the Lobu starter skill into a local `skills/` directory: - -```bash -npx @lobu/cli@latest skills list -npx @lobu/cli@latest skills add lobu -``` - -The bundled Lobu starter skill includes memory guidance. Configure local MCP clients when needed: - -```bash -npx @lobu/cli@latest memory init -``` - ## Commands ### `lobu init [name]` @@ -36,7 +21,6 @@ Scaffold a new Lobu project with interactive prompts: - **Project name** - **Gateway port** and optional **public URL** (for OAuth callbacks) -- **Admin password** - **Worker network access** (isolated, allowlist, or unrestricted) - **AI provider** selection from the bundled provider registry + API key - **Messaging platform** (Telegram, Slack, Discord, WhatsApp, Teams, Google Chat, or none) @@ -54,7 +38,7 @@ For a custom Owletto deployment, `.env` keeps `MEMORY_URL` as the optional base ### `lobu run` -Boot the embedded Lobu stack — gateway + workers + embeddings + Owletto memory backend in a single Node process. Validates `lobu.toml` and that `DATABASE_URL` is set in `.env`, then spawns the bundled `@lobu/owletto-backend/dist/server.bundle.mjs`. Ctrl+C stops the process and any spawned worker subprocesses cleanly. +Boot the embedded Lobu stack — gateway + workers + embeddings + Owletto memory backend in a single Node process. `lobu.toml` is not required; set `DATABASE_URL` in the environment or `.env`, then the command spawns the bundled `@lobu/owletto-backend/dist/server.bundle.mjs`. Ctrl+C stops the process and any spawned worker subprocesses cleanly. ## License diff --git a/packages/cli/src/__tests__/skills.test.ts b/packages/cli/src/__tests__/skills.test.ts deleted file mode 100644 index 85c7b9a52..000000000 --- a/packages/cli/src/__tests__/skills.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - installBundledSkill, - listBundledSkills, -} from "../commands/skills/registry.js"; - -describe("bundled starter skills", () => { - const tempDirs: string[] = []; - - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) rmSync(dir, { recursive: true, force: true }); - } - }); - - test("lists the public Lobu starter skill", () => { - const skills = listBundledSkills(); - expect(skills.map((skill) => skill.id)).toContain("lobu"); - - const lobu = skills.find((skill) => skill.id === "lobu"); - expect(lobu?.files).toContain("SKILL.md"); - expect(lobu?.description.length).toBeGreaterThan(0); - }); - - test("installs the Lobu starter skill into skills/", () => { - const cwd = mkdtempSync(join(tmpdir(), "lobu-skill-install-")); - tempDirs.push(cwd); - - const { destinationDir } = installBundledSkill("lobu", cwd); - - expect(destinationDir).toBe(join(cwd, "skills", "lobu")); - expect(existsSync(join(destinationDir, "SKILL.md"))).toBe(true); - - const content = readFileSync(join(destinationDir, "SKILL.md"), "utf-8"); - expect(content).toContain("# Lobu"); - expect(content).toContain("Owletto"); - }); -}); diff --git a/packages/cli/src/commands/_lib/apply/apply-cmd.ts b/packages/cli/src/commands/_lib/apply/apply-cmd.ts index bb05e8c30..d4183d343 100644 --- a/packages/cli/src/commands/_lib/apply/apply-cmd.ts +++ b/packages/cli/src/commands/_lib/apply/apply-cmd.ts @@ -24,7 +24,6 @@ export interface ApplyOptions { only?: "agents" | "memory"; org?: string; url?: string; - storePath?: string; /** Test seam — inject a stubbed fetch. */ fetchImpl?: typeof fetch; } @@ -202,7 +201,6 @@ export async function applyCommand(opts: ApplyOptions = {}): Promise { const { client, orgSlug } = await resolveApplyClient({ url: opts.url, org: opts.org, - storePath: opts.storePath, fetchImpl: opts.fetchImpl, }); printText(chalk.dim(`Org: ${orgSlug}`)); diff --git a/packages/cli/src/commands/_lib/apply/client.ts b/packages/cli/src/commands/_lib/apply/client.ts index 41cc05174..09da0df44 100644 --- a/packages/cli/src/commands/_lib/apply/client.ts +++ b/packages/cli/src/commands/_lib/apply/client.ts @@ -1,13 +1,6 @@ import type { AgentSettings } from "@lobu/core"; -import { ApiError, ValidationError } from "../../memory/_lib/errors.js"; -import { - getSessionForOrg, - getUsableToken, - mcpUrlForOrg, - orgFromMcpUrl, - resolveOrg, - resolveServerUrl, -} from "../../memory/_lib/openclaw-auth.js"; +import { resolveApiClient } from "../../../internal/index.js"; +import { ApiError } from "../../memory/_lib/errors.js"; // ── Wire types ───────────────────────────────────────────────────────────── @@ -100,61 +93,6 @@ async function parseResponseBody( } } -// ── Auth resolver — same shape as seed-cmd.ts (PR #459) ──────────────────── - -async function resolveAuth( - urlFlag?: string, - orgFlag?: string, - storePath?: string -): Promise<{ token: string; mcpUrl: string; orgSlug: string }> { - const org = resolveOrg(orgFlag); - if (org) { - const orgSession = getSessionForOrg(org, storePath, urlFlag); - if (orgSession) { - const result = await getUsableToken(orgSession.key, storePath); - if (result) { - return { token: result.token, mcpUrl: orgSession.key, orgSlug: org }; - } - } - const serverUrl = resolveServerUrl(urlFlag, storePath); - if (serverUrl) { - const orgUrl = mcpUrlForOrg(serverUrl, org); - const result = await getUsableToken(orgUrl, storePath); - if (result) { - return { token: result.token, mcpUrl: orgUrl, orgSlug: org }; - } - } - throw new ValidationError("Not logged in. Run: lobu login"); - } - - const serverUrl = resolveServerUrl(urlFlag, storePath); - const result = await getUsableToken(serverUrl || undefined, storePath); - if (!result) { - throw new ValidationError("Not logged in. Run: lobu login"); - } - const resolvedOrg = - orgFromMcpUrl(result.session.mcpUrl) || result.session.org; - if (!resolvedOrg) { - throw new ValidationError( - "Cannot determine org. Use --org or set LOBU_MEMORY_ORG." - ); - } - return { - token: result.token, - mcpUrl: result.session.mcpUrl, - orgSlug: resolvedOrg, - }; -} - -/** Strip the path off an MCP URL to reach the API root. */ -export function deriveApiBaseUrl(mcpUrl: string): string { - const url = new URL(mcpUrl); - url.pathname = ""; - url.search = ""; - url.hash = ""; - return url.toString().replace(/\/+$/, ""); -} - // ── Client ───────────────────────────────────────────────────────────────── export interface ApplyClientConfig { @@ -264,7 +202,7 @@ export class ApplyClient { ): Promise { await this.request( "PATCH", - `/api/${this.orgSlug}/agents/${agentId}`, + `/api/${this.orgSlug}/agents/${encodeURIComponent(agentId)}`, agent ); } @@ -273,7 +211,7 @@ export class ApplyClient { try { const { body } = await this.request( "GET", - `/api/${this.orgSlug}/agents/${agentId}/config` + `/api/${this.orgSlug}/agents/${encodeURIComponent(agentId)}/config` ); return body; } catch (err) { @@ -288,7 +226,7 @@ export class ApplyClient { ): Promise { await this.request( "PATCH", - `/api/${this.orgSlug}/agents/${agentId}/config`, + `/api/${this.orgSlug}/agents/${encodeURIComponent(agentId)}/config`, settings ); } @@ -298,7 +236,7 @@ export class ApplyClient { async listPlatforms(agentId: string): Promise { const { body } = await this.request<{ platforms?: RemotePlatform[] }>( "GET", - `/api/${this.orgSlug}/agents/${agentId}/platforms` + `/api/${this.orgSlug}/agents/${encodeURIComponent(agentId)}/platforms` ); return body.platforms ?? []; } @@ -320,7 +258,7 @@ export class ApplyClient { ): Promise { const { body } = await this.request( "PUT", - `/api/${this.orgSlug}/agents/${agentId}/platforms/by-stable-id/${encodeURIComponent(stableId)}`, + `/api/${this.orgSlug}/agents/${encodeURIComponent(agentId)}/platforms/by-stable-id/${encodeURIComponent(stableId)}`, payload ); return body; @@ -471,24 +409,21 @@ export interface ResolvedClient { client: ApplyClient; apiBaseUrl: string; orgSlug: string; - mcpUrl: string; } export async function resolveApplyClient(opts: { url?: string; org?: string; - storePath?: string; fetchImpl?: typeof fetch; }): Promise { - const { token, mcpUrl, orgSlug } = await resolveAuth( - opts.url, - opts.org, - opts.storePath - ); - const apiBaseUrl = deriveApiBaseUrl(mcpUrl); + const { token, apiBaseUrl, orgSlug } = await resolveApiClient({ + org: opts.org, + apiUrl: opts.url, + fetchImpl: opts.fetchImpl, + }); const client = new ApplyClient( { apiBaseUrl, orgSlug, token }, opts.fetchImpl ); - return { client, apiBaseUrl, orgSlug, mcpUrl }; + return { client, apiBaseUrl, orgSlug }; } diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts new file mode 100644 index 000000000..d34339f3d --- /dev/null +++ b/packages/cli/src/commands/agent.ts @@ -0,0 +1,183 @@ +import { readFile, writeFile } from "node:fs/promises"; +import chalk from "chalk"; +import { resolveApiClient } from "../internal/index.js"; + +interface AgentCommandOptions { + context?: string; + org?: string; + json?: boolean; +} + +interface AgentItem { + agentId: string; + name: string; + description?: string; + connectionCount?: number; + activeConnectionCount?: number; + clientCount?: number; + status?: string; +} + +export async function agentListCommand( + options: AgentCommandOptions = {} +): Promise { + const { client, orgSlug } = await resolveApiClient(options); + const data = await client.get<{ agents?: AgentItem[] }>( + `/api/${orgSlug}/agents` + ); + const agents = data.agents ?? []; + + if (options.json) { + printJson(agents); + return; + } + + if (agents.length === 0) { + console.log(chalk.dim(`\n No agents in org ${orgSlug}.\n`)); + return; + } + + console.log(chalk.bold(`\n Agents in ${orgSlug}`)); + for (const agent of agents) { + const status = agent.status ? chalk.dim(` ${agent.status}`) : ""; + const description = agent.description + ? chalk.dim(` — ${agent.description}`) + : ""; + const counts = chalk.dim( + `connections:${agent.connectionCount ?? 0} active:${agent.activeConnectionCount ?? 0} clients:${agent.clientCount ?? 0}` + ); + console.log( + ` ${chalk.green("●")} ${chalk.bold(agent.agentId)} ${counts}${status}${description}` + ); + } + console.log(); +} + +export async function agentGetCommand( + agentId: string, + options: AgentCommandOptions = {} +): Promise { + const { client, orgSlug } = await resolveApiClient(options); + const agent = await client.get( + `/api/${orgSlug}/agents/${encodeURIComponent(agentId)}` + ); + printJson(agent); +} + +export async function agentCreateCommand( + agentId: string, + options: AgentCommandOptions & { name?: string; description?: string } = {} +): Promise { + const { client, orgSlug } = await resolveApiClient(options); + const name = options.name?.trim() || agentId; + const agent = await client.post(`/api/${orgSlug}/agents`, { + agentId, + name, + ...(options.description ? { description: options.description } : {}), + }); + + if (options.json) { + printJson(agent); + return; + } + console.log(chalk.green(`\n Created agent ${agentId} in ${orgSlug}.\n`)); +} + +export async function agentUpdateCommand( + agentId: string, + options: AgentCommandOptions & { name?: string; description?: string } = {} +): Promise { + const updates: { name?: string; description?: string } = {}; + if (options.name !== undefined) updates.name = options.name; + if (options.description !== undefined) + updates.description = options.description; + if (Object.keys(updates).length === 0) { + console.error( + chalk.red("\n Pass at least one of --name or --description.\n") + ); + process.exit(1); + } + + const { client, orgSlug } = await resolveApiClient(options); + const result = await client.patch( + `/api/${orgSlug}/agents/${encodeURIComponent(agentId)}`, + updates + ); + + if (options.json) { + printJson(result); + return; + } + console.log(chalk.green(`\n Updated agent ${agentId}.\n`)); +} + +export async function agentDeleteCommand( + agentId: string, + options: AgentCommandOptions & { yes?: boolean } = {} +): Promise { + if (!options.yes) { + console.error(chalk.red("\n Refusing to delete without --yes.\n")); + process.exit(1); + } + const { client, orgSlug } = await resolveApiClient(options); + await client.delete(`/api/${orgSlug}/agents/${encodeURIComponent(agentId)}`); + console.log(chalk.green(`\n Deleted agent ${agentId}.\n`)); +} + +export async function agentConfigGetCommand( + agentId: string, + options: AgentCommandOptions & { output?: string } = {} +): Promise { + const { client, orgSlug } = await resolveApiClient(options); + const config = await client.get( + `/api/${orgSlug}/agents/${encodeURIComponent(agentId)}/config` + ); + + const json = `${JSON.stringify(config, null, 2)}\n`; + if (options.output) { + await writeFile(options.output, json); + console.log(chalk.green(`\n Wrote ${options.output}\n`)); + return; + } + process.stdout.write(json); +} + +export async function agentConfigPatchCommand( + agentId: string, + options: AgentCommandOptions & { file: string; json?: boolean } +): Promise { + if (!options.file) throw new Error("--file is required."); + const raw = await readFile(options.file, "utf-8"); + let updates: unknown; + try { + updates = JSON.parse(raw) as unknown; + } catch (error) { + console.error( + chalk.red( + `\n Failed to parse ${options.file}: ${error instanceof Error ? error.message : String(error)}\n` + ) + ); + process.exit(1); + } + if (!updates || typeof updates !== "object" || Array.isArray(updates)) { + console.error( + chalk.red("\n Config patch file must contain a JSON object.\n") + ); + process.exit(1); + } + const { client, orgSlug } = await resolveApiClient(options); + const result = await client.patch( + `/api/${orgSlug}/agents/${encodeURIComponent(agentId)}/config`, + updates + ); + + if (options.json) { + printJson(result); + return; + } + console.log(chalk.green(`\n Updated config for ${agentId}.\n`)); +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 3289fa733..22d4faef0 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -1,6 +1,7 @@ import { createInterface } from "node:readline"; import chalk from "chalk"; import { + apiBaseFromContextUrl, getToken, resolveContext, resolveGatewayUrl, @@ -34,18 +35,17 @@ export async function chatCommand( gatewayUrl = options.gateway; } else if (options.context) { const ctx = await resolveContext(options.context); - gatewayUrl = ctx.apiUrl; + gatewayUrl = apiBaseFromContextUrl(ctx.apiUrl); } else { gatewayUrl = await resolveGatewayUrl({ cwd }); } gatewayUrl = gatewayUrl.replace(/\/$/, ""); - const authToken = - (await getToken(options.context)) ?? process.env.ADMIN_PASSWORD; + const authToken = await getToken(options.context); if (!authToken) { console.error( chalk.red( - "\n Session expired or not logged in. Run `npx @lobu/cli@latest login` or set ADMIN_PASSWORD.\n" + "\n Session expired or not logged in. Run `npx @lobu/cli@latest login`.\n" ) ); process.exit(1); @@ -203,7 +203,7 @@ async function sendViaApi( if (createRes.status === 401) { console.error( chalk.red( - "\n Authentication required. Run `npx @lobu/cli@latest login` or set ADMIN_PASSWORD.\n" + "\n Authentication required. Run `npx @lobu/cli@latest login`.\n" ) ); process.exit(1); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index c490de729..5715fcad4 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -6,7 +6,6 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import chalk from "chalk"; import ora from "ora"; -import { isLoadError, loadConfig } from "../config/loader.js"; import { parseEnvContent } from "../internal/index.js"; /** @@ -22,38 +21,22 @@ export async function devCommand( cwd: string, passthroughArgs: string[] ): Promise { - const result = await loadConfig(cwd); - - if (isLoadError(result)) { - console.error(chalk.red(`\n ${result.error}`)); - if (result.details) { - for (const detail of result.details) { - console.error(chalk.dim(` ${detail}`)); - } - } - console.log(); - process.exit(1); - } - - const { config } = result; const spinner = ora("Validating environment...").start(); const envPath = join(cwd, ".env"); - let envContent = ""; + let envVars: Record = {}; try { - envContent = await readFile(envPath, "utf-8"); + envVars = parseEnvContent(await readFile(envPath, "utf-8")); } catch { - spinner.fail(".env not found"); - console.error( - chalk.red(`\n No .env file at ${envPath}. Run \`lobu init\` first.\n`) - ); - process.exit(1); + envVars = {}; } - const envVars = parseEnvContent(envContent); - if (!envVars.DATABASE_URL) { + const mergedEnv = { ...envVars, ...(process.env as Record) }; + if (!mergedEnv.DATABASE_URL) { spinner.fail("DATABASE_URL is missing"); - console.error(chalk.red(`\n Set the following in .env:\n`)); + console.error( + chalk.red(`\n Set the following in your environment or .env:\n`) + ); console.error(chalk.dim(` DATABASE_URL=`)); console.error( chalk.dim( @@ -93,26 +76,24 @@ export async function devCommand( process.exit(1); } - const agentCount = Object.keys(config.agents).length; - spinner.succeed(`Loaded ${agentCount} agent(s) from lobu.toml`); + spinner.succeed("Environment ready"); - const port = envVars.GATEWAY_PORT || envVars.PORT || "8787"; + const port = mergedEnv.GATEWAY_PORT || mergedEnv.PORT || "8787"; const gatewayUrl = `http://localhost:${port}`; console.log(chalk.cyan(`\n Starting Lobu...\n`)); console.log(chalk.dim(` bundle: ${bundlePath}`)); console.log( - chalk.dim(` database: ${redactUrl(envVars.DATABASE_URL!)}`) + chalk.dim(` database: ${redactUrl(mergedEnv.DATABASE_URL!)}`) ); console.log(chalk.dim(` api docs: ${gatewayUrl}/api/docs`)); console.log(); // Pass-through env: process.env wins so users can override per-invocation, - // .env values fill in the rest. LOBU_DEV_PROJECT_PATH points the gateway at - // this project so it loads lobu.toml and agent files. + // .env values fill in the rest. LOBU_DEV_PROJECT_PATH is optional and only + // used by file-first local workflows that still have a lobu.toml. const childEnv: Record = { - ...envVars, - ...(process.env as Record), + ...mergedEnv, LOBU_DEV_PROJECT_PATH: process.env.LOBU_DEV_PROJECT_PATH || envVars.LOBU_DEV_PROJECT_PATH || cwd, PORT: port, diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 07e1af491..dbb62aab4 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -68,7 +68,7 @@ export async function doctorCommand( checks.push(checkNodeVersion()); checks.push(checkBinaryExists("git")); - const serverUrl = resolveServerUrl(); + const serverUrl = await resolveServerUrl(); if (serverUrl) checks.push(await checkServerReachable(serverUrl)); const icons = { diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index 365550b33..99ea2868d 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -2,7 +2,12 @@ import { readFile } from "node:fs/promises"; import { basename, join } from "node:path"; import chalk from "chalk"; import { parse as parseYaml } from "yaml"; -import { getToken, resolveGatewayUrl } from "../internal/index.js"; +import { + apiBaseFromContextUrl, + getToken, + resolveContext, + resolveGatewayUrl, +} from "../internal/index.js"; import { isLoadError, loadConfig } from "../config/loader.js"; import { CURRENT_EVAL_VERSION, evalDefinitionSchema } from "../eval/types.js"; import type { EvalDefinition, EvalReport, EvalResult } from "../eval/types.js"; @@ -25,6 +30,7 @@ export async function evalCommand( list?: boolean; ci?: boolean; output?: string; + context?: string; } ): Promise { // Load config first (needed for --list and running) @@ -108,15 +114,16 @@ export async function evalCommand( // Auth and gateway required from here (not needed for --list) const gatewayUrl = ( - options.gateway ?? (await resolveGatewayUrl({ cwd })) + options.gateway ?? + (options.context + ? apiBaseFromContextUrl((await resolveContext(options.context)).apiUrl) + : await resolveGatewayUrl({ cwd })) ).replace(/\/$/, ""); - const authToken = (await getToken()) ?? process.env.ADMIN_PASSWORD; + const authToken = await getToken(options.context); if (!authToken) { console.error( - chalk.red( - "\n Authentication required. Run `lobu login` or set ADMIN_PASSWORD.\n" - ) + chalk.red("\n Authentication required. Run `lobu login`.\n") ); process.exit(1); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9ae9db887..e28032cc0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -6,7 +6,7 @@ import { confirm, input, password, select } from "@inquirer/prompts"; import chalk from "chalk"; import ora from "ora"; import { promptPlatformConfig } from "../commands/platforms/platform-prompts.js"; -import { secretsSetCommand } from "../commands/secrets.js"; +import { setLocalEnvValue } from "../internal/local-env.js"; import { getProviderById, loadProviderRegistry, @@ -68,7 +68,7 @@ export async function initCommand( // Gateway port selection const gatewayPort = await input({ message: "Gateway port?", - default: "8080", + default: "8787", validate: (value: string) => { const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) { @@ -85,18 +85,6 @@ export async function initCommand( default: "", }); - // Admin password - const adminPassword = await password({ - message: "Admin password?", - mask: true, - validate: (value: string) => { - if (!value || value.length < 4) { - return "Password must be at least 4 characters"; - } - return true; - }, - }); - // Worker network access policy const networkPolicy = await select<"restricted" | "open" | "isolated">({ message: "Worker network access?", @@ -297,7 +285,6 @@ export async function initCommand( const variables = { PROJECT_NAME: projectName, CLI_VERSION: cliVersion, - ADMIN_PASSWORD: adminPassword, ENCRYPTION_KEY: answers.encryptionKey, GATEWAY_PORT: gatewayPort, WORKER_ALLOWED_DOMAINS: answers.allowedDomains, @@ -309,7 +296,7 @@ export async function initCommand( // Save public gateway URL if explicitly set if (publicGatewayUrl) { - await secretsSetCommand( + await setLocalEnvValue( projectDir, "PUBLIC_GATEWAY_URL", publicGatewayUrl @@ -318,7 +305,7 @@ export async function initCommand( // Save provider API key to .env if (providerApiKey && selectedProvider?.providers?.[0]?.envVarName) { - await secretsSetCommand( + await setLocalEnvValue( projectDir, selectedProvider.providers[0].envVarName, providerApiKey @@ -327,12 +314,12 @@ export async function initCommand( // Save platform secrets to .env for (const secret of platformSecrets) { - await secretsSetCommand(projectDir, secret.envVar, secret.value); + await setLocalEnvValue(projectDir, secret.envVar, secret.value); } // Save OAuth secrets to .env for (const secret of envSecrets) { - await secretsSetCommand(projectDir, secret.envVar, secret.value); + await setLocalEnvValue(projectDir, secret.envVar, secret.value); } // Create .gitignore diff --git a/packages/cli/src/commands/memory/_lib/browser-auth-cmd.ts b/packages/cli/src/commands/memory/_lib/browser-auth-cmd.ts index 938216722..95f54e0cd 100644 --- a/packages/cli/src/commands/memory/_lib/browser-auth-cmd.ts +++ b/packages/cli/src/commands/memory/_lib/browser-auth-cmd.ts @@ -462,7 +462,7 @@ async function resolveConnectorDomains( } const { resolveMcpEndpoint, restToolCall } = await import("./mcp.js"); - const mcpUrl = resolveMcpEndpoint(cliProfile.config); + const mcpUrl = await resolveMcpEndpoint(cliProfile.config); if (!mcpUrl) { printText( "No MCP URL configured. Use --domains to specify cookie domains manually." @@ -521,7 +521,7 @@ export async function captureBrowserAuth( return; } const { resolveMcpEndpoint, restToolCall } = await import("./mcp.js"); - const mcpUrl = resolveMcpEndpoint(cliProfile.config); + const mcpUrl = await resolveMcpEndpoint(cliProfile.config); if (!mcpUrl) { printText("No MCP URL configured."); process.exitCode = 1; @@ -647,7 +647,7 @@ export async function captureBrowserAuth( if (args.authProfileSlug) { const { resolveMcpEndpoint, restToolCall } = await import("./mcp.js"); - const mcpUrl = resolveMcpEndpoint(cliProfile.config); + const mcpUrl = await resolveMcpEndpoint(cliProfile.config); if (!mcpUrl) { printText( @@ -797,7 +797,7 @@ export async function captureBrowserAuth( printText("Saving cookies to auth profile..."); const { resolveMcpEndpoint, restToolCall } = await import("./mcp.js"); - const mcpUrl = resolveMcpEndpoint(cliProfile.config); + const mcpUrl = await resolveMcpEndpoint(cliProfile.config); if (!mcpUrl) { printText("No MCP URL configured. Store cookies manually."); diff --git a/packages/cli/src/commands/memory/_lib/mcp.ts b/packages/cli/src/commands/memory/_lib/mcp.ts index 763d91e01..d9da405a9 100644 --- a/packages/cli/src/commands/memory/_lib/mcp.ts +++ b/packages/cli/src/commands/memory/_lib/mcp.ts @@ -78,11 +78,11 @@ type JsonRpcResponse = { * Resolve the MCP endpoint URL. * Priority: explicit config > LOBU_MEMORY_URL env > saved memory server > default cloud server. */ -export function resolveMcpEndpoint(config?: { +export async function resolveMcpEndpoint(config?: { mcpUrl?: unknown; url?: unknown; apiUrl?: unknown; -}): string | null { +}): Promise { // Explicit config should win over ambient auth/session state so callers can // deterministically target a specific server. if (config) { diff --git a/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts b/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts index 1d0545c2f..f8cb70999 100644 --- a/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts +++ b/packages/cli/src/commands/memory/_lib/openclaw-auth.test.ts @@ -2,8 +2,12 @@ import { describe, expect, test } from "bun:test"; import { getSessionForOrg } from "./openclaw-auth.js"; describe("memory auth URL resolution", () => { - test("getSessionForOrg honors an explicit --url", () => { - const session = getSessionForOrg("dev", undefined, "http://localhost:8801"); + test("getSessionForOrg honors an explicit --url", async () => { + const session = await getSessionForOrg( + "dev", + undefined, + "http://localhost:8801" + ); expect(session?.key).toBe("http://localhost:8801/mcp/dev"); }); }); diff --git a/packages/cli/src/commands/memory/_lib/openclaw-auth.ts b/packages/cli/src/commands/memory/_lib/openclaw-auth.ts index 528a9d03f..7f0e708b3 100644 --- a/packages/cli/src/commands/memory/_lib/openclaw-auth.ts +++ b/packages/cli/src/commands/memory/_lib/openclaw-auth.ts @@ -1,7 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { getToken } from "../../../internal/index.js"; +import { + findContextByMemoryUrl, + getActiveOrg, + getMemoryUrl, + getToken, + resolveContext, + setActiveOrg as setContextActiveOrg, + setMemoryUrl as setContextMemoryUrl, +} from "../../../internal/index.js"; export interface MemorySession { mcpUrl: string; @@ -10,19 +15,6 @@ export interface MemorySession { updatedAt?: string; } -interface MemoryPreferences { - version: 1; - mcpUrl?: string; - activeOrg?: string; -} - -const DEFAULT_MCP_URL = "https://lobu.ai/mcp"; -const DEFAULT_STORE_PATH = resolve(homedir(), ".config", "lobu", "memory.json"); - -function defaultPreferences(): MemoryPreferences { - return { version: 1 }; -} - export function normalizeMcpUrl(input: string): string { const url = new URL(input); url.hash = ""; @@ -62,66 +54,20 @@ export function orgFromMcpUrl(mcpUrl: string): string | null { } } -function getMemoryPreferencesPath(customPath?: string): string { - return customPath ? resolve(customPath) : DEFAULT_STORE_PATH; -} - -function loadMemoryPreferences(storePath?: string): MemoryPreferences { - const path = getMemoryPreferencesPath(storePath); - if (!existsSync(path)) return defaultPreferences(); - try { - const parsed = JSON.parse( - readFileSync(path, "utf-8") - ) as Partial; - return { - version: 1, - mcpUrl: - typeof parsed.mcpUrl === "string" - ? normalizeMcpUrl(parsed.mcpUrl) - : undefined, - activeOrg: - typeof parsed.activeOrg === "string" ? parsed.activeOrg : undefined, - }; - } catch { - return defaultPreferences(); - } -} - -function saveMemoryPreferences(store: MemoryPreferences, storePath?: string) { - const path = getMemoryPreferencesPath(storePath); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); -} - -function validateOrgSlug(orgSlug: string) { - if (!/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/i.test(orgSlug)) { - throw new Error( - `Invalid organization slug "${orgSlug}". Slugs may only contain alphanumeric characters, hyphens, and underscores.` - ); - } -} - -export function setActiveOrg(orgSlug: string, storePath?: string) { - validateOrgSlug(orgSlug); - const store = loadMemoryPreferences(storePath); - store.activeOrg = orgSlug; - saveMemoryPreferences(store, storePath); +export async function setActiveOrg(orgSlug: string, context?: string) { + await setContextActiveOrg(orgSlug, context); } -export function setActiveMcpUrl(mcpUrl: string, storePath?: string) { - const store = loadMemoryPreferences(storePath); - store.mcpUrl = baseMcpUrl(mcpUrl); - saveMemoryPreferences(store, storePath); +export async function setActiveMcpUrl(mcpUrl: string, context?: string) { + await setContextMemoryUrl(mcpUrl, context); } -export function getActiveSession(storePath?: string): { +export async function getActiveSession(context?: string): Promise<{ session: MemorySession | null; key: string | null; - path: string; -} { - const path = getMemoryPreferencesPath(storePath); - const base = resolveServerUrl(undefined, storePath) ?? DEFAULT_MCP_URL; - const org = resolveOrg(undefined, undefined, storePath); +}> { + const base = await resolveServerUrl(undefined, context); + const org = await resolveOrg(undefined, undefined, context); const key = org ? mcpUrlForOrg(base, org) : base; return { session: { @@ -131,18 +77,15 @@ export function getActiveSession(storePath?: string): { updatedAt: new Date().toISOString(), }, key, - path, }; } -export function getSessionForOrg( +export async function getSessionForOrg( orgSlug: string, - storePath?: string, + context?: string, urlFlag?: string -): { session: MemorySession; key: string; path: string } | null { - validateOrgSlug(orgSlug); - const path = getMemoryPreferencesPath(storePath); - const base = resolveServerUrl(urlFlag, storePath) ?? DEFAULT_MCP_URL; +): Promise<{ session: MemorySession; key: string } | null> { + const base = await resolveServerUrl(urlFlag, context); const key = mcpUrlForOrg(base, orgSlug); return { session: { @@ -152,38 +95,34 @@ export function getSessionForOrg( updatedAt: new Date().toISOString(), }, key, - path, }; } /** * Resolve which server URL to use. - * Priority: explicit url arg > LOBU_MEMORY_URL > local preference > cloud default. + * Priority: explicit url arg > LOBU_MEMORY_URL > context preference > cloud default. */ -export function resolveServerUrl( +export async function resolveServerUrl( urlFlag?: string, - storePath?: string -): string | null { + context?: string +): Promise { if (urlFlag) return normalizeMcpUrl(urlFlag); - if (process.env.LOBU_MEMORY_URL) - return normalizeMcpUrl(process.env.LOBU_MEMORY_URL); - const prefs = loadMemoryPreferences(storePath); - return prefs.mcpUrl ?? DEFAULT_MCP_URL; + return normalizeMcpUrl(await getMemoryUrl(context)); } /** * Resolve which org to use. - * Priority: explicit org arg > LOBU_MEMORY_ORG > session > local preference. + * Priority: explicit org arg > LOBU_MEMORY_ORG > session > context preference. */ -export function resolveOrg( +export async function resolveOrg( orgFlag?: string, session?: MemorySession | null, - storePath?: string -): string | undefined { + context?: string +): Promise { if (orgFlag) return orgFlag; if (process.env.LOBU_MEMORY_ORG) return process.env.LOBU_MEMORY_ORG; if (session?.org) return session.org; - return loadMemoryPreferences(storePath).activeOrg; + return getActiveOrg(context); } /** @@ -191,20 +130,38 @@ export function resolveOrg( */ export async function getUsableToken( mcpUrl?: string, - storePath?: string + contextName?: string ): Promise<{ token: string; session: MemorySession; - storePath: string; } | null> { - const token = await getToken(); - if (!token) return null; - + let target = await resolveContext(contextName); const resolvedUrl = mcpUrl ? normalizeMcpUrl(mcpUrl) - : (resolveServerUrl(undefined, storePath) ?? DEFAULT_MCP_URL); + : await resolveServerUrl(undefined, target.name); + + if (mcpUrl && !contextName) { + const matched = await findContextByMemoryUrl(resolvedUrl); + if (matched) target = matched; + } + + if (mcpUrl && !process.env.LOBU_API_TOKEN) { + const contextUrl = await resolveServerUrl(undefined, target.name); + const requestedBase = baseMcpUrl(resolvedUrl); + const contextBase = baseMcpUrl(contextUrl); + if (requestedBase !== contextBase) { + throw new Error( + `Refusing to send stored context credentials for "${target.name}" to ${requestedBase}. Configure that context's memory URL or set LOBU_API_TOKEN explicitly.` + ); + } + } + + const token = await getToken(target.name); + if (!token) return null; + const org = - orgFromMcpUrl(resolvedUrl) ?? resolveOrg(undefined, undefined, storePath); + orgFromMcpUrl(resolvedUrl) ?? + (await resolveOrg(undefined, undefined, target.name)); const sessionUrl = org && !orgFromMcpUrl(resolvedUrl) ? mcpUrlForOrg(resolvedUrl, org) @@ -218,6 +175,5 @@ export async function getUsableToken( tokenType: "Bearer", updatedAt: new Date().toISOString(), }, - storePath: getMemoryPreferencesPath(storePath), }; } diff --git a/packages/cli/src/commands/memory/_lib/openclaw-cmd.ts b/packages/cli/src/commands/memory/_lib/openclaw-cmd.ts index 402251f7c..5bb73cb67 100644 --- a/packages/cli/src/commands/memory/_lib/openclaw-cmd.ts +++ b/packages/cli/src/commands/memory/_lib/openclaw-cmd.ts @@ -136,16 +136,16 @@ async function initializeMcpSession( async function resolveSessionAndUrl( urlFlag?: string, orgFlag?: string, - storePath?: string + context?: string ): Promise<{ token: string; session: MemorySession; mcpUrl: string }> { - const org = resolveOrg(orgFlag, undefined, storePath); - const serverUrl = resolveServerUrl(urlFlag, storePath); + const org = await resolveOrg(orgFlag, undefined, context); + const serverUrl = await resolveServerUrl(urlFlag, context); if (!serverUrl) { throw new ValidationError("Memory MCP URL could not be resolved."); } const mcpUrl = org ? mcpUrlForOrg(serverUrl, org) : serverUrl; - const result = await getUsableToken(mcpUrl, storePath); + const result = await getUsableToken(mcpUrl, context); if (!result) { throw new ValidationError("Not logged in. Run: lobu login"); } @@ -169,10 +169,20 @@ function writeJsonObject(filePath: string, payload: Record) { writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`); } +function defaultTokenCommand(context?: string): string { + return context + ? `lobu token --raw --context ${shellQuote(context)}` + : "lobu token --raw"; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + export interface HealthOptions { url?: string; org?: string; - storePath?: string; + context?: string; } export async function checkMemoryHealth( @@ -182,8 +192,8 @@ export async function checkMemoryHealth( token: accessToken, session, mcpUrl: targetMcpUrl, - } = await resolveSessionAndUrl(opts.url, opts.org, opts.storePath); - const org = resolveOrg(opts.org, session, opts.storePath); + } = await resolveSessionAndUrl(opts.url, opts.org, opts.context); + const org = await resolveOrg(opts.org, session, opts.context); const sessionId = await initializeMcpSession(targetMcpUrl, accessToken); const result = await postJson<{ result?: { tools?: unknown[] } }>( @@ -223,21 +233,24 @@ export async function checkMemoryHealth( export interface ConfigureOptions { url?: string; org?: string; + context?: string; configPath?: string; tokenCommand?: string; } -export function configureMemoryPlugin(opts: ConfigureOptions = {}): void { - const org = resolveOrg(opts.org); - const baseMcpUrl = resolveServerUrl(opts.url); +export async function configureMemoryPlugin( + opts: ConfigureOptions = {} +): Promise { + const org = await resolveOrg(opts.org, undefined, opts.context); + const baseMcpUrl = await resolveServerUrl(opts.url, opts.context); if (!baseMcpUrl) { throw new ValidationError("Memory MCP URL could not be resolved."); } const resolvedMcpUrl = org ? mcpUrlForOrg(baseMcpUrl, org) : normalizeMcpUrl(baseMcpUrl); - setActiveMcpUrl(resolvedMcpUrl); - if (org) setActiveOrg(org); + await setActiveMcpUrl(resolvedMcpUrl, opts.context); + if (org) await setActiveOrg(org, opts.context); const configPath = resolve( opts.configPath || resolve(homedir(), ".openclaw", "openclaw.json") @@ -260,7 +273,7 @@ export function configureMemoryPlugin(opts: ConfigureOptions = {}): void { ? (existingEntry.config as Record) : {}; - const tokenCommand = opts.tokenCommand || "lobu token --raw"; + const tokenCommand = opts.tokenCommand || defaultTokenCommand(opts.context); entries[pluginId] = { ...existingEntry, diff --git a/packages/cli/src/commands/memory/_lib/seed-cmd.ts b/packages/cli/src/commands/memory/_lib/seed-cmd.ts index 15e3c1b6d..6644e7a93 100644 --- a/packages/cli/src/commands/memory/_lib/seed-cmd.ts +++ b/packages/cli/src/commands/memory/_lib/seed-cmd.ts @@ -565,22 +565,22 @@ async function seedWatcher( async function resolveAuth( urlFlag?: string, orgFlag?: string, - storePath?: string + context?: string ): Promise<{ token: string; mcpUrl: string; orgSlug: string }> { - const org = resolveOrg(orgFlag); + const org = await resolveOrg(orgFlag, undefined, context); if (org) { - const orgSession = getSessionForOrg(org, storePath, urlFlag); + const orgSession = await getSessionForOrg(org, context, urlFlag); if (orgSession) { - const result = await getUsableToken(orgSession.key, storePath); + const result = await getUsableToken(orgSession.key, context); if (result) { return { token: result.token, mcpUrl: orgSession.key, orgSlug: org }; } } - const serverUrl = resolveServerUrl(urlFlag, storePath); + const serverUrl = await resolveServerUrl(urlFlag, context); if (serverUrl) { const orgUrl = mcpUrlForOrg(serverUrl, org); - const result = await getUsableToken(orgUrl, storePath); + const result = await getUsableToken(orgUrl, context); if (result) { return { token: result.token, mcpUrl: orgUrl, orgSlug: org }; } @@ -588,8 +588,8 @@ async function resolveAuth( throw new ValidationError("Not logged in. Run: lobu login"); } - const serverUrl = resolveServerUrl(urlFlag, storePath); - const result = await getUsableToken(serverUrl || undefined, storePath); + const serverUrl = await resolveServerUrl(urlFlag, context); + const result = await getUsableToken(serverUrl || undefined, context); if (!result) { throw new ValidationError("Not logged in. Run: lobu login"); } @@ -614,7 +614,7 @@ export interface SeedOptions { dryRun?: boolean; org?: string; url?: string; - storePath?: string; + context?: string; } export async function seedMemoryWorkspace( @@ -626,7 +626,7 @@ export async function seedMemoryWorkspace( const { token, mcpUrl, orgSlug } = await resolveAuth( opts.url, orgOverride, - opts.storePath + opts.context ); const apiBaseUrl = deriveApiBaseUrl(mcpUrl); const dryRun = opts.dryRun ?? false; diff --git a/packages/cli/src/commands/memory/configure.ts b/packages/cli/src/commands/memory/configure.ts index 2e215deab..b1c847b4b 100644 --- a/packages/cli/src/commands/memory/configure.ts +++ b/packages/cli/src/commands/memory/configure.ts @@ -3,6 +3,8 @@ import { configureMemoryPlugin, } from "./_lib/openclaw-cmd.js"; -export function memoryConfigureCommand(options: ConfigureOptions): void { - configureMemoryPlugin(options); +export async function memoryConfigureCommand( + options: ConfigureOptions +): Promise { + await configureMemoryPlugin(options); } diff --git a/packages/cli/src/commands/memory/org.ts b/packages/cli/src/commands/memory/org.ts index 9a6a9fc64..48eebf72b 100644 --- a/packages/cli/src/commands/memory/org.ts +++ b/packages/cli/src/commands/memory/org.ts @@ -6,12 +6,14 @@ import { import { isJson, printJson, printText } from "./_lib/output.js"; interface OrgOptions { - storePath?: string; + context?: string; } -export function memoryOrgCurrentCommand(options: OrgOptions = {}): void { - const { session, key } = getActiveSession(options.storePath); - const org = resolveOrg(undefined, session, options.storePath); +export async function memoryOrgCurrentCommand( + options: OrgOptions = {} +): Promise { + const { session, key } = await getActiveSession(options.context); + const org = await resolveOrg(undefined, session, options.context); if (isJson()) { printJson({ org: org || null, server: key }); @@ -22,11 +24,11 @@ export function memoryOrgCurrentCommand(options: OrgOptions = {}): void { printText(`server: ${key || "(none)"}`); } -export function memoryOrgSetCommand( +export async function memoryOrgSetCommand( orgSlug: string, options: OrgOptions = {} -): void { - setActiveOrg(orgSlug, options.storePath); +): Promise { + await setActiveOrg(orgSlug, options.context); if (isJson()) { printJson({ org: orgSlug }); diff --git a/packages/cli/src/commands/memory/run.ts b/packages/cli/src/commands/memory/run.ts index e6b33bbc2..decf2aebe 100644 --- a/packages/cli/src/commands/memory/run.ts +++ b/packages/cli/src/commands/memory/run.ts @@ -11,6 +11,7 @@ import { isJson, printJson, printText } from "./_lib/output.js"; interface RunOptions { url?: string; org?: string; + context?: string; } export async function memoryRunCommand( @@ -18,16 +19,20 @@ export async function memoryRunCommand( params: string | undefined, options: RunOptions = {} ): Promise { - const org = resolveOrg(options.org); + const org = await resolveOrg(options.org, undefined, options.context); let mcpUrl: string; if (org) { - const orgSession = getSessionForOrg(org, undefined, options.url); + const orgSession = await getSessionForOrg( + org, + options.context, + options.url + ); if (orgSession) { mcpUrl = orgSession.key; } else { - const serverUrl = resolveServerUrl(options.url); - const base = serverUrl || resolveMcpEndpoint(); + const serverUrl = await resolveServerUrl(options.url, options.context); + const base = serverUrl || (await resolveMcpEndpoint()); if (!base) throw new ValidationError( "Server URL required. Pass --url or set LOBU_MEMORY_URL." @@ -35,8 +40,8 @@ export async function memoryRunCommand( mcpUrl = mcpUrlForOrg(base, org); } } else { - const serverUrl = resolveServerUrl(options.url); - const resolved = serverUrl || resolveMcpEndpoint(); + const serverUrl = await resolveServerUrl(options.url, options.context); + const resolved = serverUrl || (await resolveMcpEndpoint()); if (!resolved) throw new ValidationError( "Server URL required. Pass --url or set LOBU_MEMORY_URL." diff --git a/packages/cli/src/commands/org.ts b/packages/cli/src/commands/org.ts new file mode 100644 index 000000000..7f0b7ddcd --- /dev/null +++ b/packages/cli/src/commands/org.ts @@ -0,0 +1,56 @@ +import chalk from "chalk"; +import { + getActiveOrg, + listOrganizations, + resolveContext, + setActiveOrg, +} from "../internal/index.js"; + +export async function orgListCommand(options?: { + context?: string; +}): Promise { + const target = await resolveContext(options?.context); + const active = await getActiveOrg(target.name); + const orgs = await listOrganizations({ context: target.name }); + + if (orgs.length === 0) { + console.log(chalk.dim("\n No organizations found for this login.\n")); + return; + } + + console.log(chalk.bold(`\n Organizations in ${target.name}`)); + for (const org of orgs) { + const marker = org.slug === active ? chalk.green("*") : " "; + const name = org.name ? chalk.dim(` ${org.name}`) : ""; + console.log(`${marker} ${org.slug}${name}`); + } + console.log(); +} + +export async function orgCurrentCommand(options?: { + context?: string; +}): Promise { + const target = await resolveContext(options?.context); + const active = await getActiveOrg(target.name); + if (!active) { + console.log( + chalk.dim( + `\n No active org set for context ${target.name}. Run \`lobu org set \`.\n` + ) + ); + return; + } + console.log(chalk.bold(`\n Current org for context ${target.name}`)); + console.log(chalk.dim(` ${active}\n`)); +} + +export async function orgSetCommand( + slug: string, + options?: { context?: string } +): Promise { + const target = await resolveContext(options?.context); + await setActiveOrg(slug, target.name); + console.log( + chalk.green(`\n Active org for context ${target.name} set to ${slug}\n`) + ); +} diff --git a/packages/cli/src/commands/platforms/add.ts b/packages/cli/src/commands/platforms/add.ts deleted file mode 100644 index 4ec59f43d..000000000 --- a/packages/cli/src/commands/platforms/add.ts +++ /dev/null @@ -1,79 +0,0 @@ -import chalk from "chalk"; -import { - appendTomlBlock, - loadAgentContext, - setSecrets, -} from "../../config/agent-helpers.js"; -import { CONFIG_FILENAME } from "../../config/loader.js"; -import { PLATFORM_LABELS, promptPlatformConfig } from "./platform-prompts.js"; - -const SUPPORTED = [ - "telegram", - "slack", - "discord", - "whatsapp", - "teams", - "gchat", -]; - -export async function platformsAddCommand( - cwd: string, - platform: string -): Promise { - if (!SUPPORTED.includes(platform)) { - console.log(chalk.red(`\n Platform "${platform}" is not supported.`)); - console.log(chalk.dim(` Supported: ${SUPPORTED.join(", ")}\n`)); - return; - } - - const ctx = await loadAgentContext(cwd); - if (!ctx) return; - - const existing = (ctx.agent.platforms ?? []) as Array< - Record - >; - if (existing.some((p) => p.type === platform)) { - console.log( - chalk.yellow( - `\n Platform "${platform}" is already configured for agent "${ctx.agentId}".\n` - ) - ); - return; - } - - console.log( - chalk.dim( - `\n Adding ${PLATFORM_LABELS[platform]} platform to agent "${ctx.agentId}".\n` - ) - ); - - const { platformConfig, platformSecrets } = - await promptPlatformConfig(platform); - - if (Object.keys(platformConfig).length === 0) { - console.log(chalk.yellow("\n No credentials provided. Aborting.\n")); - return; - } - - const lines = [ - "", - `[[agents.${ctx.agentId}.platforms]]`, - `type = "${platform}"`, - "", - `[agents.${ctx.agentId}.platforms.config]`, - ...Object.entries(platformConfig).map( - ([key, value]) => `${key} = "${value}"` - ), - ]; - await appendTomlBlock(ctx, lines); - await setSecrets(cwd, platformSecrets); - - console.log( - chalk.green( - `\n Added ${PLATFORM_LABELS[platform]} platform to ${CONFIG_FILENAME}` - ) - ); - console.log( - chalk.dim(" Run `lobu run -d` to start the stack with the new platform.\n") - ); -} diff --git a/packages/cli/src/commands/platforms/list.ts b/packages/cli/src/commands/platforms/list.ts deleted file mode 100644 index 95d88f74f..000000000 --- a/packages/cli/src/commands/platforms/list.ts +++ /dev/null @@ -1,26 +0,0 @@ -import chalk from "chalk"; -import { loadAgentContext } from "../../config/agent-helpers.js"; -import { PLATFORM_LABELS } from "./platform-prompts.js"; - -export async function platformsListCommand(cwd: string): Promise { - const ctx = await loadAgentContext(cwd); - if (!ctx) return; - - console.log(); - for (const [agentId, agent] of Object.entries(ctx.agents)) { - const platforms = (agent.platforms ?? []) as Array>; - console.log(chalk.bold(` ${agentId}`)); - if (platforms.length === 0) { - console.log(chalk.dim(" (no platforms configured)")); - } else { - for (const p of platforms) { - const type = p.type as string; - const label = PLATFORM_LABELS[type] ?? type; - console.log( - ` ${chalk.cyan("●")} ${label} ${chalk.dim(`(${type})`)}` - ); - } - } - console.log(); - } -} diff --git a/packages/cli/src/commands/platforms/platform-prompts.ts b/packages/cli/src/commands/platforms/platform-prompts.ts index f294b51d2..1db4ec2aa 100644 --- a/packages/cli/src/commands/platforms/platform-prompts.ts +++ b/packages/cli/src/commands/platforms/platform-prompts.ts @@ -1,6 +1,6 @@ /** * Shared platform prompt/config logic. - * Used by both `lobu init` and `lobu platforms add `. + * Used by `lobu init` when scaffolding optional local platform config. */ import { input, password } from "@inquirer/prompts"; diff --git a/packages/cli/src/commands/providers/add.ts b/packages/cli/src/commands/providers/add.ts deleted file mode 100644 index e225a94dc..000000000 --- a/packages/cli/src/commands/providers/add.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { password } from "@inquirer/prompts"; -import chalk from "chalk"; -import { - appendTomlBlock, - loadAgentContext, - setSecrets, -} from "../../config/agent-helpers.js"; -import { CONFIG_FILENAME } from "../../config/loader.js"; -import { getProviderById } from "./registry.js"; - -export async function providersAddCommand( - cwd: string, - providerId: string -): Promise { - const providerEntry = getProviderById(providerId); - if (!providerEntry) { - console.log(chalk.red(`\n Provider "${providerId}" not found.`)); - console.log( - chalk.dim( - " Run `npx @lobu/cli@latest providers list` to see available providers.\n" - ) - ); - return; - } - - const ctx = await loadAgentContext(cwd); - if (!ctx) return; - - const providers = (ctx.agent.providers ?? []) as Array< - Record - >; - if (providers.some((p) => p.id === providerId)) { - console.log( - chalk.yellow(`\n Provider "${providerId}" is already configured.\n`) - ); - return; - } - - const provider = providerEntry.providers?.[0]; - if (!provider) return; - - const defaultModel = provider.defaultModel; - const envVar = provider.envVarName; - - const apiKey = await password({ - message: `${provider.displayName} API key:`, - mask: true, - }); - - await appendTomlBlock(ctx, [ - "", - `[[agents.${ctx.agentId}.providers]]`, - `id = "${providerId}"`, - ...(defaultModel ? [`model = "${defaultModel}"`] : []), - `key = "$${envVar}"`, - ]); - - if (apiKey) { - await setSecrets(cwd, [{ envVar, value: apiKey }]); - } - - console.log( - chalk.green(`\n Added provider "${providerId}" to ${CONFIG_FILENAME}`) - ); - if (defaultModel) { - console.log(chalk.dim(` Default model: ${defaultModel}`)); - } - if (!apiKey) { - console.log(chalk.dim("\n Set the API key:")); - console.log( - chalk.cyan(` npx @lobu/cli@latest secrets set ${envVar} `) - ); - } - console.log(); -} diff --git a/packages/cli/src/commands/providers/list.ts b/packages/cli/src/commands/providers/list.ts deleted file mode 100644 index 309361f82..000000000 --- a/packages/cli/src/commands/providers/list.ts +++ /dev/null @@ -1,34 +0,0 @@ -import chalk from "chalk"; -import { loadProviderRegistry } from "./registry.js"; - -export async function providersListCommand(): Promise { - const providers = loadProviderRegistry(); - - if (providers.length === 0) { - console.log( - chalk.yellow("\n No providers found in the bundled registry.\n") - ); - return; - } - - console.log(chalk.bold("\n Available LLM Providers:\n")); - - const maxIdLen = Math.max(...providers.map((provider) => provider.id.length)); - - for (const provider of providers) { - const meta = provider.providers[0]; - if (!meta) continue; - const model = meta.defaultModel - ? chalk.dim(` default: ${meta.defaultModel}`) - : ""; - console.log( - ` ${chalk.cyan(provider.id.padEnd(maxIdLen))} ${meta.displayName}${model}` - ); - } - - console.log( - chalk.dim( - "\n Use `npx @lobu/cli@latest providers add ` to add a provider to lobu.toml.\n" - ) - ); -} diff --git a/packages/cli/src/commands/secrets.ts b/packages/cli/src/commands/secrets.ts deleted file mode 100644 index d33d7d4ac..000000000 --- a/packages/cli/src/commands/secrets.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import chalk from "chalk"; - -/** - * Local .env file management for dev mode secrets. - * Cloud secrets will use the API when available. - */ -export async function secretsSetCommand( - cwd: string, - key: string, - value: string -): Promise { - const envPath = join(cwd, ".env"); - let content = ""; - try { - content = await readFile(envPath, "utf-8"); - } catch { - // No .env yet - } - - const lines = content.split("\n"); - let found = false; - - const updated = lines.map((line) => { - const trimmed = line.trim(); - if (trimmed.startsWith(`${key}=`)) { - found = true; - return `${key}=${value}`; - } - return line; - }); - - if (!found) { - updated.push(`${key}=${value}`); - } - - await writeFile(envPath, updated.join("\n")); - console.log(chalk.green(`\n Set ${key} in .env\n`)); -} - -export async function secretsListCommand(cwd: string): Promise { - const envPath = join(cwd, ".env"); - let content = ""; - try { - content = await readFile(envPath, "utf-8"); - } catch { - console.log(chalk.dim("\n No .env file found.\n")); - return; - } - - const secrets: Array<{ key: string; preview: string }> = []; - - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx === -1) continue; - - const key = trimmed.slice(0, eqIdx); - const value = trimmed.slice(eqIdx + 1); - - // Redact values that look like secrets - const isSecret = - key.includes("KEY") || - key.includes("SECRET") || - key.includes("TOKEN") || - key.includes("PASSWORD"); - const preview = isSecret - ? value.length > 4 - ? `${value.slice(0, 4)}${"*".repeat(Math.min(value.length - 4, 20))}` - : "****" - : value; - - secrets.push({ key, preview }); - } - - if (secrets.length === 0) { - console.log(chalk.dim("\n No secrets found in .env\n")); - return; - } - - console.log(chalk.bold("\n Secrets (.env):")); - const maxKeyLen = Math.max(...secrets.map((s) => s.key.length)); - for (const { key, preview } of secrets) { - console.log( - ` ${chalk.cyan(key.padEnd(maxKeyLen))} ${chalk.dim(preview)}` - ); - } - console.log(); -} - -export async function secretsDeleteCommand( - cwd: string, - key: string -): Promise { - const envPath = join(cwd, ".env"); - let content = ""; - try { - content = await readFile(envPath, "utf-8"); - } catch { - console.log(chalk.dim("\n No .env file found.\n")); - return; - } - - const lines = content.split("\n"); - const filtered = lines.filter((line) => { - const trimmed = line.trim(); - return !trimmed.startsWith(`${key}=`); - }); - - if (lines.length === filtered.length) { - console.log(chalk.yellow(`\n Key "${key}" not found in .env\n`)); - return; - } - - await writeFile(envPath, filtered.join("\n")); - console.log(chalk.green(`\n Removed ${key} from .env\n`)); -} diff --git a/packages/cli/src/commands/skills/add.ts b/packages/cli/src/commands/skills/add.ts deleted file mode 100644 index a45c190d8..000000000 --- a/packages/cli/src/commands/skills/add.ts +++ /dev/null @@ -1,38 +0,0 @@ -import chalk from "chalk"; -import { installBundledSkill, listBundledSkills } from "./registry.js"; - -export async function skillsAddCommand( - cwd: string, - skillId: string, - options?: { dir?: string; force?: boolean } -): Promise { - const available = listBundledSkills(); - const destinationRoot = options?.dir || cwd; - - try { - const { skill, destinationDir } = installBundledSkill(skillId, destinationRoot, { - force: options?.force, - }); - - console.log(chalk.green(`\n Installed \"${skill.name}\"`)); - console.log(chalk.dim(` → ${destinationDir}`)); - console.log(); - console.log(chalk.dim(" Next steps:")); - console.log(chalk.cyan(" 1. Commit the new skills/ directory to your repo")); - console.log(chalk.cyan(" 2. Point your agent or workspace at that local skill")); - console.log(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(chalk.red(`\n ${message}`)); - - if (message.includes("not found")) { - console.log( - chalk.dim( - ` Available starter skills: ${available.map((skill) => skill.id).join(", ")}` - ) - ); - } - - console.log(); - } -} diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts deleted file mode 100644 index 995c9618e..000000000 --- a/packages/cli/src/commands/skills/list.ts +++ /dev/null @@ -1,21 +0,0 @@ -import chalk from "chalk"; -import { listBundledSkills } from "./registry.js"; - -export async function skillsListCommand(): Promise { - const skills = listBundledSkills(); - - if (skills.length === 0) { - console.log(chalk.yellow("\n No bundled starter skills are available.\n")); - return; - } - - console.log(chalk.cyan("\n Bundled starter skills\n")); - for (const skill of skills) { - console.log(chalk.green(` ${skill.id}`)); - if (skill.description) { - console.log(chalk.dim(` ${skill.description}`)); - } - console.log(chalk.dim(` Files: ${skill.files.join(", ")}`)); - console.log(); - } -} diff --git a/packages/cli/src/commands/skills/registry.ts b/packages/cli/src/commands/skills/registry.ts deleted file mode 100644 index a5139c18a..000000000 --- a/packages/cli/src/commands/skills/registry.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import YAML from "yaml"; - -const PUBLIC_SKILL_IDS = ["lobu"] as const; - -type PublicSkillId = (typeof PUBLIC_SKILL_IDS)[number]; - -interface SkillFrontmatter { - name?: string; - description?: string; -} - -interface BundledSkill { - id: PublicSkillId; - name: string; - description: string; - sourceDir: string; - files: string[]; -} - -function findSkillDir(id: PublicSkillId): string | null { - let dir = dirname(fileURLToPath(import.meta.url)); - - while (true) { - const packaged = join(dir, "bundled-skills", id, "SKILL.md"); - if (existsSync(packaged)) return join(dir, "bundled-skills", id); - - const repoSkill = join(dir, "skills", id, "SKILL.md"); - if (existsSync(repoSkill)) return join(dir, "skills", id); - - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -function parseFrontmatter(skillDir: string): SkillFrontmatter { - const content = readFileSync(join(skillDir, "SKILL.md"), "utf-8"); - const match = content.match(/^---\n([\s\S]*?)\n---\n?/); - if (!match?.[1]) return {}; - return (YAML.parse(match[1]) as SkillFrontmatter | null) ?? {}; -} - -function listFilesRecursive(root: string, dir = root): string[] { - const entries = readdirSync(dir, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...listFilesRecursive(root, fullPath)); - continue; - } - files.push(relative(root, fullPath)); - } - - return files.sort(); -} - -export function listBundledSkills(): BundledSkill[] { - return PUBLIC_SKILL_IDS.map((id) => { - const sourceDir = findSkillDir(id); - if (!sourceDir) { - throw new Error(`Bundled skill \"${id}\" is not available in this build.`); - } - - const frontmatter = parseFrontmatter(sourceDir); - return { - id, - name: frontmatter.name?.trim() || id, - description: frontmatter.description?.trim() || "", - sourceDir, - files: listFilesRecursive(sourceDir), - } satisfies BundledSkill; - }); -} - -function getBundledSkill(id: string): BundledSkill | undefined { - return listBundledSkills().find((skill) => skill.id === id); -} - -interface InstallBundledSkillResult { - skill: BundledSkill; - destinationDir: string; -} - -export function installBundledSkill( - id: string, - targetRoot: string, - options?: { force?: boolean } -): InstallBundledSkillResult { - const skill = getBundledSkill(id); - if (!skill) { - throw new Error(`Bundled skill \"${id}\" not found.`); - } - - const destinationDir = resolve(targetRoot, "skills", skill.id); - if (existsSync(destinationDir)) { - if (!options?.force) { - throw new Error( - `Target already exists: ${destinationDir}. Re-run with --force to overwrite it.` - ); - } - rmSync(destinationDir, { recursive: true, force: true }); - } - - mkdirSync(dirname(destinationDir), { recursive: true }); - cpSync(skill.sourceDir, destinationDir, { recursive: true, force: true }); - - return { skill, destinationDir }; -} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 6839b4555..643cf2aaa 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,129 +1,43 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { parseEnvContent, resolveGatewayUrl } from "../internal/index.js"; import chalk from "chalk"; - -interface StatusResponse { - agents: Array<{ - agentId: string; - name: string; - providers: string[]; - model: string; - }>; - connections: Array<{ - id: string; - platform: string; - status: string; - templateAgentId: string | null; - botUsername: string | null; - }>; - sandboxes: Array<{ - agentId: string; - name: string; - parentConnectionId: string | null; - lastUsedAt: number | null; - }>; +import { resolveApiClient } from "../internal/index.js"; + +interface AgentStatusItem { + agentId: string; + name: string; + connectionCount?: number; + activeConnectionCount?: number; + clientCount?: number; + status?: string; } -export async function statusCommand(cwd: string): Promise { - const { gatewayUrl, adminPassword } = await resolveConfig(cwd); - - let status: StatusResponse; - try { - const res = await fetch(`${gatewayUrl}/internal/status`, { - headers: { Authorization: `Bearer ${adminPassword}` }, - }); - if (!res.ok) { - if (res.status === 401) { - console.log( - chalk.red("\n Unauthorized. Check ADMIN_PASSWORD in .env.\n") - ); - return; - } - throw new Error(`HTTP ${res.status}`); - } - status = (await res.json()) as StatusResponse; - } catch { - console.log(chalk.yellow("\n Gateway not reachable.")); - console.log( - chalk.dim(" Start with `npx @lobu/cli@latest run` to run your agents.\n") - ); +export async function statusCommand( + options: { context?: string; org?: string } = {} +): Promise { + const { client, orgSlug, apiBaseUrl } = await resolveApiClient(options); + const data = await client.get<{ agents?: AgentStatusItem[] }>( + `/api/${orgSlug}/agents` + ); + const agents = data.agents ?? []; + + console.log(chalk.bold("\n Lobu")); + console.log(chalk.dim(` API: ${apiBaseUrl}`)); + console.log(chalk.dim(` Org: ${orgSlug}`)); + + if (agents.length === 0) { + console.log(chalk.yellow("\n No agents configured.\n")); return; } - // Agents - if (status.agents.length > 0) { - console.log(chalk.bold.cyan("\n Agents")); - for (const a of status.agents) { - const providers = - a.providers.length > 0 ? a.providers.join(", ") : "none"; - console.log( - ` ${chalk.green("●")} ${chalk.bold(a.name)} ${chalk.dim(`(${a.agentId})`)} ${chalk.dim(`model:${a.model} providers:${providers}`)}` - ); - } - } else { - console.log(chalk.yellow("\n No agents configured.")); - } - - // Connections - if (status.connections.length > 0) { - console.log(chalk.bold.cyan("\n Connections")); - for (const conn of status.connections) { - const icon = - conn.status === "connected" ? chalk.green("●") : chalk.red("●"); - const bot = conn.botUsername - ? `@${conn.botUsername}` - : conn.id.slice(0, 8); - const agent = conn.templateAgentId - ? chalk.dim(` → ${conn.templateAgentId}`) - : ""; - console.log( - ` ${icon} ${chalk.bold(conn.platform)} ${bot}${agent} ${chalk.dim(conn.status)}` - ); - } - } - - // Sandboxes - if (status.sandboxes.length > 0) { - console.log(chalk.bold.cyan("\n Sandboxes")); - for (const s of status.sandboxes) { - const lastUsed = s.lastUsedAt - ? chalk.dim(timeAgo(s.lastUsedAt)) - : chalk.dim("never"); - console.log(` ${chalk.dim("○")} ${s.agentId} ${lastUsed}`); - } + console.log(chalk.bold.cyan("\n Agents")); + for (const agent of agents) { + const active = + agent.status === "active" || (agent.activeConnectionCount ?? 0) > 0; + const icon = active ? chalk.green("●") : chalk.dim("○"); + console.log( + ` ${icon} ${chalk.bold(agent.name)} ${chalk.dim(`(${agent.agentId})`)} ${chalk.dim( + `connections:${agent.connectionCount ?? 0} active:${agent.activeConnectionCount ?? 0} clients:${agent.clientCount ?? 0}` + )}` + ); } - console.log(); } - -function timeAgo(ts: number): string { - const seconds = Math.floor((Date.now() - ts) / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -async function resolveConfig( - cwd: string -): Promise<{ gatewayUrl: string; adminPassword: string }> { - const gatewayUrl = await resolveGatewayUrl({ cwd }); - - let adminPassword = ""; - try { - const envContent = await readFile(join(cwd, ".env"), "utf-8"); - adminPassword = parseEnvContent(envContent).ADMIN_PASSWORD ?? ""; - } catch { - // No .env file - } - - if (!adminPassword) { - adminPassword = process.env.ADMIN_PASSWORD || ""; - } - - return { gatewayUrl, adminPassword }; -} diff --git a/packages/cli/src/config/agent-helpers.ts b/packages/cli/src/config/agent-helpers.ts deleted file mode 100644 index 63c25be72..000000000 --- a/packages/cli/src/config/agent-helpers.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Shared helpers for the `lobu {providers,skills,connections} add` commands. - * All three follow the same pattern: read lobu.toml, locate the first agent, - * apply a mutation, write the updated file, and optionally set secrets. - */ - -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import chalk from "chalk"; -import { parse as parseToml } from "smol-toml"; -import { secretsSetCommand } from "../commands/secrets.js"; -import { CONFIG_FILENAME } from "./loader.js"; - -interface AgentContext { - /** Absolute path to lobu.toml */ - configPath: string; - /** Raw TOML text */ - raw: string; - /** Parsed TOML object */ - parsed: Record; - /** ID of the first agent (Lobu assumes one agent per project today) */ - agentId: string; - /** The first agent's config object */ - agent: Record; - /** All agents keyed by ID */ - agents: Record>; -} - -/** - * Load lobu.toml and return the first agent's context. - * Prints a red error message and returns null if the file or agent is missing. - */ -export async function loadAgentContext( - cwd: string -): Promise { - const configPath = join(cwd, CONFIG_FILENAME); - let raw: string; - try { - raw = await readFile(configPath, "utf-8"); - } catch { - console.log( - chalk.red(`\n No ${CONFIG_FILENAME} found. Run \`lobu init\` first.\n`) - ); - return null; - } - - const parsed = parseToml(raw) as Record; - const agents = parsed.agents as - | Record> - | undefined; - if (!agents || Object.keys(agents).length === 0) { - console.log(chalk.red(`\n No agents found in ${CONFIG_FILENAME}.\n`)); - return null; - } - - const agentId = Object.keys(agents)[0]!; - return { - configPath, - raw, - parsed, - agentId, - agent: agents[agentId]!, - agents, - }; -} - -/** - * Append a block of TOML lines to the end of lobu.toml. - * Preserves existing comments/formatting by editing the raw text. - */ -export async function appendTomlBlock( - ctx: AgentContext, - lines: string[] -): Promise { - const block = lines.join("\n"); - await writeFile(ctx.configPath, `${ctx.raw.trimEnd()}\n${block}\n`); -} - -/** - * Write multiple secrets to .env via the secrets command. - */ -export async function setSecrets( - cwd: string, - secrets: Array<{ envVar: string; value: string }> -): Promise { - for (const secret of secrets) { - await secretsSetCommand(cwd, secret.envVar, secret.value); - } -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5dbf14b0c..c7a746c7c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -104,6 +104,7 @@ export async function runCli( .option("--list", "List available evals without running them") .option("--ci", "CI mode: JSON output, non-zero exit on failure") .option("--output ", "Write results to JSON file") + .option("-c, --context ", "Use a named context") .action( async ( name: string | undefined, @@ -115,6 +116,7 @@ export async function runCli( list?: boolean; ci?: boolean; output?: string; + context?: string; } ) => { const { evalCommand } = await import("./commands/eval.js"); @@ -284,120 +286,179 @@ export async function runCli( // ─── status ───────────────────────────────────────────────────────── program .command("status") - .description("Agent health and version info") - .action(async () => { + .description("Show agent status from the active org") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .action(async (options: { context?: string; org?: string }) => { const { statusCommand } = await import("./commands/status.js"); - await statusCommand(process.cwd()); + await statusCommand(options); }); - // ─── secrets ──────────────────────────────────────────────────────── - const secrets = program - .command("secrets") - .description("Manage agent secrets"); - - secrets - .command("set ") - .description("Set a secret (stored in local .env for dev)") - .action(async (key: string, value: string) => { - const { secretsSetCommand } = await import("./commands/secrets.js"); - await secretsSetCommand(process.cwd(), key, value); - }); + // ─── org ──────────────────────────────────────────────────────────── + const org = program.command("org").description("Manage active Lobu org"); - secrets + org .command("list") - .description("List secrets (values redacted)") - .action(async () => { - const { secretsListCommand } = await import("./commands/secrets.js"); - await secretsListCommand(process.cwd()); + .description("List organizations available to the current login") + .option("-c, --context ", "Use a named context") + .action(async (options: { context?: string }) => { + const { orgListCommand } = await import("./commands/org.js"); + await orgListCommand(options); + }); + + org + .command("current") + .description("Show the active org") + .option("-c, --context ", "Use a named context") + .action(async (options: { context?: string }) => { + const { orgCurrentCommand } = await import("./commands/org.js"); + await orgCurrentCommand(options); }); - secrets - .command("delete ") - .description("Remove a secret") - .action(async (key: string) => { - const { secretsDeleteCommand } = await import("./commands/secrets.js"); - await secretsDeleteCommand(process.cwd(), key); + org + .command("set ") + .description("Set the active org slug") + .option("-c, --context ", "Use a named context") + .action(async (slug: string, options: { context?: string }) => { + const { orgSetCommand } = await import("./commands/org.js"); + await orgSetCommand(slug, options); }); - // ─── providers ────────────────────────────────────────────────────── - const providers = program - .command("providers") - .description("Browse and manage LLM providers"); + // ─── agent ────────────────────────────────────────────────────────── + const agent = program + .command("agent") + .description("Manage agents via the same REST API as the web app"); - providers + agent .command("list") - .description("Browse available LLM providers") - .action(async () => { - const { providersListCommand } = await import( - "./commands/providers/list.js" - ); - await providersListCommand(); - }); + .description("List agents") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .option("--json", "Print JSON") + .action( + async (options: { context?: string; org?: string; json?: boolean }) => { + const { agentListCommand } = await import("./commands/agent.js"); + await agentListCommand(options); + } + ); - providers - .command("add ") - .description("Add a provider to lobu.toml") - .action(async (id: string) => { - const { providersAddCommand } = await import( - "./commands/providers/add.js" - ); - await providersAddCommand(process.cwd(), id); - }); + agent + .command("get ") + .description("Get an agent") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .action( + async (agentId: string, options: { context?: string; org?: string }) => { + const { agentGetCommand } = await import("./commands/agent.js"); + await agentGetCommand(agentId, options); + } + ); - // ─── skills ───────────────────────────────────────────────────────── - const skills = program - .command("skills") - .description( - "Install bundled starter skills into the local skills/ directory" + agent + .command("create ") + .description("Create an agent") + .option("--name ", "Display name") + .option("--description ", "Description") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .option("--json", "Print JSON") + .action( + async ( + agentId: string, + options: { + name?: string; + description?: string; + context?: string; + org?: string; + json?: boolean; + } + ) => { + const { agentCreateCommand } = await import("./commands/agent.js"); + await agentCreateCommand(agentId, options); + } ); - skills - .command("list") - .description("List bundled starter skills") - .action(async () => { - const { skillsListCommand } = await import("./commands/skills/list.js"); - await skillsListCommand(); - }); + agent + .command("update ") + .description("Update agent metadata") + .option("--name ", "Display name") + .option("--description ", "Description") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .option("--json", "Print JSON") + .action( + async ( + agentId: string, + options: { + name?: string; + description?: string; + context?: string; + org?: string; + json?: boolean; + } + ) => { + const { agentUpdateCommand } = await import("./commands/agent.js"); + await agentUpdateCommand(agentId, options); + } + ); - skills - .command("add ") - .description("Install a bundled starter skill into skills/") - .option( - "-d, --dir ", - "Target directory (defaults to current working directory)" - ) - .option("-f, --force", "Overwrite an existing skills/ directory") - .action(async (id: string, options: { dir?: string; force?: boolean }) => { - const { skillsAddCommand } = await import("./commands/skills/add.js"); - await skillsAddCommand(process.cwd(), id, options); - }); + agent + .command("delete ") + .description("Delete an agent") + .option("--yes", "Confirm deletion") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .action( + async ( + agentId: string, + options: { yes?: boolean; context?: string; org?: string } + ) => { + const { agentDeleteCommand } = await import("./commands/agent.js"); + await agentDeleteCommand(agentId, options); + } + ); - // ─── platforms ────────────────────────────────────────────────────── - const platforms = program - .command("platforms") - .description("Manage chat platforms"); + const agentConfig = agent + .command("config") + .description("Read or patch agent config"); - platforms - .command("list") - .description("List configured platforms per agent") - .action(async () => { - const { platformsListCommand } = await import( - "./commands/platforms/list.js" - ); - await platformsListCommand(process.cwd()); - }); + agentConfig + .command("get ") + .description("Print agent config JSON") + .option("--output ", "Write JSON to a file") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .action( + async ( + agentId: string, + options: { output?: string; context?: string; org?: string } + ) => { + const { agentConfigGetCommand } = await import("./commands/agent.js"); + await agentConfigGetCommand(agentId, options); + } + ); - platforms - .command("add ") - .description( - "Add a chat platform (telegram, slack, discord, whatsapp, teams, gchat)" - ) - .action(async (platform: string) => { - const { platformsAddCommand } = await import( - "./commands/platforms/add.js" - ); - await platformsAddCommand(process.cwd(), platform); - }); + agentConfig + .command("patch ") + .description("Patch agent config from a JSON file") + .requiredOption("--file ", "JSON file with config fields to update") + .option("-c, --context ", "Use a named context") + .option("--org ", "Org slug override") + .option("--json", "Print JSON") + .action( + async ( + agentId: string, + options: { + file: string; + context?: string; + org?: string; + json?: boolean; + } + ) => { + const { agentConfigPatchCommand } = await import("./commands/agent.js"); + await agentConfigPatchCommand(agentId, options); + } + ); // ─── doctor ───────────────────────────────────────────────────────── program @@ -422,18 +483,20 @@ export async function runCli( memoryOrg .command("current") .description("Show the active org") - .action(async () => { + .option("-c, --context ", "Use a named context") + .action(async (options: { context?: string }) => { const { memoryOrgCurrentCommand } = await import( "./commands/memory/org.js" ); - memoryOrgCurrentCommand(); + await memoryOrgCurrentCommand(options); }); memoryOrg .command("set ") .description("Set the active org slug") - .action(async (slug: string) => { + .option("-c, --context ", "Use a named context") + .action(async (slug: string, options: { context?: string }) => { const { memoryOrgSetCommand } = await import("./commands/memory/org.js"); - memoryOrgSetCommand(slug); + await memoryOrgSetCommand(slug, options); }); memory @@ -441,11 +504,12 @@ export async function runCli( .description("Invoke an MCP tool (or list tools when called bare)") .option("--url ", "Server URL override") .option("--org ", "Org slug override") + .option("-c, --context ", "Use a named context") .action( async ( tool: string | undefined, params: string | undefined, - options: { url?: string; org?: string } + options: { url?: string; org?: string; context?: string } ) => { const { memoryRunCommand } = await import("./commands/memory/run.js"); await memoryRunCommand(tool, params, options); @@ -457,12 +521,15 @@ export async function runCli( .description("Validate Lobu login + MCP connectivity") .option("--url ", "Server URL override") .option("--org ", "Org slug override") - .action(async (options: { url?: string; org?: string }) => { - const { memoryHealthCommand } = await import( - "./commands/memory/health.js" - ); - await memoryHealthCommand(options); - }); + .option("-c, --context ", "Use a named context") + .action( + async (options: { url?: string; org?: string; context?: string }) => { + const { memoryHealthCommand } = await import( + "./commands/memory/health.js" + ); + await memoryHealthCommand(options); + } + ); memory .command("configure") @@ -471,6 +538,7 @@ export async function runCli( ) .option("--url ", "Server URL override") .option("--org ", "Org slug override") + .option("-c, --context ", "Use a named context") .option( "--config-path ", "OpenClaw config path (defaults to ~/.openclaw/openclaw.json)" @@ -483,13 +551,14 @@ export async function runCli( async (options: { url?: string; org?: string; + context?: string; configPath?: string; tokenCommand?: string; }) => { const { memoryConfigureCommand } = await import( "./commands/memory/configure.js" ); - memoryConfigureCommand(options); + await memoryConfigureCommand(options); } ); @@ -504,6 +573,7 @@ export async function runCli( "Org slug override (defaults to [memory.owletto].org)" ) .option("--url ", "Server URL override") + .option("-c, --context ", "Use a named context") .action( async ( pathArg: string | undefined, @@ -511,6 +581,7 @@ export async function runCli( dryRun?: boolean; org?: string; url?: string; + context?: string; } ) => { const { memorySeedCommand } = await import("./commands/memory/seed.js"); diff --git a/packages/cli/src/internal/__tests__/api-client.test.ts b/packages/cli/src/internal/__tests__/api-client.test.ts new file mode 100644 index 000000000..ab01d6784 --- /dev/null +++ b/packages/cli/src/internal/__tests__/api-client.test.ts @@ -0,0 +1,137 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { ApiClient, listOrganizations, resolveApiClient } from "../api-client"; +import * as context from "../context"; +import * as credentials from "../credentials"; + +describe("ApiClient", () => { + test("request sends correct headers", async () => { + const fetchMock = mock(async () => { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + const client = new ApiClient( + "https://api.example.com", + "my-token", + fetchMock as unknown as typeof fetch + ); + const result = await client.get("/test"); + + expect(result).toEqual({ ok: true }); + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe("https://api.example.com/test"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer my-token", + Accept: "application/json", + }); + }); + + test("request throws ApiClientError on failure", async () => { + const fetchMock = mock(async () => { + return new Response( + JSON.stringify({ error: "Failed", message: "Error message" }), + { status: 400 } + ); + }); + + const client = new ApiClient( + "https://api.example.com", + "my-token", + fetchMock as unknown as typeof fetch + ); + + expect(client.get("/fail")).rejects.toThrow( + "GET /fail failed: Error message" + ); + }); +}); + +describe("resolveApiClient", () => { + beforeEach(() => { + delete process.env.LOBU_API_TOKEN; + delete process.env.LOBU_ORG; + }); + + afterEach(() => { + mock.restore(); + }); + + test("resolves the token from the context that owns an overridden API URL", async () => { + spyOn(context, "resolveContext").mockResolvedValue({ + name: "default", + apiUrl: "https://app.lobu.ai/api/v1", + source: "default", + }); + spyOn(context, "findContextByUrl").mockImplementation(async (url) => { + if (url === "https://custom.lobu.ai/api/v1") { + return { + name: "custom", + apiUrl: "https://custom.lobu.ai/api/v1", + source: "config", + }; + } + return undefined; + }); + spyOn(credentials, "getToken").mockImplementation(async (name) => { + if (name === "custom") return "custom-token"; + if (name === "default") return "default-token"; + return null; + }); + spyOn(context, "getActiveOrg").mockResolvedValue("my-org"); + + const resolved = await resolveApiClient({ + apiUrl: "https://custom.lobu.ai/api/v1", + }); + expect(resolved.contextName).toBe("custom"); + expect(resolved.token).toBe("custom-token"); + expect(resolved.apiBaseUrl).toBe("https://custom.lobu.ai"); + + await expect( + resolveApiClient({ apiUrl: "https://unknown.lobu.ai/api/v1" }) + ).rejects.toThrow("Refusing to send stored context credentials"); + }); + + test("reads the active org from the resolved context", async () => { + spyOn(context, "resolveContext").mockResolvedValue({ + name: "prod", + apiUrl: "https://app.lobu.ai/api/v1", + source: "config", + }); + spyOn(credentials, "getToken").mockResolvedValue("prod-token"); + const getActiveOrgSpy = spyOn(context, "getActiveOrg").mockImplementation( + async (ctx) => { + if (ctx === "prod") return "prod-org"; + return "default-org"; + } + ); + + const resolved = await resolveApiClient({ context: "prod" }); + + expect(resolved.orgSlug).toBe("prod-org"); + expect(getActiveOrgSpy).toHaveBeenCalledWith("prod"); + }); + + test("listOrganizations refuses unmatched URL overrides with stored credentials", async () => { + spyOn(context, "resolveContext").mockResolvedValue({ + name: "default", + apiUrl: "https://app.lobu.ai/api/v1", + source: "default", + }); + spyOn(context, "findContextByUrl").mockResolvedValue(undefined); + spyOn(credentials, "getToken").mockResolvedValue("default-token"); + + await expect( + listOrganizations({ apiUrl: "https://unknown.lobu.ai/api/v1" }) + ).rejects.toThrow("Refusing to send stored context credentials"); + }); +}); diff --git a/packages/cli/src/internal/__tests__/context.test.ts b/packages/cli/src/internal/__tests__/context.test.ts new file mode 100644 index 000000000..a3382cc9a --- /dev/null +++ b/packages/cli/src/internal/__tests__/context.test.ts @@ -0,0 +1,107 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import * as fs from "node:fs/promises"; +import { + DEFAULT_CONTEXT_NAME, + findContextByMemoryUrl, + findContextByUrl, + getActiveOrg, + loadContextConfig, + setActiveOrg, +} from "../context"; + +describe("context management", () => { + let readFileSpy: ReturnType>; + let writeFileSpy: ReturnType>; + + beforeEach(() => { + delete process.env.LOBU_CONTEXT; + delete process.env.LOBU_ORG; + delete process.env.LOBU_API_URL; + delete process.env.LOBU_MEMORY_URL; + + readFileSpy = spyOn(fs, "readFile"); + writeFileSpy = spyOn(fs, "writeFile").mockResolvedValue(undefined); + spyOn(fs, "mkdir").mockResolvedValue(undefined); + }); + + afterEach(() => { + mock.restore(); + }); + + test("loadContextConfig handles missing file", async () => { + readFileSpy.mockRejectedValue(new Error("File not found")); + + const config = await loadContextConfig(); + + expect(config.currentContext).toBe(DEFAULT_CONTEXT_NAME); + expect(config.contexts[DEFAULT_CONTEXT_NAME]).toBeDefined(); + }); + + test("stores and reads the active org per context", async () => { + const configData = { + currentContext: "prod", + contexts: { + lobu: { + apiUrl: "https://app.lobu.ai/api/v1", + activeOrg: "default-org", + }, + prod: { apiUrl: "https://prod.lobu.ai/api/v1", activeOrg: "prod-org" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + expect(await getActiveOrg("lobu")).toBe("default-org"); + expect(await getActiveOrg("prod")).toBe("prod-org"); + expect(await getActiveOrg()).toBe("prod-org"); + + await setActiveOrg("new-org", "lobu"); + const [, written] = writeFileSpy.mock.calls[0]!; + const saved = JSON.parse(written as string) as typeof configData; + expect(saved.contexts.lobu.activeOrg).toBe("new-org"); + expect(saved.contexts.prod.activeOrg).toBe("prod-org"); + }); + + test("finds contexts by normalized API URL", async () => { + const configData = { + currentContext: "lobu", + contexts: { + lobu: { apiUrl: "https://app.lobu.ai/api/v1" }, + custom: { apiUrl: "https://custom.lobu.ai/api/v1" }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + const matched = await findContextByUrl("https://custom.lobu.ai/api/v1/"); + expect(matched?.name).toBe("custom"); + expect(matched?.apiUrl).toBe("https://custom.lobu.ai/api/v1"); + + const none = await findContextByUrl("https://unknown.ai"); + expect(none).toBeUndefined(); + }); + + test("finds contexts by normalized memory URL", async () => { + const configData = { + currentContext: "lobu", + contexts: { + lobu: { apiUrl: "https://app.lobu.ai/api/v1" }, + local: { + apiUrl: "http://localhost:8787/api/v1", + memoryUrl: "http://localhost:8787/mcp/acme", + }, + }, + }; + readFileSpy.mockResolvedValue(JSON.stringify(configData)); + + const matched = await findContextByMemoryUrl("http://localhost:8787/mcp"); + + expect(matched?.name).toBe("local"); + }); +}); diff --git a/packages/cli/src/internal/api-client.ts b/packages/cli/src/internal/api-client.ts new file mode 100644 index 000000000..72edf3804 --- /dev/null +++ b/packages/cli/src/internal/api-client.ts @@ -0,0 +1,334 @@ +import { + findContextByUrl, + getActiveOrg, + resolveContext, + type ResolvedContext, +} from "./context.js"; +import { getToken, loadCredentials } from "./credentials.js"; + +export interface ApiClientOptions { + context?: string; + org?: string; + apiUrl?: string; + fetchImpl?: typeof fetch; +} + +export interface ResolvedApiClient { + client: ApiClient; + contextName: string; + apiBaseUrl: string; + orgSlug: string; + token: string; +} + +export interface OrganizationInfo { + slug: string; + name?: string; +} + +export class ApiClientError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly code?: string + ) { + super(message); + this.name = "ApiClientError"; + } +} + +export class ApiClient { + constructor( + private readonly apiBaseUrl: string, + private readonly token: string, + private readonly fetchImpl: typeof fetch = fetch + ) {} + + async request( + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + path: string, + body?: unknown, + options: { okStatuses?: number[] } = {} + ): Promise { + const url = path.startsWith("http") ? path : `${this.apiBaseUrl}${path}`; + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + }; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + + const response = await this.fetchImpl(url, init); + const parsed = await parseResponse(response, url); + const okStatuses = options.okStatuses ?? [200, 201, 204]; + if (!response.ok || !okStatuses.includes(response.status)) { + const { message, code } = extractError(parsed, response); + throw new ApiClientError( + `${method} ${path} failed: ${message}`, + response.status, + code + ); + } + return parsed as T; + } + + get(path: string): Promise { + return this.request("GET", path); + } + + post(path: string, body?: unknown): Promise { + return this.request("POST", path, body); + } + + patch(path: string, body?: unknown): Promise { + return this.request("PATCH", path, body); + } + + delete(path: string): Promise { + return this.request("DELETE", path, undefined, { + okStatuses: [200, 204], + }); + } +} + +export async function resolveApiClient( + options: ApiClientOptions = {} +): Promise { + const target = await resolveApiTarget(options); + const apiBaseUrl = apiBaseFromContextUrl(target.apiUrl); + const token = process.env.LOBU_API_TOKEN || (await getToken(target.name)); + + if (!token) { + throw new ApiClientError( + `Not logged in to context "${target.name}". Run \`lobu login${options.context ? ` --context ${target.name}` : ""}\` first.`, + 401 + ); + } + + const orgSlug = await resolveOrgSlug({ + ...options, + contextName: target.name, + token, + apiBaseUrl, + useStoredUserInfoEndpoint: target.useStoredUserInfoEndpoint, + }); + + return { + client: new ApiClient(apiBaseUrl, token, options.fetchImpl), + contextName: target.name, + apiBaseUrl, + orgSlug, + token, + }; +} + +export async function listOrganizations( + options: Pick = {} +): Promise { + const target = await resolveApiTarget(options); + const token = process.env.LOBU_API_TOKEN || (await getToken(target.name)); + if (!token) { + throw new ApiClientError( + `Not logged in to context "${target.name}". Run \`lobu login${options.context ? ` --context ${target.name}` : ""}\` first.`, + 401 + ); + } + return getOrganizationsFromUserInfo( + target.name, + token, + apiBaseFromContextUrl(target.apiUrl), + options.fetchImpl, + { useStoredUserInfoEndpoint: target.useStoredUserInfoEndpoint } + ); +} + +interface ResolvedApiTarget extends ResolvedContext { + useStoredUserInfoEndpoint: boolean; +} + +async function resolveApiTarget( + options: Pick +): Promise { + const requested = await resolveContext(options.context); + if (!options.apiUrl) { + return { ...requested, useStoredUserInfoEndpoint: true }; + } + + const matched = await findContextByUrl(options.apiUrl); + if (matched) { + return { ...matched, useStoredUserInfoEndpoint: true }; + } + + const apiBaseUrl = apiBaseFromContextUrl(options.apiUrl); + const contextApiBaseUrl = apiBaseFromContextUrl(requested.apiUrl); + if (!process.env.LOBU_API_TOKEN && apiBaseUrl !== contextApiBaseUrl) { + throw new ApiClientError( + `Refusing to send stored context credentials for "${requested.name}" to ${apiBaseUrl}. Add a context for that URL or set LOBU_API_TOKEN explicitly.` + ); + } + + return { + ...requested, + apiUrl: options.apiUrl, + useStoredUserInfoEndpoint: apiBaseUrl === contextApiBaseUrl, + }; +} + +export function apiBaseFromContextUrl(apiUrl: string): string { + const url = new URL(apiUrl); + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +async function resolveOrgSlug( + options: ApiClientOptions & { + contextName: string; + token: string; + apiBaseUrl: string; + useStoredUserInfoEndpoint: boolean; + } +): Promise { + const explicit = options.org?.trim() || process.env.LOBU_ORG?.trim(); + if (explicit) return validateOrgSlug(explicit); + + const active = await getActiveOrg(options.contextName); + if (active) return validateOrgSlug(active); + + const organizations = await getOrganizationsFromUserInfo( + options.contextName, + options.token, + options.apiBaseUrl, + options.fetchImpl, + { useStoredUserInfoEndpoint: options.useStoredUserInfoEndpoint } + ).catch(() => []); + + if (organizations.length === 1) { + return validateOrgSlug(organizations[0]!.slug); + } + + if (organizations.length > 1) { + throw new ApiClientError( + `Multiple organizations are available (${organizations.map((org) => org.slug).join(", ")}). Run \`lobu org set \` or pass \`--org \`.` + ); + } + + throw new ApiClientError( + "No organization selected. Run `lobu org set ` or pass `--org `." + ); +} + +async function getOrganizationsFromUserInfo( + contextName: string, + token: string, + apiBaseUrl: string, + fetchImpl: typeof fetch = fetch, + options: { useStoredUserInfoEndpoint?: boolean } = {} +): Promise { + const creds = + options.useStoredUserInfoEndpoint === false + ? null + : await loadCredentials(contextName); + const endpoint = + creds?.oauth?.userinfoEndpoint ?? `${apiBaseUrl}/oauth/userinfo`; + const response = await fetchImpl(endpoint, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) return []; + const data = (await response.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!data) return []; + const orgs = Array.isArray(data.organizations) ? data.organizations : []; + const result: OrganizationInfo[] = []; + for (const entry of orgs) { + if (!entry || typeof entry !== "object") continue; + const value = entry as Record; + const slug = typeof value.slug === "string" ? value.slug : ""; + if (!slug) continue; + result.push({ + slug, + ...(typeof value.name === "string" ? { name: value.name } : {}), + }); + } + return result; +} + +async function parseResponse( + response: Response, + url: string +): Promise { + if (response.status === 204) return undefined; + const raw = await response.text(); + if (!raw) return undefined; + try { + return JSON.parse(raw) as unknown; + } catch { + if (!response.ok) return { error: raw }; + throw new ApiClientError( + `Invalid JSON from ${url}: ${raw.slice(0, 500)}`, + response.status + ); + } +} + +function extractError( + parsed: unknown, + response: Response +): { message: string; code?: string } { + if (parsed && typeof parsed === "object") { + const record = parsed as Record; + if (typeof record.error === "string") { + return { + message: + pickString(record, "error_description") ?? + pickString(record, "message") ?? + record.error, + code: pickString(record, "code") ?? record.error, + }; + } + if (record.error && typeof record.error === "object") { + const error = record.error as Record; + return { + message: + pickString(error, "message") ?? + `HTTP ${response.status} ${response.statusText}`, + code: pickString(error, "code"), + }; + } + if (typeof record.message === "string") { + return { message: record.message, code: pickString(record, "code") }; + } + if (typeof record.error_description === "string") { + return { + message: record.error_description, + code: pickString(record, "error"), + }; + } + } + return { message: `HTTP ${response.status} ${response.statusText}` }; +} + +function pickString( + record: Record, + key: string +): string | undefined { + return typeof record[key] === "string" ? record[key] : undefined; +} + +function validateOrgSlug(slug: string): string { + if (!/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/.test(slug)) { + throw new ApiClientError( + `Invalid organization slug "${slug}". Slugs may only contain alphanumeric characters, hyphens, and underscores.` + ); + } + return slug; +} diff --git a/packages/cli/src/internal/context.ts b/packages/cli/src/internal/context.ts index d04737769..fe1c8dadd 100644 --- a/packages/cli/src/internal/context.ts +++ b/packages/cli/src/internal/context.ts @@ -8,8 +8,12 @@ const DEFAULT_API_URL = "https://app.lobu.ai/api/v1"; const CONTEXTS_FILE = join(LOBU_CONFIG_DIR, "config.json"); +export const DEFAULT_MEMORY_URL = "https://lobu.ai/mcp"; + interface LobuContextEntry { apiUrl: string; + activeOrg?: string; + memoryUrl?: string; } interface LobuContextConfig { @@ -17,7 +21,7 @@ interface LobuContextConfig { contexts: Record; } -interface ResolvedContext { +export interface ResolvedContext { name: string; apiUrl: string; source: "default" | "config" | "env"; @@ -55,6 +59,75 @@ export async function getCurrentContextName(): Promise { return config.currentContext; } +export async function getActiveOrg( + contextName?: string +): Promise { + const envOrg = process.env.LOBU_ORG?.trim(); + if (envOrg) return envOrg; + + const config = await loadContextConfig(); + const name = contextName || config.currentContext; + return config.contexts[name]?.activeOrg; +} + +export async function getMemoryUrl(contextName?: string): Promise { + const envUrl = process.env.LOBU_MEMORY_URL?.trim(); + if (envUrl) return normalizeApiUrl(envUrl); + + const config = await loadContextConfig(); + const name = contextName || config.currentContext; + return normalizeApiUrl( + config.contexts[name]?.memoryUrl || DEFAULT_MEMORY_URL + ); +} + +export async function setActiveOrg( + orgSlug: string, + contextName?: string +): Promise { + const trimmed = orgSlug.trim(); + if (!trimmed) { + throw new Error("Organization slug cannot be empty."); + } + if (!/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/.test(trimmed)) { + throw new Error( + `Invalid organization slug "${orgSlug}". Slugs may only contain alphanumeric characters, hyphens, and underscores.` + ); + } + + const config = await loadContextConfig(); + const name = contextName || config.currentContext; + const context = config.contexts[name]; + if (!context) { + throw new Error(`Unknown context "${name}".`); + } + + context.activeOrg = trimmed; + await saveContextConfig(config); + return config; +} + +export async function setMemoryUrl( + memoryUrl: string, + contextName?: string +): Promise { + const trimmed = memoryUrl.trim(); + if (!trimmed) { + throw new Error("Memory URL cannot be empty."); + } + + const config = await loadContextConfig(); + const name = contextName || config.currentContext; + const context = config.contexts[name]; + if (!context) { + throw new Error(`Unknown context "${name}".`); + } + + context.memoryUrl = normalizeAndValidateApiUrl(trimmed); + await saveContextConfig(config); + return config; +} + export async function resolveContext( preferredContext?: string ): Promise { @@ -132,7 +205,17 @@ function normalizeContextConfig(raw: StoredContextConfig): LobuContextConfig { if (!value || typeof value.apiUrl !== "string") { continue; } - contexts[name] = { apiUrl: normalizeApiUrl(value.apiUrl) }; + contexts[name] = { + apiUrl: normalizeApiUrl(value.apiUrl), + activeOrg: + typeof value.activeOrg === "string" + ? value.activeOrg.trim() + : undefined, + memoryUrl: + typeof value.memoryUrl === "string" + ? value.memoryUrl.trim() + : undefined, + }; } const currentContext = @@ -140,7 +223,10 @@ function normalizeContextConfig(raw: StoredContextConfig): LobuContextConfig { ? raw.currentContext : DEFAULT_CONTEXT_NAME; - return { currentContext, contexts }; + return { + currentContext, + contexts, + }; } function normalizeAndValidateApiUrl(apiUrl: string): string { @@ -168,3 +254,64 @@ function normalizeApiUrl(url: string): string { } return end === url.length ? url : url.slice(0, end); } + +export async function findContextByUrl( + apiUrl: string +): Promise { + const config = await loadContextConfig(); + const normalizedSearch = normalizeApiUrl(apiUrl); + + for (const [name, context] of Object.entries(config.contexts)) { + if (normalizeApiUrl(context.apiUrl) === normalizedSearch) { + return contextToResolvedContext(name, context); + } + } + + return undefined; +} + +export async function findContextByMemoryUrl( + memoryUrl: string +): Promise { + const config = await loadContextConfig(); + const normalizedSearch = normalizeMemoryBaseUrl(memoryUrl); + + for (const [name, context] of Object.entries(config.contexts)) { + const candidate = normalizeMemoryBaseUrl( + context.memoryUrl || DEFAULT_MEMORY_URL + ); + if (candidate === normalizedSearch) { + return contextToResolvedContext(name, context); + } + } + + return undefined; +} + +function contextToResolvedContext( + name: string, + context: LobuContextEntry +): ResolvedContext { + return { + name, + apiUrl: normalizeApiUrl(context.apiUrl), + source: name === DEFAULT_CONTEXT_NAME ? "default" : "config", + }; +} + +function normalizeMemoryBaseUrl(input: string): string { + try { + const url = new URL(input); + url.hash = ""; + url.search = ""; + if (!url.pathname || url.pathname === "/") { + url.pathname = "/mcp"; + } else if (!url.pathname.startsWith("/mcp")) { + url.pathname = `${url.pathname.replace(/\/+$/, "")}/mcp`; + } + url.pathname = "/mcp"; + return url.toString().replace(/\/+$/, ""); + } catch { + return normalizeApiUrl(input); + } +} diff --git a/packages/cli/src/internal/gateway-url.ts b/packages/cli/src/internal/gateway-url.ts index 85259867a..49aa84535 100644 --- a/packages/cli/src/internal/gateway-url.ts +++ b/packages/cli/src/internal/gateway-url.ts @@ -2,16 +2,16 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { parseEnvContent } from "./env-file.js"; -export const GATEWAY_DEFAULT_URL = "http://localhost:8080"; +export const GATEWAY_DEFAULT_URL = "http://localhost:8787"; interface ResolveGatewayUrlOptions { cwd?: string; } /** - * Resolve the local gateway URL by reading `GATEWAY_PORT` from the project's - * `.env` file (if present). Falls back to `GATEWAY_DEFAULT_URL` when the file - * is missing or the variable is not set. + * Resolve the local gateway URL by reading `GATEWAY_PORT` / `PORT` from the + * project's `.env` file (if present). Falls back to `GATEWAY_DEFAULT_URL` when + * the file is missing or neither variable is set. */ export async function resolveGatewayUrl( options: ResolveGatewayUrlOptions = {} @@ -19,7 +19,8 @@ export async function resolveGatewayUrl( const cwd = options.cwd ?? process.cwd(); try { const envContent = await readFile(join(cwd, ".env"), "utf-8"); - const port = parseEnvContent(envContent).GATEWAY_PORT; + const parsed = parseEnvContent(envContent); + const port = parsed.GATEWAY_PORT || parsed.PORT; if (port) return `http://localhost:${port}`; } catch { // No .env file diff --git a/packages/cli/src/internal/index.ts b/packages/cli/src/internal/index.ts index f44a195b3..11a6963ce 100644 --- a/packages/cli/src/internal/index.ts +++ b/packages/cli/src/internal/index.ts @@ -1,10 +1,18 @@ export { + DEFAULT_MEMORY_URL, addContext, + findContextByMemoryUrl, + findContextByUrl, + getActiveOrg, getCurrentContextName, + getMemoryUrl, loadContextConfig, resolveContext, + setActiveOrg, setCurrentContext, + setMemoryUrl, } from "./context.js"; +export type { ResolvedContext } from "./context.js"; export { type Credentials, type OAuthClientInfo, @@ -16,6 +24,11 @@ export { } from "./credentials.js"; export { parseEnvContent } from "./env-file.js"; export { - GATEWAY_DEFAULT_URL, - resolveGatewayUrl, -} from "./gateway-url.js"; + type OrganizationInfo, + ApiClient, + ApiClientError, + apiBaseFromContextUrl, + listOrganizations, + resolveApiClient, +} from "./api-client.js"; +export { GATEWAY_DEFAULT_URL, resolveGatewayUrl } from "./gateway-url.js"; diff --git a/packages/cli/src/internal/local-env.ts b/packages/cli/src/internal/local-env.ts new file mode 100644 index 000000000..1e58f9cbf --- /dev/null +++ b/packages/cli/src/internal/local-env.ts @@ -0,0 +1,36 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export async function setLocalEnvValue( + cwd: string, + key: string, + value: string +): Promise { + const envPath = join(cwd, ".env"); + let content = ""; + try { + content = await readFile(envPath, "utf-8"); + } catch { + content = ""; + } + + const serialized = `${key}=${formatEnvValue(value)}`; + const trimmed = content.trimEnd(); + const lines = trimmed ? trimmed.split("\n") : []; + let found = false; + const updated = lines.map((line) => { + if (line.trim().startsWith(`${key}=`)) { + found = true; + return serialized; + } + return line; + }); + + if (!found) updated.push(serialized); + await writeFile(envPath, updated.join("\n")); +} + +function formatEnvValue(value: string): string { + if (!/[\s#"'\\]/.test(value)) return value; + return JSON.stringify(value); +} diff --git a/packages/cli/src/templates/.env.tmpl b/packages/cli/src/templates/.env.tmpl index bd00265ee..2e3f5c727 100644 --- a/packages/cli/src/templates/.env.tmpl +++ b/packages/cli/src/templates/.env.tmpl @@ -8,7 +8,6 @@ GATEWAY_PORT={{GATEWAY_PORT}} DATABASE_URL= # Security -ADMIN_PASSWORD={{ADMIN_PASSWORD}} ENCRYPTION_KEY={{ENCRYPTION_KEY}} # Worker Network Access Control diff --git a/packages/landing/src/components/InstallSection.tsx b/packages/landing/src/components/InstallSection.tsx index ce6a63c01..ac1f5bef9 100644 --- a/packages/landing/src/components/InstallSection.tsx +++ b/packages/landing/src/components/InstallSection.tsx @@ -12,7 +12,7 @@ const localDev = { label: "Set DATABASE_URL in .env, then boot", code: "cd my-agent && npx @lobu/cli@latest run", }, - { label: "Open the docs", code: "open http://localhost:8080/api/docs" }, + { label: "Open the docs", code: "open http://localhost:8787/api/docs" }, ], }; diff --git a/packages/landing/src/components/SkillsRegistryTable.tsx b/packages/landing/src/components/SkillsRegistryTable.tsx index f5eaf6a79..223d09d48 100644 --- a/packages/landing/src/components/SkillsRegistryTable.tsx +++ b/packages/landing/src/components/SkillsRegistryTable.tsx @@ -15,7 +15,7 @@ const headerCellStyle = { const starterSkills = [ { product: "Lobu", - install: "npx @lobu/cli@latest skills add lobu", + install: "Enable from the agent settings UI", adds: "The Lobu starter skill in skills/lobu/ (includes memory guidance)", }, ]; @@ -25,8 +25,8 @@ export function SkillsRegistryTable() {

Starter Skills

- Lobu ships one starter-skill installer. After install, Lobu discovers - local skills from skills/<name>/SKILL.md or{" "} + Lobu ships one starter skill. Lobu also discovers local skills from{" "} + skills/<name>/SKILL.md or{" "} agents/<agent-id>/skills/<name>/SKILL.md.

@@ -40,7 +40,7 @@ export function SkillsRegistryTable() { Product - Install command + Install What it adds diff --git a/packages/landing/src/content/blog/hello-world.mdx b/packages/landing/src/content/blog/hello-world.mdx index 475008b37..7c8014d67 100644 --- a/packages/landing/src/content/blog/hello-world.mdx +++ b/packages/landing/src/content/blog/hello-world.mdx @@ -36,7 +36,7 @@ OpenClaw is a great agent runtime. But running it for a team exposes real proble Every agent is configurable through a settings page — providers, skills, MCP servers, Nix packages, and permissions. All without touching config files. -**Skills** are modular bundles of instructions, MCP servers, system packages, and network requirements. A skill declares what it needs: integrations, Nix packages, and domains to allowlist. Tool visibility and approval policy live separately in `lobu.toml`, which keeps the capability manifest distinct from security controls. Lobu ships a bundled starter skill with project and memory guidance that you can install with `npx @lobu/cli@latest skills add lobu`. Teams can still create project-owned local skills, and agents can request skill installation mid-conversation — the user gets a prefilled settings link, approves, and the agent resumes. +**Skills** are modular bundles of instructions, MCP servers, system packages, and network requirements. A skill declares what it needs: integrations, Nix packages, and domains to allowlist. Tool visibility and approval policy live separately in `lobu.toml`, which keeps the capability manifest distinct from security controls. Lobu ships a bundled starter skill with project and memory guidance that you can enable from the agent settings UI. Teams can still create project-owned local skills, and agents can request skill installation mid-conversation — the user gets a prefilled settings link, approves, and the agent resumes. **Nix** is how we handle reproducible environments. Instead of baking every possible tool into a runtime image, users install what they need from the settings page — `ffmpeg`, `python`, `curl`, whatever. Nix gives us deterministic, conflict-free package management across sandboxes. It's the same approach [Replit uses](https://blog.replit.com/nix) for their development environments, and for the same reason: when you have many isolated environments, you need package management that's reproducible and doesn't break between runs. diff --git a/packages/landing/src/content/blog/mcp-is-overengineered-skills-are-too-primitive.mdx b/packages/landing/src/content/blog/mcp-is-overengineered-skills-are-too-primitive.mdx index 84533a6c5..ee7228d3d 100644 --- a/packages/landing/src/content/blog/mcp-is-overengineered-skills-are-too-primitive.mdx +++ b/packages/landing/src/content/blog/mcp-is-overengineered-skills-are-too-primitive.mdx @@ -130,11 +130,11 @@ First boot pays the install cost. After that packages are cached in the persiste #### Starter skills ```bash -$ npx @lobu/cli@latest skills add lobu +$ npx @lobu/cli@latest agent config patch my-agent --file skill.patch.json Installed "lobu" → ./skills/lobu -$ npx @lobu/cli@latest skills add lobu +$ npx @lobu/cli@latest agent config patch my-agent --file skill.patch.json Installed "owletto" → ./skills/lobu ``` diff --git a/packages/landing/src/content/docs/getting-started/index.mdx b/packages/landing/src/content/docs/getting-started/index.mdx index a78e5923d..78ef20d40 100644 --- a/packages/landing/src/content/docs/getting-started/index.mdx +++ b/packages/landing/src/content/docs/getting-started/index.mdx @@ -95,29 +95,21 @@ Lobu supports two configuration approaches depending on how you deploy: ### CLI -After `init`, your project is configured through `lobu.toml`. Use the CLI to add providers and install starter skills at any time: +Runtime agent configuration lives in the web app/API. Use the UI for providers, platforms, and skills; use local files for validate/apply workflows: ```bash -# Browse and add providers -npx @lobu/cli@latest providers list -npx @lobu/cli@latest providers add gemini # prompts for API key, updates lobu.toml + .env +# Select the target org and inspect remote agents +npx @lobu/cli@latest org set my-org +npx @lobu/cli@latest agent list -# Install the Lobu starter skill into skills/lobu -npx @lobu/cli@latest skills list -npx @lobu/cli@latest skills add lobu - -# Install the bundled Lobu starter skill -npx @lobu/cli@latest skills add lobu - -# Manage secrets -npx @lobu/cli@latest secrets set GEMINI_API_KEY AIza... -npx @lobu/cli@latest secrets list - -# Validate and run +# Validate/apply local artifacts when you are using a checked-out project npx @lobu/cli@latest validate +npx @lobu/cli@latest apply --org my-org + +# Run the embedded app/server locally npx @lobu/cli@latest run ``` See the [CLI Reference](/reference/cli/) for all available commands. -The CLI exposes an admin page at `/agents` and a REST API at `/api/docs` for runtime management — adding providers, connections, and viewing agent status. +The web app and CLI use the same org-scoped REST API for runtime management — adding providers, connections, skills, and viewing agent status. diff --git a/packages/landing/src/content/docs/getting-started/skills.mdx b/packages/landing/src/content/docs/getting-started/skills.mdx index e0a4d3615..21f3055fb 100644 --- a/packages/landing/src/content/docs/getting-started/skills.mdx +++ b/packages/landing/src/content/docs/getting-started/skills.mdx @@ -19,7 +19,7 @@ Lobu still discovers local `SKILL.md` files at runtime. The CLI starter-skill co The bundled Lobu starter skill includes project, runtime, and memory guidance. ```bash -npx @lobu/cli@latest skills add lobu +# Enable the Lobu skill from the agent settings UI ``` Use the **Lobu** skill when the agent should understand Lobu projects, `lobu.toml`, prompt files, evals, project structure, memory tools, watchers, and client setup. diff --git a/packages/landing/src/content/docs/guides/admin-ui.md b/packages/landing/src/content/docs/guides/admin-ui.md index 168d8b49d..6b904742b 100644 --- a/packages/landing/src/content/docs/guides/admin-ui.md +++ b/packages/landing/src/content/docs/guides/admin-ui.md @@ -13,8 +13,8 @@ Agents are created via `lobu.toml` (at startup) or the API (at runtime): ```bash # Via API -curl -X POST http://localhost:8080/api/v1/agents \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/agents \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agentId": "support", "name": "Support Agent" }' ``` @@ -24,8 +24,8 @@ Returns the agent ID and a settings URL for configuration. ### List agents ```bash -curl http://localhost:8080/api/v1/agents \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl http://localhost:8787/api/v1/agents \ + -H "Authorization: Bearer $LOBU_API_TOKEN" ``` Returns all agents with their name, description, channel count, and last activity. @@ -33,8 +33,8 @@ Returns all agents with their name, description, channel count, and last activit ### Update an agent ```bash -curl -X PATCH http://localhost:8080/api/v1/agents/support \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X PATCH http://localhost:8787/api/v1/agents/support \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Customer Support", "description": "Handles billing and account questions" }' ``` @@ -42,8 +42,8 @@ curl -X PATCH http://localhost:8080/api/v1/agents/support \ ### Delete an agent ```bash -curl -X DELETE http://localhost:8080/api/v1/agents/support \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl -X DELETE http://localhost:8787/api/v1/agents/support \ + -H "Authorization: Bearer $LOBU_API_TOKEN" ``` Unbinds all platform channels and removes the agent configuration. @@ -53,8 +53,8 @@ Unbinds all platform channels and removes the agent configuration. Fetch the full configuration for an agent: ```bash -curl http://localhost:8080/api/v1/agents/support/config \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl http://localhost:8787/api/v1/agents/support/config \ + -H "Authorization: Bearer $LOBU_API_TOKEN" ``` This returns everything about the agent: @@ -83,8 +83,8 @@ When a user creates a new agent through a platform connection (e.g., a new Slack ### Add a provider via API key ```bash -curl -X POST http://localhost:8080/api/v1/auth/openai/save-key \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/auth/openai/save-key \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agentId": "support", "apiKey": "sk-..." }' ``` @@ -95,14 +95,14 @@ Some providers use device-code auth: ```bash # Start the flow -curl -X POST http://localhost:8080/api/v1/auth/github/start \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/auth/github/start \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agentId": "support" }' # Poll for completion -curl -X POST http://localhost:8080/api/v1/auth/github/poll \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/auth/github/poll \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agentId": "support", "deviceAuthId": "..." }' ``` @@ -110,8 +110,8 @@ curl -X POST http://localhost:8080/api/v1/auth/github/poll \ ### Remove a provider ```bash -curl -X POST http://localhost:8080/api/v1/auth/openai/logout \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/auth/openai/logout \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agentId": "support" }' ``` @@ -119,8 +119,8 @@ curl -X POST http://localhost:8080/api/v1/auth/openai/logout \ ### Browse available providers ```bash -curl http://localhost:8080/api/v1/agents/support/config/providers/catalog \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl http://localhost:8787/api/v1/agents/support/config/providers/catalog \ + -H "Authorization: Bearer $LOBU_API_TOKEN" ``` Returns providers that are not yet installed for this agent. @@ -131,16 +131,16 @@ Check agent status, view messages, and get session stats: ```bash # Is the agent online? -curl http://localhost:8080/api/v1/agents/support/history/status \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl http://localhost:8787/api/v1/agents/support/history/status \ + -H "Authorization: Bearer $LOBU_API_TOKEN" # Get recent messages (paginated) -curl http://localhost:8080/api/v1/agents/support/history/session/messages \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl http://localhost:8787/api/v1/agents/support/history/session/messages \ + -H "Authorization: Bearer $LOBU_API_TOKEN" # Session statistics (message counts, token usage) -curl http://localhost:8080/api/v1/agents/support/history/session/stats \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl http://localhost:8787/api/v1/agents/support/history/session/stats \ + -H "Authorization: Bearer $LOBU_API_TOKEN" ``` ## Channel bindings @@ -148,8 +148,8 @@ curl http://localhost:8080/api/v1/agents/support/history/session/stats \ View which platform channels are bound to an agent: ```bash -curl http://localhost:8080/api/v1/agents/support/channels \ - -H "Authorization: Bearer $ADMIN_PASSWORD" +curl http://localhost:8787/api/v1/agents/support/channels \ + -H "Authorization: Bearer $LOBU_API_TOKEN" ``` ## Interactive API reference diff --git a/packages/landing/src/content/docs/guides/evals.md b/packages/landing/src/content/docs/guides/evals.md index a29a75f8c..01c914f3d 100644 --- a/packages/landing/src/content/docs/guides/evals.md +++ b/packages/landing/src/content/docs/guides/evals.md @@ -189,7 +189,7 @@ When a rubric is present, its score is weighted 50% alongside assertion scores ( | Flag | Description | |------|-------------| | `-a, --agent ` | Agent ID (defaults to first in `lobu.toml`) | -| `-g, --gateway ` | Gateway URL (default: from `.env` or `http://localhost:8080`) | +| `-g, --gateway ` | Gateway URL (default: from `.env` or `http://localhost:8787`) | | `-m, --model ` | Model to evaluate (e.g., `anthropic/claude-sonnet-4`) | | `--trials ` | Override trial count for all evals | | `--ci` | CI mode: JSON output, exit code 1 on any failure | diff --git a/packages/landing/src/content/docs/guides/testing.md b/packages/landing/src/content/docs/guides/testing.md index 92fd9f837..f3342d8e2 100644 --- a/packages/landing/src/content/docs/guides/testing.md +++ b/packages/landing/src/content/docs/guides/testing.md @@ -102,15 +102,15 @@ TEST_PLATFORM=whatsapp TEST_CHANNEL=+1234567890 ./scripts/test-bot.sh "Hello" | `TEST_CHANNEL` | Channel/chat ID for the target platform | | `TEST_TIMEOUT` | Response timeout in seconds (default: 120) | | `TEST_AGENT_ID` | Agent ID to test (default: `test-{platform}`) | -| `GATEWAY_URL` | Gateway URL (default: `http://localhost:8080`) | +| `GATEWAY_URL` | Gateway URL (default: `http://localhost:8787`) | ## REST API Send messages directly to the gateway HTTP API: ```bash -curl -X POST http://localhost:8080/api/v1/agents/{agentId}/messages \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/agents/{agentId}/messages \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "platform": "api", @@ -122,8 +122,8 @@ Route through a platform by adding platform-specific fields: ```bash # Through Slack -curl -X POST http://localhost:8080/api/v1/agents/{agentId}/messages \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/agents/{agentId}/messages \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "platform": "slack", @@ -132,8 +132,8 @@ curl -X POST http://localhost:8080/api/v1/agents/{agentId}/messages \ }' # Through Telegram -curl -X POST http://localhost:8080/api/v1/agents/{agentId}/messages \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/agents/{agentId}/messages \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "platform": "telegram", diff --git a/packages/landing/src/content/docs/platforms/discord.mdx b/packages/landing/src/content/docs/platforms/discord.mdx index 1e318f012..b6c8a3272 100644 --- a/packages/landing/src/content/docs/platforms/discord.mdx +++ b/packages/landing/src/content/docs/platforms/discord.mdx @@ -17,7 +17,7 @@ Under the hood, Lobu uses the Chat SDK Discord adapter to handle DMs and server 2. Under **Bot**, create a bot and copy the **bot token**. 3. Copy the **Application ID** and **Public Key** from the General Information page. 4. Under **OAuth2 → URL Generator**, select the `bot` scope with `Send Messages`, `Read Message History`, and `Use Slash Commands` permissions. Use the generated URL to invite the bot to your server. -5. Run `lobu platforms add discord` to add the connection (prompts for the bot token), or run `lobu init` to scaffold a new project and pick Discord in the wizard. +5. Use Agents → Platforms in the web app to add Discord to your agent, or run `lobu init` to scaffold a local project and pick Discord in the wizard. ## Configuration diff --git a/packages/landing/src/content/docs/platforms/google-chat.mdx b/packages/landing/src/content/docs/platforms/google-chat.mdx index 086d2a625..28cb43167 100644 --- a/packages/landing/src/content/docs/platforms/google-chat.mdx +++ b/packages/landing/src/content/docs/platforms/google-chat.mdx @@ -19,7 +19,7 @@ Under the hood, Lobu uses `@chat-adapter/gchat` with the Google Chat API and Wor 3. In the [Google Chat API configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat), configure the app: - Set the **App URL** to your gateway's webhook endpoint: `https://your-gateway/api/v1/webhooks/{connectionId}` - Enable **Interactive features** and configure the connection settings. -4. Run `lobu platforms add gchat` to add the connection (prompts for the service account JSON), or run `lobu init` to scaffold a new project and pick Google Chat in the wizard. +4. Use Agents → Platforms in the web app to add Google Chat to your agent, or run `lobu init` to scaffold a local project and pick Google Chat in the wizard. ## Configuration diff --git a/packages/landing/src/content/docs/platforms/rest-api.md b/packages/landing/src/content/docs/platforms/rest-api.md index 644bfb50d..814c51447 100644 --- a/packages/landing/src/content/docs/platforms/rest-api.md +++ b/packages/landing/src/content/docs/platforms/rest-api.md @@ -23,8 +23,8 @@ The reference is auto-generated from the gateway's OpenAPI spec and always refle ```bash # Send a message to an agent -curl -X POST http://localhost:8080/api/v1/agents/{agentId}/messages \ - -H "Authorization: Bearer $ADMIN_PASSWORD" \ +curl -X POST http://localhost:8787/api/v1/agents/{agentId}/messages \ + -H "Authorization: Bearer $LOBU_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "platform": "api", diff --git a/packages/landing/src/content/docs/platforms/slack.mdx b/packages/landing/src/content/docs/platforms/slack.mdx index 487e2858f..6b3fd8527 100644 --- a/packages/landing/src/content/docs/platforms/slack.mdx +++ b/packages/landing/src/content/docs/platforms/slack.mdx @@ -16,7 +16,7 @@ Lobu's Slack adapter supports assistant threads, rich UI, and interactive workfl 1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) and install it to your workspace. 2. Copy the **Signing Secret** and **Bot Token** (`xoxb-...`) from the app settings. -3. Run `lobu platforms add slack` to add the connection to your existing agent (prompts for the bot token and signing secret), or run `lobu init` to scaffold a new project and pick Slack in the wizard. +3. Use Agents → Platforms in the web app to add Slack to your agent, or run `lobu init` to scaffold a local project and pick Slack in the wizard. 4. Start the stack with `lobu run -d` — the bot is now live in your Slack workspace. ## Installation Modes diff --git a/packages/landing/src/content/docs/platforms/teams.mdx b/packages/landing/src/content/docs/platforms/teams.mdx index 781c8e281..41665d285 100644 --- a/packages/landing/src/content/docs/platforms/teams.mdx +++ b/packages/landing/src/content/docs/platforms/teams.mdx @@ -16,7 +16,7 @@ Under the hood, Lobu uses the Chat SDK Teams adapter with Azure Bot Framework. 1. Register a bot in the [Azure Portal](https://portal.azure.com) under **Azure Bot** (or **Bot Framework Registration**). 2. Note the **App ID**, **App Password** (client secret), and **Tenant ID**. 3. In the Azure Bot's **Channels** section, enable the **Microsoft Teams** channel. -4. Run `lobu platforms add teams` to add the connection (prompts for the app ID and app password), or run `lobu init` to scaffold a new project and pick Microsoft Teams in the wizard. +4. Use Agents → Platforms in the web app to add Microsoft Teams to your agent, or run `lobu init` to scaffold a local project and pick Microsoft Teams in the wizard. ## Configuration diff --git a/packages/landing/src/content/docs/platforms/telegram.mdx b/packages/landing/src/content/docs/platforms/telegram.mdx index 03d29256b..cfb24cf1e 100644 --- a/packages/landing/src/content/docs/platforms/telegram.mdx +++ b/packages/landing/src/content/docs/platforms/telegram.mdx @@ -14,7 +14,7 @@ Under the hood, Lobu uses the Telegram Bot API via `@chat-adapter/telegram`. ## Setup 1. Create a bot with [@BotFather](https://t.me/BotFather) on Telegram and copy the bot token. -2. Run `lobu platforms add telegram` to add the connection (prompts for the bot token), or run `lobu init` to scaffold a new project and pick Telegram in the wizard. +2. Use Agents → Platforms in the web app to add Telegram to your agent, or run `lobu init` to scaffold a local project and pick Telegram in the wizard. 3. Start the stack with `lobu run -d` — the bot starts receiving messages immediately. ## Configuration diff --git a/packages/landing/src/content/docs/platforms/whatsapp.mdx b/packages/landing/src/content/docs/platforms/whatsapp.mdx index a4cb09e34..f36822f4e 100644 --- a/packages/landing/src/content/docs/platforms/whatsapp.mdx +++ b/packages/landing/src/content/docs/platforms/whatsapp.mdx @@ -14,7 +14,7 @@ Under the hood, Lobu uses the WhatsApp Business Cloud API for message delivery a ## Setup 1. Obtain a WhatsApp Business **access token**, **phone number ID**, **app secret**, and **verify token** from the [Meta Developer Portal](https://developers.facebook.com/). -2. Run `lobu platforms add whatsapp` to add the connection (prompts for the access token and phone number ID), or run `lobu init` to scaffold a new project and pick WhatsApp in the wizard. +2. Use Agents → Platforms in the web app to add WhatsApp to your agent, or run `lobu init` to scaffold a local project and pick WhatsApp in the wizard. 3. Configure the webhook URL in the Meta Developer Portal to point to your gateway's webhook endpoint. 4. Start the stack with `lobu run -d` — the bot starts handling messages on the configured phone number. diff --git a/packages/landing/src/content/docs/reference/cli.md b/packages/landing/src/content/docs/reference/cli.md index 3ac01264f..52eb5f41a 100644 --- a/packages/landing/src/content/docs/reference/cli.md +++ b/packages/landing/src/content/docs/reference/cli.md @@ -5,7 +5,7 @@ sidebar: order: 0 --- -The Lobu CLI (`@lobu/cli`) scaffolds projects, runs agents locally, and manages deployments. +The Lobu CLI (`@lobu/cli`) scaffolds local project files, runs the embedded server, and manages org/agent configuration through the same REST API used by the web app. ## Install @@ -22,7 +22,7 @@ lobu ### `init [name]` -Scaffold a new agent project with `lobu.toml`, `.env`, and an agent directory. +Scaffold a local agent project with `lobu.toml`, `.env`, and an agent directory. ```bash npx @lobu/cli@latest init my-agent @@ -30,249 +30,204 @@ npx @lobu/cli@latest init my-agent Generates: -- `lobu.toml` — agent configuration (skills, providers, connections, network) -- `.env` — credentials and environment variables (set `DATABASE_URL` after init) -- `agents/{name}/` — agent directory with `IDENTITY.md`, `SOUL.md`, `USER.md`, and `skills/` -- `skills/` — shared skills directory (available to all agents) +- `lobu.toml` — local project/apply/validate configuration +- `.env` — local environment variables (set `DATABASE_URL` after init) +- `agents/{name}/` — `IDENTITY.md`, `SOUL.md`, `USER.md`, local skills, and evals +- `skills/` — shared local skills directory - `AGENTS.md`, `TESTING.md`, `README.md`, `.gitignore` -Interactive prompts guide you through provider, skills, platform, network access policy, gateway port, public URL, admin password, and memory configuration. Postgres (with pgvector) is the only user-provided external — Lobu does not bundle it. +Interactive prompts guide you through provider, platform, network access policy, gateway port, public URL, and memory configuration. Postgres (with pgvector) is the only user-provided external — Lobu does not bundle it. --- -### `chat ` +### `run` -Send a prompt to an agent and stream the response to the terminal. +Run the embedded Lobu stack. `lobu.toml` is not required; set `DATABASE_URL` in the environment or `.env`, then run: ```bash -npx @lobu/cli@latest chat "What is the weather?" -npx @lobu/cli@latest chat "Hello" --agent my-agent --thread conv-123 -npx @lobu/cli@latest chat "Check my PRs" --user telegram:12345 -npx @lobu/cli@latest chat "Status update" -c staging +npx @lobu/cli@latest run ``` -**API mode** (default): creates a session, sends the message, and streams the response to the terminal. - -**Platform mode** (with `--user`): routes the message through Telegram/Slack/Discord so the response appears on the platform. The terminal also streams the output. - -| Flag | Description | -|------|-------------| -| `-a, --agent ` | Agent ID (defaults to first agent in `lobu.toml`) | -| `-u, --user ` | Route through a platform (e.g. `telegram:12345`, `slack:C0123`) | -| `-t, --thread ` | Thread/conversation ID for multi-turn conversations | -| `-g, --gateway ` | Gateway URL (default: `http://localhost:8080` or from `.env`) | -| `--dry-run` | Process without persisting history | -| `--new` | Force a new session (ignore existing) | -| `-c, --context ` | Use a named context for gateway URL and credentials | +The command spawns the bundled Node server (`@lobu/owletto-backend/dist/server.bundle.mjs`) and forwards stdio. Ctrl+C cleanly stops the server and worker subprocesses. Extra arguments are forwarded to the Node entry point. --- -### `eval [name]` +### `login` -Run agent evaluations. Eval files live in the agent directory and define test cases with expected outcomes. +Authenticate with Lobu via the OAuth 2.0 device-code flow. Prints a verification URL and opens it in the browser; you approve there and the CLI receives the token. ```bash -npx @lobu/cli@latest eval # run all evals -npx @lobu/cli@latest eval basic-qa # run a specific eval -npx @lobu/cli@latest eval --model claude/sonnet # eval with a specific model -npx @lobu/cli@latest eval --ci --output results.json # CI mode with JSON output +npx @lobu/cli@latest login +npx @lobu/cli@latest login --token # CI/CD +npx @lobu/cli@latest login -c staging # login to a named context +npx @lobu/cli@latest login --force # re-authenticate ``` | Flag | Description | |------|-------------| -| `-a, --agent ` | Agent ID (defaults to first in `lobu.toml`) | -| `-g, --gateway ` | Gateway URL (default: `http://localhost:8080`) | -| `-m, --model ` | Model to evaluate (e.g. `claude/sonnet`, `openai/gpt-4.1`) | -| `--trials ` | Override trial count | -| `--ci` | CI mode: JSON output, non-zero exit on failure | -| `--output ` | Write results to JSON file | -| `--list` | List available evals without running them | +| `--token ` | Use an API token directly | +| `-c, --context ` | Authenticate against a named context | +| `-f, --force` | Re-authenticate, revoking the existing OAuth session first | --- -### `run` +### `context` -Run the embedded Lobu stack. Validates `lobu.toml`, checks that `DATABASE_URL` is set in `.env`, then spawns the bundled Node server (`@lobu/owletto-backend/dist/server.bundle.mjs`) as a child process and forwards stdio. Ctrl+C cleanly stops the server and any worker subprocesses. +Manage named API contexts. ```bash -npx @lobu/cli@latest run # boot the gateway + workers + memory backend +npx @lobu/cli@latest context list +npx @lobu/cli@latest context current +npx @lobu/cli@latest context add staging --api-url https://staging.example.com/api/v1 +npx @lobu/cli@latest context use staging ``` -Extra arguments are forwarded to the Node entry point. +Environment overrides: set `LOBU_CONTEXT` to select a context by name, or `LOBU_API_URL` to override the URL directly. --- -### `validate` +### `org` -Validate `lobu.toml` schema, skill IDs, and provider configuration. +Manage the active organization for org-scoped API commands. ```bash -npx @lobu/cli@latest validate +npx @lobu/cli@latest org list +npx @lobu/cli@latest org current +npx @lobu/cli@latest org set my-org ``` -Returns exit code `1` if validation fails. +`LOBU_ORG` overrides the active org for one process. --- -### `context` +### `agent` -Manage named API contexts for switching between local and remote gateways. +Manage agents via the same org-scoped REST endpoints as the web app. ```bash -npx @lobu/cli@latest context list -npx @lobu/cli@latest context current -npx @lobu/cli@latest context add staging --api-url https://staging.example.com -npx @lobu/cli@latest context use staging +npx @lobu/cli@latest agent list +npx @lobu/cli@latest agent get my-agent +npx @lobu/cli@latest agent create my-agent --name "My Agent" +npx @lobu/cli@latest agent update my-agent --description "Handles support" +npx @lobu/cli@latest agent delete my-agent --yes ``` -| Subcommand | Description | -|------------|-------------| -| `list` | List all configured contexts | -| `current` | Show the active context | -| `add --api-url ` | Add a named context | -| `use ` | Set the active context | - -Environment overrides: set `LOBU_CONTEXT` to select a context by name, or `LOBU_API_URL` to override the URL directly. - ---- - -### `login` - -Authenticate with Lobu Cloud via the OAuth 2.0 device-code flow. Prints a -verification URL and opens it in the browser; you approve there and the CLI -receives the token. +Config helpers use the web app's `/config` API: ```bash -npx @lobu/cli@latest login -npx @lobu/cli@latest login --token # CI/CD -npx @lobu/cli@latest login -c staging # login to a named context -npx @lobu/cli@latest login --force # re-authenticate (revokes existing session) +npx @lobu/cli@latest agent config get my-agent --output config.json +npx @lobu/cli@latest agent config patch my-agent --file config.patch.json ``` -| Flag | Description | -|------|-------------| -| `--token ` | Use an API token directly (for CI/CD pipelines) | -| `-c, --context ` | Authenticate against a named context | -| `-f, --force` | Re-authenticate, revoking the existing session first | +Most agent commands accept `--org `, `-c/--context `, and `--json` where useful. --- -### `logout` +### `chat ` -Revoke the session server-side and clear stored credentials. If the gateway is unreachable, local credentials are still cleared. +Send a prompt to an agent and stream the response to the terminal. ```bash -npx @lobu/cli@latest logout -npx @lobu/cli@latest logout -c staging +npx @lobu/cli@latest chat "What is the weather?" +npx @lobu/cli@latest chat "Hello" --agent my-agent --thread conv-123 +npx @lobu/cli@latest chat "Check my PRs" --user telegram:12345 +npx @lobu/cli@latest chat "Status update" -c staging ``` | Flag | Description | |------|-------------| -| `-c, --context ` | Clear credentials for a named context | +| `-a, --agent ` | Agent ID (defaults to first agent in local `lobu.toml` when present) | +| `-u, --user ` | Route through a platform (e.g. `telegram:12345`, `slack:C0123`) | +| `-t, --thread ` | Thread/conversation ID for multi-turn conversations | +| `-g, --gateway ` | Gateway URL (default: `http://localhost:8787` or from `.env`) | +| `--dry-run` | Process without persisting history | +| `--new` | Force a new session | +| `-c, --context ` | Use a named context for gateway URL and credentials | --- -### `whoami` +### `eval [name]` -Show the current authenticated user, linked agent, and API URL. +Run local agent evaluations. Eval files live in the agent directory and define test cases with expected outcomes. ```bash -npx @lobu/cli@latest whoami -npx @lobu/cli@latest whoami -c staging +npx @lobu/cli@latest eval +npx @lobu/cli@latest eval basic-qa +npx @lobu/cli@latest eval --model claude/sonnet +npx @lobu/cli@latest eval --ci --output results.json ``` | Flag | Description | |------|-------------| -| `-c, --context ` | Query a named context | +| `-a, --agent ` | Agent ID (defaults to first in local `lobu.toml`) | +| `-g, --gateway ` | Gateway URL (default: `http://localhost:8787`) | +| `-m, --model ` | Model to evaluate | +| `--trials ` | Override trial count | +| `--ci` | CI mode: JSON output, non-zero exit on failure | +| `--output ` | Write results to JSON file | +| `--list` | List available evals without running them | --- -### `status` +### `validate` -Show agent health: lists agents with their providers and models, platform connections with status, and active sandboxes. Requires the gateway to be running. +Validate local `lobu.toml` schema, skill IDs, and provider configuration. ```bash -npx @lobu/cli@latest status +npx @lobu/cli@latest validate ``` +Returns exit code `1` if validation fails. + --- -### `secrets` +### `apply` -Manage agent secrets (stored in `.env` for local dev). +Sync local `lobu.toml` and agent directories to a Lobu org. ```bash -npx @lobu/cli@latest secrets set OPENAI_API_KEY sk-... -npx @lobu/cli@latest secrets list -npx @lobu/cli@latest secrets delete OPENAI_API_KEY +npx @lobu/cli@latest apply --org my-org +npx @lobu/cli@latest apply --dry-run ``` -| Subcommand | Description | -|------------|-------------| -| `set ` | Set a secret | -| `list` | List secrets (values redacted) | -| `delete ` | Remove a secret | - --- -### `skills` - -Install bundled starter skills into a local `skills/` directory. - -```bash -npx @lobu/cli@latest skills list -npx @lobu/cli@latest skills add lobu -npx @lobu/cli@latest skills add lobu --force -``` - -| Subcommand | Description | -|------------|-------------| -| `list` | Show bundled Lobu starter skills | -| `add ` | Copy a bundled starter skill into `skills/` | +### `status` -The bundled Lobu starter skill includes memory guidance: +Show a summary of agents in the active org. ```bash -npx @lobu/cli@latest skills add lobu +npx @lobu/cli@latest status +npx @lobu/cli@latest status --org my-org ``` --- -### `providers` - -Browse and manage LLM providers. +### `logout`, `whoami`, `token` ```bash -npx @lobu/cli@latest providers list # browse available providers -npx @lobu/cli@latest providers add gemini # add to lobu.toml +npx @lobu/cli@latest whoami +npx @lobu/cli@latest token --raw +npx @lobu/cli@latest logout ``` -| Subcommand | Description | -|------------|-------------| -| `list` | Browse available LLM providers | -| `add ` | Add a provider to `lobu.toml` | - ## Typical workflow ```bash -# 1. Scaffold -npx @lobu/cli@latest init my-agent - -# 2. Configure -cd my-agent -npx @lobu/cli@latest skills add lobu -npx @lobu/cli@latest providers add gemini -npx @lobu/cli@latest secrets set GEMINI_API_KEY ... +# 1. Authenticate and select org +npx @lobu/cli@latest login +npx @lobu/cli@latest org set my-org -# Optional: install the bundled Lobu starter skill -npx @lobu/cli@latest skills add lobu +# 2. Manage remote/UI-backed agents +npx @lobu/cli@latest agent list +npx @lobu/cli@latest agent create my-agent --name "My Agent" -# 3. Validate +# 3. Optional local artifact workflow +npx @lobu/cli@latest init my-agent +cd my-agent npx @lobu/cli@latest validate +npx @lobu/cli@latest apply --org my-org -# 4. Run locally -npx @lobu/cli@latest run -d - -# 5. Chat with your agent -npx @lobu/cli@latest chat "Hello, what can you do?" +# 4. Run locally when DATABASE_URL is configured +npx @lobu/cli@latest run ``` diff --git a/packages/landing/src/content/docs/reference/lobu-memory.md b/packages/landing/src/content/docs/reference/lobu-memory.md index 15d1d6a09..87beeb7d4 100644 --- a/packages/landing/src/content/docs/reference/lobu-memory.md +++ b/packages/landing/src/content/docs/reference/lobu-memory.md @@ -143,7 +143,7 @@ Useful flags: The old standalone Owletto starter skills are folded into the bundled Lobu starter skill: ```bash -lobu skills add lobu +# Enable the Lobu skill from the agent settings UI ``` Local skills are still discovered from `skills//SKILL.md` and `agents//skills//SKILL.md`. diff --git a/packages/landing/src/pages/reference/api-reference.astro b/packages/landing/src/pages/reference/api-reference.astro index c900c1dd3..afe1e7887 100644 --- a/packages/landing/src/pages/reference/api-reference.astro +++ b/packages/landing/src/pages/reference/api-reference.astro @@ -5,7 +5,7 @@ import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; // Try the local dev gateway first, then fall back to production so the page // always renders something useful even when the local gateway is offline. const specOrigins = import.meta.env.DEV - ? ["http://localhost:8080", "https://app.lobu.ai"] + ? ["http://localhost:8787", "https://app.lobu.ai"] : ["https://app.lobu.ai"]; let spec: string | null = null; diff --git a/packages/landing/src/use-case-showcases.ts b/packages/landing/src/use-case-showcases.ts index 1a2d6297b..51a82b063 100644 --- a/packages/landing/src/use-case-showcases.ts +++ b/packages/landing/src/use-case-showcases.ts @@ -2825,7 +2825,7 @@ export function getSkillsPrompt(showcase: LandingUseCaseShowcase) { export function getMemoryPrompt(showcase: LandingUseCaseShowcase) { const memory = showcase.memory; - return `Run \`npx @lobu/cli@latest skills add lobu\` and then \`npx @lobu/cli@latest memory init\` to set up Lobu memory for ${showcase.label}. Model these entities: ${memory.entityTypes.join(", ")}. Keep the extracted memory durable, typed, and linked so the runtime can reuse it across future tasks.`; + return `Use the Agents UI to enable the Lobu skill and configure memory for ${showcase.label}. Model these entities: ${memory.entityTypes.join(", ")}. Keep the extracted memory durable, typed, and linked so the runtime can reuse it across future tasks.`; } const LOBU_ZONE = diff --git a/packages/owletto-backend/src/__tests__/unit/lobu/gateway.test.ts b/packages/owletto-backend/src/__tests__/unit/lobu/gateway.test.ts index 43d35f1eb..ca40dae46 100644 --- a/packages/owletto-backend/src/__tests__/unit/lobu/gateway.test.ts +++ b/packages/owletto-backend/src/__tests__/unit/lobu/gateway.test.ts @@ -3,8 +3,6 @@ import { ensureEmbeddedGatewaySecrets } from '../../../lobu/gateway'; const ORIGINAL_ENV = { ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, - ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, - LOBU_ADMIN_PASSWORD: process.env.LOBU_ADMIN_PASSWORD, OWLETTO_ALLOW_EPHEMERAL_ENCRYPTION_KEY: process.env.OWLETTO_ALLOW_EPHEMERAL_ENCRYPTION_KEY, }; @@ -28,13 +26,10 @@ describe('ensureEmbeddedGatewaySecrets', () => { it('allows explicitly ephemeral encryption keys', () => { delete process.env.ENCRYPTION_KEY; - delete process.env.ADMIN_PASSWORD; - delete process.env.LOBU_ADMIN_PASSWORD; process.env.OWLETTO_ALLOW_EPHEMERAL_ENCRYPTION_KEY = '1'; ensureEmbeddedGatewaySecrets(); expect(process.env.ENCRYPTION_KEY).toBeTruthy(); - expect(process.env.ADMIN_PASSWORD).toBeTruthy(); }); }); diff --git a/packages/owletto-backend/src/gateway/auth/api-auth-middleware.ts b/packages/owletto-backend/src/gateway/auth/api-auth-middleware.ts index 3cb0e572a..cd53f6f7a 100644 --- a/packages/owletto-backend/src/gateway/auth/api-auth-middleware.ts +++ b/packages/owletto-backend/src/gateway/auth/api-auth-middleware.ts @@ -1,4 +1,3 @@ -import { timingSafeEqual } from "node:crypto"; import { verifyWorkerToken } from "@lobu/core"; import type { Context, Next } from "hono"; import { verifySettingsSession } from "../routes/public/settings-auth.js"; @@ -8,10 +7,9 @@ export const TOKEN_EXPIRATION_MS = 24 * 60 * 60 * 1000; /** * Creates a Hono middleware that enforces the standard auth check: - * 1. Settings session cookie 2. External OAuth 3. Admin password 4. Worker token + * 1. Settings session cookie 2. External OAuth 3. Worker token */ export function createApiAuthMiddleware(opts: { - adminPassword?: string; externalAuthClient?: ExternalAuthClient; allowWorkerToken?: boolean; allowSettingsSession?: boolean; @@ -36,16 +34,7 @@ export function createApiAuthMiddleware(opts: { } } - // 3. Try admin password - if (opts.adminPassword) { - const a = Buffer.from(token); - const b = Buffer.from(opts.adminPassword); - if (a.length === b.length && timingSafeEqual(a, b)) { - return next(); - } - } - - // 4. Try worker token when explicitly allowed for the route + // 3. Try worker token when explicitly allowed for the route if (opts.allowWorkerToken !== false) { const workerData = verifyWorkerToken(token); if (workerData) { diff --git a/packages/owletto-backend/src/gateway/cli/gateway.ts b/packages/owletto-backend/src/gateway/cli/gateway.ts index a55510058..b81e11a04 100644 --- a/packages/owletto-backend/src/gateway/cli/gateway.ts +++ b/packages/owletto-backend/src/gateway/cli/gateway.ts @@ -1,6 +1,5 @@ #!/usr/bin/env bun -import { randomBytes } from "node:crypto"; import type { Server } from "node:http"; import { createServer } from "node:http"; import { getRequestListener } from "@hono/node-server"; @@ -134,16 +133,13 @@ export function createGatewayApp( streaming: true, toolApproval: true, }, - wsUrl: `ws://localhost:8080/ws`, + wsUrl: `ws://localhost:${process.env.PORT || process.env.GATEWAY_PORT || "8787"}/ws`, secretProxy: !!secretProxy, }); }); app.get("/ready", (c) => c.json({ ready: true })); - const adminPassword: string = - process.env.ADMIN_PASSWORD || randomBytes(16).toString("base64url"); - // Metrics auth is optional so existing ServiceMonitor configs continue to scrape. app.get("/metrics", async (c) => { const metricsAuthToken = process.env.METRICS_AUTH_TOKEN; @@ -280,7 +276,6 @@ export function createGatewayApp( sessionManager: sessionMgr, sseManager: coreServices.getSseManager(), publicGatewayUrl: publicUrl, - adminPassword, externalAuthClient: coreServices.getExternalAuthClient(), agentSettingsStore: coreServices.getAgentSettingsStore(), agentConfigStore: coreServices.getConfigStore(), @@ -588,12 +583,6 @@ export function createGatewayApp( setEnvResolver((key: string) => systemEnvStore.resolve(key)); } - if (!process.env.ADMIN_PASSWORD) { - logger.info( - "An admin password has been auto-generated. For security reasons, it is not logged. Set the ADMIN_PASSWORD env var to use a fixed password." - ); - } - { const landingRouter = createLandingRoutes(); app.route("", landingRouter); @@ -703,12 +692,26 @@ export function createGatewayApp( ); } + async function hasDevRouteAccess(c: any): Promise { + if (verifySettingsSessionOrToken(c)) return true; + const authHeader = c.req.header("Authorization"); + if (!authHeader?.startsWith("Bearer ")) return false; + const token = authHeader.slice(7); + const externalAuthClient = coreServices?.getExternalAuthClient?.(); + if (!externalAuthClient) return false; + try { + const userInfo = await externalAuthClient.fetchUserInfo(token); + return Boolean(userInfo?.sub); + } catch { + return false; + } + } + app.post("/api/v1/reload", async (c) => { if (process.env.NODE_ENV === "production") { return c.json({ error: "Not found" }, 404); } - const authHeader = c.req.header("Authorization"); - if (authHeader !== `Bearer ${adminPassword}`) { + if (!(await hasDevRouteAccess(c))) { return c.json({ error: "Unauthorized" }, 401); } @@ -734,8 +737,7 @@ export function createGatewayApp( if (process.env.NODE_ENV === "production") { return c.json({ error: "Not found" }, 404); } - const authHeader = c.req.header("Authorization"); - if (authHeader !== `Bearer ${adminPassword}`) { + if (!(await hasDevRouteAccess(c))) { return c.json({ error: "Unauthorized" }, 401); } @@ -816,21 +818,21 @@ The Lobu API allows you to create and interact with AI agents programmatically. ## Authentication -1. Authenticate the agent-creation request with an admin password or CLI access token +1. Authenticate the agent-creation request with a settings session or CLI access token 2. Create an agent with \`POST /api/v1/agents\` to get a worker token 3. Use the returned worker token as a Bearer token for subsequent agent requests ## Quick Start \`\`\`bash -# 1. Create an agent (authenticate with admin password or CLI token) -curl -X POST http://localhost:8080/api/v1/agents \\ - -H "Authorization: Bearer $ADMIN_PASSWORD" \\ +# 1. Create an agent (authenticate with a CLI token) +curl -X POST http://localhost:8787/api/v1/agents \\ + -H "Authorization: Bearer $LOBU_API_TOKEN" \\ -H "Content-Type: application/json" \\ -d '{"provider": "claude"}' # 2. Send a message (use worker token from step 1) -curl -X POST http://localhost:8080/api/v1/agents/{agentId}/messages \\ +curl -X POST http://localhost:8787/api/v1/agents/{agentId}/messages \\ -H "Authorization: Bearer {token}" \\ -H "Content-Type: application/json" \\ -d '{"content": "Hello!"}' @@ -894,7 +896,7 @@ Agents can be configured with custom MCP (Model Context Protocol) servers: }, ], servers: [ - { url: "http://localhost:8080", description: "Local development" }, + { url: "http://localhost:8787", description: "Local development" }, ], }); diff --git a/packages/owletto-backend/src/gateway/routes/public/agent.ts b/packages/owletto-backend/src/gateway/routes/public/agent.ts index 7ee028e8a..5c03df094 100644 --- a/packages/owletto-backend/src/gateway/routes/public/agent.ts +++ b/packages/owletto-backend/src/gateway/routes/public/agent.ts @@ -1,4 +1,4 @@ -import { randomUUID, timingSafeEqual } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { type AgentConfigStore, @@ -452,7 +452,6 @@ export interface AgentApiConfig { sessionManager: ISessionManager; sseManager: SseManager; publicGatewayUrl: string; - adminPassword?: string; externalAuthClient?: ExternalAuthClient; agentSettingsStore?: AgentSettingsStore; agentConfigStore?: Pick< @@ -471,7 +470,6 @@ export interface AgentApiConfig { export function createAgentApi(config: AgentApiConfig): OpenAPIHono { const { queueProducer, - adminPassword, externalAuthClient, agentSettingsStore, agentConfigStore, @@ -488,7 +486,6 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { app.use( "/api/v1/agents/*", createApiAuthMiddleware({ - adminPassword, externalAuthClient, allowSettingsSession: true, }) @@ -528,22 +525,12 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { return token.length > 0 ? token : null; } - function matchesAdminPassword(token: string): boolean { - if (!adminPassword) return false; - const a = Buffer.from(token); - const b = Buffer.from(adminPassword); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); - } - /** * Verify that the caller is authorized to act on `resolvedAgentId`. * - * The agent API middleware accepts four auth methods (admin password, - * worker token, external OAuth, settings session). Each needs its own - * ownership rule: + * The agent API middleware accepts three auth methods (worker token, + * external OAuth, settings session). Each needs its own ownership rule: * - * - admin password → full access * - worker token → scoped to its own agentId * - settings session → verifyOwnedAgentAccess (handles admin bypass, * agent-scoped sessions, and UserAgentsStore @@ -563,10 +550,7 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { const bearer = tokenFromHeader(c); - // 1. Admin password bypasses ownership entirely, regardless of any cookie. - if (bearer && matchesAdminPassword(bearer)) return null; - - // 2. Settings session cookie (or injected auth provider for embedded mode). + // 1. Settings session cookie (or injected auth provider for embedded mode). const settingsSession = verifySettingsSession(c); if (settingsSession) { const access = await verifyOwnedAgentAccess( @@ -579,7 +563,7 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { if (!bearer) return deny(); - // 3. Worker token — must target its own agent. + // 2. Worker token — must target its own agent. const workerData = verifyWorkerToken(bearer); if (workerData) { const tokenAge = Date.now() - workerData.timestamp; @@ -588,7 +572,7 @@ export function createAgentApi(config: AgentApiConfig): OpenAPIHono { return workerAgentId && workerAgentId === resolvedAgentId ? null : deny(); } - // 4. External OAuth (Owletto / memory-url userinfo). + // 3. External OAuth (Owletto / memory-url userinfo). if (externalAuthClient) { try { const userInfo = (await externalAuthClient.fetchUserInfo(bearer)) as { diff --git a/packages/owletto-backend/src/gateway/services/agent-threads.ts b/packages/owletto-backend/src/gateway/services/agent-threads.ts index 7893343c4..4d7863fad 100644 --- a/packages/owletto-backend/src/gateway/services/agent-threads.ts +++ b/packages/owletto-backend/src/gateway/services/agent-threads.ts @@ -9,7 +9,7 @@ * can open threads and post messages without going through HTTP. * * The functions here intentionally do NOT perform any HTTP-shaped auth - * (admin password, settings session, worker-token, OAuth, …). Callers are + * (settings session, worker-token, OAuth, …). Callers are * presumed to be inside the gateway's trust boundary; the public routes * remain the single auth-gated entry point for external clients. */ diff --git a/packages/owletto-backend/src/gateway/services/provider-registry-service.ts b/packages/owletto-backend/src/gateway/services/provider-registry-service.ts index 0fdaae20d..bf84fd653 100644 --- a/packages/owletto-backend/src/gateway/services/provider-registry-service.ts +++ b/packages/owletto-backend/src/gateway/services/provider-registry-service.ts @@ -10,8 +10,6 @@ const logger = createLogger("provider-registry-service"); const ENV_SUBSTITUTION_BLOCKLIST = new Set([ "ENCRYPTION_KEY", - "ADMIN_PASSWORD", - "DATABASE_PASSWORD", "DATABASE_URL", // Kept defense-in-depth: even though the runtime no longer uses Redis, an // operator may still set REDIS_PASSWORD in their environment for unrelated @@ -25,6 +23,10 @@ const ENV_SUBSTITUTION_BLOCKLIST = new Set([ "SENTRY_DSN", ]); +function isBlockedEnvSubstitution(varName: string): boolean { + return ENV_SUBSTITUTION_BLOCKLIST.has(varName) || /(^|_)PASSWORD$/i.test(varName); +} + export class ProviderRegistryService { private configUrl?: string; private loaded?: ProvidersConfigFile; @@ -117,7 +119,7 @@ export function resolveProviderRegistryFromRaw(raw: string): { } const substituted = raw.replace(/\$\{env:([^}]+)\}/g, (_match, varName) => { - if (ENV_SUBSTITUTION_BLOCKLIST.has(varName)) { + if (isBlockedEnvSubstitution(varName)) { logger.warn(`Blocked env substitution for sensitive var: ${varName}`); return ""; } diff --git a/packages/owletto-backend/src/lobu/gateway.ts b/packages/owletto-backend/src/lobu/gateway.ts index 38feaae4e..9c239c563 100644 --- a/packages/owletto-backend/src/lobu/gateway.ts +++ b/packages/owletto-backend/src/lobu/gateway.ts @@ -73,12 +73,6 @@ function ensureEmbeddedGatewaySecrets(): void { ); } } - - if (!process.env.ADMIN_PASSWORD && !process.env.LOBU_ADMIN_PASSWORD) { - process.env.ADMIN_PASSWORD = crypto.randomBytes(16).toString('base64url'); - } else if (process.env.LOBU_ADMIN_PASSWORD) { - process.env.ADMIN_PASSWORD = process.env.LOBU_ADMIN_PASSWORD; - } } /** diff --git a/scripts/test-bot.sh b/scripts/test-bot.sh index d2f6c106c..4200d34f3 100755 --- a/scripts/test-bot.sh +++ b/scripts/test-bot.sh @@ -133,6 +133,20 @@ if [ -f .env ]; then done < .env fi +resolve_auth_token() { + if [ -n "${TEST_AUTH_TOKEN:-}" ]; then + printf '%s\n' "$TEST_AUTH_TOKEN" + return + fi + if [ -n "${LOBU_API_TOKEN:-}" ]; then + printf '%s\n' "$LOBU_API_TOKEN" + return + fi + if command -v lobu > /dev/null 2>&1; then + lobu token --raw 2>/dev/null || true + fi +} + telegram_send_and_wait() { local peer="$1" local message="$2" @@ -283,7 +297,7 @@ TIMEOUT="${TEST_TIMEOUT:-120}" # Platform-specific setup case "$TEST_PLATFORM" in slack) - AUTH_TOKEN="${TEST_AUTH_TOKEN:-${ADMIN_PASSWORD:-}}" + AUTH_TOKEN="$(resolve_auth_token)" CHANNEL="${TEST_CHANNEL:-${QA_SLACK_CHANNEL:-}}" if [ -z "$CHANNEL" ]; then echo "❌ QA_SLACK_CHANNEL or TEST_CHANNEL environment variable is required for Slack" @@ -291,7 +305,7 @@ case "$TEST_PLATFORM" in fi ;; whatsapp) - AUTH_TOKEN="${TEST_AUTH_TOKEN:-${ADMIN_PASSWORD:-}}" + AUTH_TOKEN="$(resolve_auth_token)" CHANNEL="${TEST_CHANNEL:-${WHATSAPP_SELF_PHONE:-}}" if [ -z "$CHANNEL" ]; then # For self-chat mode, we can use "self" as a special channel @@ -304,7 +318,7 @@ case "$TEST_PLATFORM" in fi ;; telegram) - AUTH_TOKEN="${TEST_AUTH_TOKEN:-${ADMIN_PASSWORD:-}}" + AUTH_TOKEN="$(resolve_auth_token)" CHANNEL="${TEST_CHANNEL:-${TELEGRAM_TEST_CHAT_ID:-}}" ACTIVE_TELEGRAM_BOT_PEER="$(fetch_telegram_bot_peer)" TELEGRAM_BOT_PEER="${TELEGRAM_TEST_BOT_USERNAME:-}" @@ -335,6 +349,11 @@ case "$TEST_PLATFORM" in ;; esac +if [ -z "$AUTH_TOKEN" ]; then + echo "❌ Authentication token required. Set TEST_AUTH_TOKEN/LOBU_API_TOKEN or run \`lobu login\`." + exit 1 +fi + # Get messages from arguments or use default if [ $# -eq 0 ]; then MESSAGES=("@me test message")