diff --git a/packages/cli/README.md b/packages/cli/README.md index 9e79d336e..dd8d94fdc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @lobu/cli -CLI tool for running Lobu locally and managing Lobu agents through the same REST API as the web app. +CLI for running Lobu locally and managing Lobu agents through the same REST API as the web app. ## Quick Start @@ -8,37 +8,30 @@ CLI tool for running Lobu locally and managing Lobu agents through the same REST npx @lobu/cli@latest init my-bot cd my-bot # edit .env to set DATABASE_URL -npx @lobu/cli@latest run +lobu run ``` -Lobu boots as a single Node process. Postgres is a user-provided external (managed instance or local — `brew services start postgresql`). +Lobu boots as a single Node process. Postgres (with pgvector) is a user-provided external. `lobu doctor` reports what's missing. -## Commands - -### `lobu init [name]` - -Scaffold a new Lobu project with interactive prompts: - -- **Project name** -- **Gateway port** and optional **public URL** (for OAuth callbacks) -- **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) -- **Memory** selection (filesystem, Lobu Cloud, or custom Owletto URL) - -**Generates:** `lobu.toml`, `.env` (with `DATABASE_URL` placeholder), `agents//` (`IDENTITY.md`, `SOUL.md`, `USER.md`, `skills/`, `evals/`), `skills/`, `AGENTS.md`, `TESTING.md`, `README.md`, `.gitignore`. - -When Owletto-backed memory is enabled, `lobu init` also scaffolds the file-first memory layout: - -- `[memory.owletto]` in `lobu.toml` (org, name, description, models, data) -- `models/` -- `data/` - -For a custom Owletto deployment, `.env` keeps `MEMORY_URL` as the optional base MCP URL override. +```bash +docker run -d --name lobu-pg -p 5432:5432 \ + -e POSTGRES_PASSWORD=lobu pgvector/pgvector:pg16 +# DATABASE_URL=postgresql://postgres:lobu@localhost:5432/postgres +``` -### `lobu run` +## Commands -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/server/dist/server.bundle.mjs`. Ctrl+C stops the process and any spawned worker subprocesses cleanly. +`lobu --help` shows the full grouped command list, and `lobu --help` lists the per-command flags. The highlights: + +- `lobu init [name]` — scaffold a project. Interactive by default; pass `--yes` (with any of `--port` / `--provider` / `--platform` / `--memory` / `--no-sentry` / etc.) for non-interactive / CI scaffolding. `lobu init .` or `--here` scaffolds into the current directory. +- `lobu run` (aliases: `lobu dev`, `lobu start`) — boot the embedded stack. Pre-flights the gateway port and accepts `--port` / `--quiet` / `--verbose` / `--log-level`. +- `lobu chat ` — send one prompt and stream the response. `-C/--continue` resumes the last thread (per context+agent); `--auto-approve` skips tool prompts in trusted runs; `--json` emits raw SSE events for piping. +- `lobu doctor` — Postgres connectivity, pgvector extension, port availability, provider API keys, workspace dir. +- `lobu link` / `lobu unlink` — bind this directory to a (context, org) at `.lobu/project.json`. `lobu apply` refuses to push mismatched targets unless `--force` is set. +- `lobu apply` (alias: `lobu deploy`) — idempotent sync of `lobu.toml` to Lobu Cloud. +- `lobu agent scaffold ` — add a second/third agent to an existing project. +- `lobu eval new ` — scaffold a YAML eval into the current agent. +- `lobu telemetry {status,on,off}` — Sentry is off by default; toggle here. ## License diff --git a/packages/cli/src/__tests__/cli-ux.test.ts b/packages/cli/src/__tests__/cli-ux.test.ts new file mode 100644 index 000000000..54a950e21 --- /dev/null +++ b/packages/cli/src/__tests__/cli-ux.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { join } from "node:path"; + +import { isPortFree } from "../commands/dev"; +import { agentScaffoldCommand } from "../commands/agent"; +import { evalNewCommand } from "../commands/eval"; +import { loadProjectLink, saveProjectLink } from "../internal/project-link"; +import { initCommand } from "../commands/init"; + +describe("isPortFree", () => { + test("returns true for a port nothing is holding", async () => { + // Pick a high port, almost certainly free. + const port = 49152 + Math.floor(Math.random() * 10_000); + expect(await isPortFree(port)).toBe(true); + }); + + test("returns false when a server is bound", async () => { + const server = createServer(); + await new Promise((resolve) => + server.listen({ port: 0, host: "127.0.0.1" }, () => resolve()) + ); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("expected AddressInfo"); + } + try { + expect(await isPortFree(address.port)).toBe(false); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + +describe("project-link round-trip", () => { + let cwd: string; + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "lobu-link-")); + }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + test("save then load returns the same context+org", async () => { + const saved = await saveProjectLink(cwd, { + context: "lobu", + org: "acme", + }); + expect(saved.context).toBe("lobu"); + expect(saved.org).toBe("acme"); + expect(saved.linkedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + const loaded = await loadProjectLink(cwd); + expect(loaded?.context).toBe("lobu"); + expect(loaded?.org).toBe("acme"); + }); + + test("load returns null when no link file exists", async () => { + expect(await loadProjectLink(cwd)).toBeNull(); + }); + + test("save appends `.lobu/` to an existing .gitignore exactly once", async () => { + writeFileSync(join(cwd, ".gitignore"), "node_modules/\n"); + await saveProjectLink(cwd, { context: "lobu", org: "acme" }); + await saveProjectLink(cwd, { context: "lobu", org: "acme2" }); + const content = readFileSync(join(cwd, ".gitignore"), "utf-8"); + expect(content.match(/^\.lobu\/$/gm)?.length ?? 0).toBe(1); + expect(content).toContain("node_modules/"); + }); +}); + +describe("lobu init --yes", () => { + let cwd: string; + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "lobu-init-yes-")); + }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + test("scaffolds a non-interactive project with defaults", async () => { + await initCommand(cwd, "demo", { yes: true }); + const proj = join(cwd, "demo"); + expect(existsSync(join(proj, "lobu.toml"))).toBe(true); + expect(existsSync(join(proj, ".env"))).toBe(true); + expect(existsSync(join(proj, "agents", "demo", "IDENTITY.md"))).toBe(true); + expect(existsSync(join(proj, "agents", "demo", "evals", "ping.yaml"))).toBe( + true + ); + const env = readFileSync(join(proj, ".env"), "utf-8"); + expect(env.includes("SENTRY_DSN=")).toBe(false); + }); + + test("--here scaffolds into the current directory", async () => { + await initCommand(cwd, undefined, { yes: true, here: true }); + expect(existsSync(join(cwd, "lobu.toml"))).toBe(true); + expect(existsSync(join(cwd, "agents"))).toBe(true); + }); + + test("--sentry writes SENTRY_DSN", async () => { + await initCommand(cwd, "sentry-on", { yes: true, sentry: true }); + const env = readFileSync(join(cwd, "sentry-on", ".env"), "utf-8"); + expect(env).toMatch(/SENTRY_DSN=/); + }); + + test("--provider with bad id throws before writing files", async () => { + await expect( + initCommand(cwd, "bad-provider", { + yes: true, + provider: "definitely-not-a-real-provider", + }) + ).rejects.toThrow(/Unknown provider/); + }); +}); + +describe("agent scaffold", () => { + let cwd: string; + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "lobu-scaffold-")); + }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + test("appends a new agent block to lobu.toml", async () => { + writeFileSync( + join(cwd, "lobu.toml"), + ["[agents.first]", 'name = "first"', 'dir = "./agents/first"', ""].join( + "\n" + ) + ); + await agentScaffoldCommand("second", { cwd, name: "Second" }); + const toml = readFileSync(join(cwd, "lobu.toml"), "utf-8"); + expect(toml).toContain("[agents.second]"); + expect(toml).toContain('name = "Second"'); + expect(toml).toContain('dir = "./agents/second"'); + expect(existsSync(join(cwd, "agents", "second", "IDENTITY.md"))).toBe(true); + expect(existsSync(join(cwd, "agents", "second", "SOUL.md"))).toBe(true); + expect(existsSync(join(cwd, "agents", "second", "USER.md"))).toBe(true); + }); + + test("escapes quotes in --name so the TOML stays parseable", async () => { + writeFileSync(join(cwd, "lobu.toml"), ""); + await agentScaffoldCommand("quoty", { + cwd, + name: 'Sales "Bot" v2', + }); + const toml = readFileSync(join(cwd, "lobu.toml"), "utf-8"); + expect(toml).toContain('name = "Sales \\"Bot\\" v2"'); + }); +}); + +describe("eval new", () => { + let cwd: string; + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "lobu-eval-new-")); + writeFileSync( + join(cwd, "lobu.toml"), + [ + "[agents.demo]", + 'name = "demo"', + 'dir = "./agents/demo"', + "", + "[agents.demo.skills]", + "", + "[agents.demo.network]", + "allowed = []", + "", + ].join("\n") + ); + mkdirSync(join(cwd, "agents", "demo"), { recursive: true }); + }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + test("creates evals/.yaml with a sane template", async () => { + await evalNewCommand("smoke", { cwd, description: "smoke test" }); + const file = join(cwd, "agents", "demo", "evals", "smoke.yaml"); + expect(existsSync(file)).toBe(true); + const yaml = readFileSync(file, "utf-8"); + expect(yaml).toContain("name: smoke"); + expect(yaml).toContain("smoke test"); + expect(yaml).toContain("type: llm-rubric"); + }); +}); diff --git a/packages/cli/src/commands/_lib/apply/apply-cmd.ts b/packages/cli/src/commands/_lib/apply/apply-cmd.ts index d4183d343..6536f5937 100644 --- a/packages/cli/src/commands/_lib/apply/apply-cmd.ts +++ b/packages/cli/src/commands/_lib/apply/apply-cmd.ts @@ -1,4 +1,6 @@ import chalk from "chalk"; +import { resolveContext } from "../../../internal/context.js"; +import { loadProjectLink } from "../../../internal/project-link.js"; import { ApiError, ValidationError } from "../../memory/_lib/errors.js"; import { printError, printText } from "../../memory/_lib/output.js"; import { @@ -24,6 +26,8 @@ export interface ApplyOptions { only?: "agents" | "memory"; org?: string; url?: string; + /** Bypass the project-link guard. */ + force?: boolean; /** Test seam — inject a stubbed fetch. */ fetchImpl?: typeof fetch; } @@ -205,6 +209,36 @@ export async function applyCommand(opts: ApplyOptions = {}): Promise { }); printText(chalk.dim(`Org: ${orgSlug}`)); + // Refuse if .lobu/project.json points at a different (context, org). + const link = await loadProjectLink(cwd); + if (link && !opts.force) { + const activeContext = await resolveContext().catch(() => null); + const contextMismatch = + activeContext !== null && activeContext.name !== link.context; + const orgMismatch = orgSlug !== link.org; + if (contextMismatch || orgMismatch) { + const detail: string[] = []; + if (contextMismatch) { + detail.push( + ` context: linked=${link.context}, active=${activeContext.name}` + ); + } + if (orgMismatch) { + detail.push(` org: linked=${link.org}, applying=${orgSlug}`); + } + printError( + [ + "", + "Project link mismatch — refusing to apply.", + ...detail, + "", + "Run `lobu link --org ` to update the link, or pass `--force` to override.", + ].join("\n") + ); + throw new ValidationError("project-link mismatch"); + } + } + const remote = await fetchRemoteSnapshot(client, state, opts.only); const plan = computeDiff(state, remote, { only: opts.only }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index d34339f3d..699ec1b1d 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -1,4 +1,11 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { + access, + constants, + mkdir, + readFile, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; import chalk from "chalk"; import { resolveApiClient } from "../internal/index.js"; @@ -181,3 +188,103 @@ export async function agentConfigPatchCommand( function printJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } + +export interface AgentScaffoldOptions { + cwd?: string; + name?: string; + description?: string; +} + +const AGENT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +/** Add a new local agent + a `[agents.]` block to an existing lobu.toml. */ +export async function agentScaffoldCommand( + agentId: string, + options: AgentScaffoldOptions = {} +): Promise { + if (!AGENT_ID_PATTERN.test(agentId)) { + console.error( + chalk.red( + `\n Invalid agent id "${agentId}". Use lowercase alphanumeric + hyphens.\n` + ) + ); + process.exit(1); + } + + const cwd = options.cwd ?? process.cwd(); + const lobuTomlPath = join(cwd, "lobu.toml"); + let tomlExists = false; + try { + await access(lobuTomlPath, constants.F_OK); + tomlExists = true; + } catch { + tomlExists = false; + } + + if (!tomlExists) { + console.error( + chalk.red( + "\n No lobu.toml in the current directory. Run `lobu init` first or `cd` into a Lobu project.\n" + ) + ); + process.exit(1); + } + + const agentDir = join(cwd, "agents", agentId); + try { + await access(agentDir, constants.F_OK); + console.error( + chalk.red( + `\n Directory ${agentDir} already exists. Pick a different agent id.\n` + ) + ); + process.exit(1); + } catch { + // not present — what we want + } + + const displayName = options.name ?? agentId; + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, "IDENTITY.md"), + `# Identity\n\nYou are ${displayName}, a helpful AI assistant.\n` + ); + await writeFile( + join(agentDir, "SOUL.md"), + `# Instructions\n\nBe concise and helpful. Ask clarifying questions when the request is ambiguous.\n` + ); + await writeFile( + join(agentDir, "USER.md"), + `# User Context\n\n\n` + ); + await mkdir(join(agentDir, "skills"), { recursive: true }); + await writeFile(join(agentDir, "skills", ".gitkeep"), ""); + await mkdir(join(agentDir, "evals"), { recursive: true }); + + const description = options.description ?? ""; + const tomlBlock = [ + "", + `[agents.${agentId}]`, + `name = ${JSON.stringify(displayName)}`, + `description = ${JSON.stringify(description)}`, + `dir = "./agents/${agentId}"`, + "", + `[agents.${agentId}.skills]`, + "", + `[agents.${agentId}.network]`, + "allowed = []", + "", + ].join("\n"); + + const existing = await readFile(lobuTomlPath, "utf-8"); + const sep = existing.endsWith("\n") ? "" : "\n"; + await writeFile(lobuTomlPath, `${existing}${sep}${tomlBlock}`); + + console.log(chalk.green(`\n Scaffolded agent "${agentId}".`)); + console.log(chalk.dim(` - agents/${agentId}/IDENTITY.md`)); + console.log(chalk.dim(` - agents/${agentId}/SOUL.md`)); + console.log(chalk.dim(` - agents/${agentId}/USER.md`)); + console.log(chalk.dim(` - agents/${agentId}/skills/`)); + console.log(chalk.dim(` - agents/${agentId}/evals/`)); + console.log(chalk.dim(` - lobu.toml: appended [agents.${agentId}]\n`)); +} diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 22d4faef0..d1ea9dc85 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -1,35 +1,76 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { createInterface } from "node:readline"; import chalk from "chalk"; import { apiBaseFromContextUrl, + getCurrentContextName, getToken, resolveContext, resolveGatewayUrl, } from "../internal/index.js"; +import { LOBU_CONFIG_DIR } from "../internal/context.js"; import { isLoadError, loadConfig } from "../config/loader.js"; import { renderMarkdown } from "../utils/markdown.js"; +const THREADS_FILE = join(LOBU_CONFIG_DIR, "threads.json"); + +async function getLastThread( + context: string, + agent: string +): Promise { + try { + const raw = await readFile(THREADS_FILE, "utf-8"); + const parsed = JSON.parse(raw) as Record; + return parsed[`${context}|${agent}`]; + } catch { + return undefined; + } +} + +async function setLastThread( + context: string, + agent: string, + threadId: string +): Promise { + let store: Record = {}; + try { + const raw = await readFile(THREADS_FILE, "utf-8"); + store = JSON.parse(raw) as Record; + } catch { + // first-write or corrupt; reset + } + store[`${context}|${agent}`] = threadId; + await mkdir(LOBU_CONFIG_DIR, { recursive: true }); + await writeFile(THREADS_FILE, JSON.stringify(store, null, 2), { + mode: 0o600, + }); +} + +export interface ChatOptions { + agent?: string; + gateway?: string; + user?: string; + thread?: string; + dryRun?: boolean; + new?: boolean; + continue?: boolean; + context?: string; + autoApprove?: boolean; + json?: boolean; +} + /** - * `lobu chat "prompt"` — send a prompt to an agent and stream the response. + * `lobu chat ` — send a prompt to an agent and stream the response. * - * Without --user: API mode — creates a session, sends message, streams to terminal. - * With --user platform:id: Platform mode — sends through Telegram/Slack, response - * appears on the platform. Terminal shows the streamed response too. + * With --user platform:id, routes through Telegram/Slack. + * With --continue, resumes the last thread for (context, agent). */ export async function chatCommand( cwd: string, prompt: string, - options: { - agent?: string; - gateway?: string; - user?: string; - thread?: string; - dryRun?: boolean; - new?: boolean; - context?: string; - } + options: ChatOptions ): Promise { - // Resolve gateway URL: explicit flag > named context > .env fallback let gatewayUrl: string; if (options.gateway) { gatewayUrl = options.gateway; @@ -44,35 +85,47 @@ export async function chatCommand( 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`.\n" - ) + chalk.red("\n Session expired or not logged in. Run `lobu login`.\n") ); process.exit(1); } const agentId = options.agent ?? (await resolveAgentId(cwd)); - - // Parse --user flag: "telegram:12345" → { platform: "telegram", userId: "12345" } const platformUser = options.user ? parsePlatformUser(options.user) : null; + const contextName = options.context ?? (await getCurrentContextName()); + + // Resolve thread: explicit --thread > --continue (last for this agent) + let threadId = options.thread; + if (!threadId && options.continue && agentId) { + threadId = await getLastThread(contextName, agentId); + if (!threadId) { + console.error( + chalk.dim( + `\n No prior thread for ${agentId} in context ${contextName}; starting fresh.\n` + ) + ); + } + } if (platformUser) { - // Platform mode: route through Telegram/Slack await sendViaPlatform(gatewayUrl, authToken, { agentId, platform: platformUser.platform, userId: platformUser.userId, message: prompt, - thread: options.thread, + thread: threadId, + json: options.json, }); } else { - // API mode: create session, send message, stream response await sendViaApi(gatewayUrl, authToken, { agentId, message: prompt, - thread: options.thread, + thread: threadId, dryRun: options.dryRun, - forceNew: options.new, + forceNew: options.new && !threadId, + autoApprove: options.autoApprove, + json: options.json, + contextName, }); } } @@ -81,20 +134,13 @@ function parsePlatformUser( user: string ): { platform: string; userId: string } | null { const colonIndex = user.indexOf(":"); - if (colonIndex === -1) { - // No platform prefix — use as plain userId in API mode - return null; - } + if (colonIndex === -1) return null; return { platform: user.slice(0, colonIndex), userId: user.slice(colonIndex + 1), }; } -/** - * Platform mode: send message through Telegram/Slack via /api/v1/agents/{agentId}/messages. - * The response appears on the platform AND streams to terminal via eventsUrl. - */ async function sendViaPlatform( gatewayUrl: string, authToken: string, @@ -104,6 +150,7 @@ async function sendViaPlatform( userId: string; message: string; thread?: string; + json?: boolean; } ): Promise { const agentId = opts.agentId || `test-${opts.platform}`; @@ -112,14 +159,10 @@ async function sendViaPlatform( content: opts.message, }; - // Platform-specific routing if (opts.platform === "telegram") { body.telegram = { chatId: opts.userId }; } else if (opts.platform === "slack") { - body.slack = { - channel: opts.userId, - thread: opts.thread, - }; + body.slack = { channel: opts.userId, thread: opts.thread }; } else if (opts.platform === "discord") { body.discord = { channelId: opts.userId }; } @@ -153,13 +196,15 @@ async function sendViaPlatform( }; if (result.eventsUrl) { - // Stream the response from the agent const sseUrl = result.eventsUrl.startsWith("http") ? result.eventsUrl : `${gatewayUrl}${result.eventsUrl}`; const sseController = new AbortController(); - await streamResponse(sseUrl, authToken, sseController, result.messageId); + await streamResponse(sseUrl, authToken, sseController, { + expectedMessageId: result.messageId, + json: opts.json, + }); } else { console.log( chalk.dim( @@ -169,20 +214,33 @@ async function sendViaPlatform( } } -/** - * API mode: create session, send message, stream response to terminal. - */ -async function sendViaApi( +interface ApiSendOptions { + agentId?: string; + message: string; + thread?: string; + dryRun?: boolean; + forceNew?: boolean; + autoApprove?: boolean; + json?: boolean; + contextName?: string; +} + +interface ApiSession { + agentId: string; + token: string; + threadId?: string; +} + +async function createSession( gatewayUrl: string, authToken: string, opts: { agentId?: string; - message: string; thread?: string; dryRun?: boolean; forceNew?: boolean; } -): Promise { +): Promise { const createBody: Record = {}; if (opts.agentId) createBody.agentId = opts.agentId; if (opts.thread) createBody.thread = opts.thread; @@ -202,9 +260,7 @@ async function sendViaApi( const body = await createRes.text().catch(() => ""); if (createRes.status === 401) { console.error( - chalk.red( - "\n Authentication required. Run `npx @lobu/cli@latest login`.\n" - ) + chalk.red("\n Authentication required. Run `lobu login`.\n") ); process.exit(1); } @@ -217,14 +273,37 @@ async function sendViaApi( const session = (await createRes.json()) as { agentId: string; token: string; + threadId?: string; + thread?: string; + }; + return { + agentId: session.agentId, + token: session.token, + threadId: session.threadId ?? session.thread, }; +} + +async function sendViaApi( + gatewayUrl: string, + authToken: string, + opts: ApiSendOptions +): Promise { + const session = await createSession(gatewayUrl, authToken, { + agentId: opts.agentId, + thread: opts.thread, + dryRun: opts.dryRun, + forceNew: opts.forceNew, + }); const base = `${gatewayUrl}/api/v1/agents/${session.agentId}`; const sseUrl = `${base}/events`; const messagesUrl = `${base}/messages`; const sseController = new AbortController(); - const streaming = streamResponse(sseUrl, session.token, sseController); + const streaming = streamResponse(sseUrl, session.token, sseController, { + autoApprove: opts.autoApprove, + json: opts.json, + }); const msgRes = await fetch(messagesUrl, { method: "POST", @@ -245,6 +324,10 @@ async function sendViaApi( } await streaming; + + if (opts.contextName && session.threadId) { + await setLastThread(opts.contextName, session.agentId, session.threadId); + } } async function resolveAgentId(cwd: string): Promise { @@ -278,11 +361,17 @@ async function writeStderr(text: string): Promise { }); } +interface StreamOptions { + expectedMessageId?: string; + autoApprove?: boolean; + json?: boolean; +} + async function streamResponse( sseUrl: string, token: string, controller: AbortController, - expectedMessageId?: string + options: StreamOptions = {} ): Promise { const OVERALL_TIMEOUT_MS = 5 * 60 * 1000; const IDLE_TIMEOUT_MS = 60 * 1000; @@ -329,16 +418,28 @@ async function streamResponse( const data = parseJSON(line.slice(6)); if (!data) continue; if ( - expectedMessageId && + options.expectedMessageId && currentEvent !== "connected" && currentEvent !== "ping" && typeof data.messageId === "string" && - data.messageId !== expectedMessageId + data.messageId !== options.expectedMessageId ) { currentEvent = ""; continue; } + if (options.json) { + await writeStdout( + `${JSON.stringify({ event: currentEvent, ...data })}\n` + ); + if (currentEvent === "complete" || currentEvent === "error") { + controller.abort(); + return; + } + currentEvent = ""; + continue; + } + switch (currentEvent) { case "output": if (typeof data.content === "string") { @@ -369,34 +470,43 @@ async function streamResponse( `\n Tool Approval Required\n ${data.mcpId} → ${data.toolName}\n${argsText}\n` ) ); - const options = ["1h", "24h", "always", "deny"]; - const optionLabels: Record = { - "1h": "1h", - "24h": "24h", - always: "always", - deny: "deny always", - }; - await writeStderr( - `${options - .map( - (o, i) => - ` ${chalk.bold(`${i + 1}`)}. ${o === "deny" ? chalk.red(optionLabels[o]) : chalk.green(optionLabels[o])}` - ) - .join("\n")}\n` - ); - const rl = createInterface({ - input: process.stdin, - output: process.stderr, - }); - const answer = await new Promise((resolve) => - rl.question(chalk.dim("\n Choice (1-4): "), (a) => { - rl.close(); - resolve(a.trim()); - }) - ); - const idx = Number.parseInt(answer, 10) - 1; - const decision = options[idx] || "deny"; + let decision: string; + if (options.autoApprove) { + decision = "always"; + await writeStderr( + chalk.dim("\n --auto-approve: approving (always).\n") + ); + } else { + const choices = ["1h", "24h", "always", "deny"]; + const labels: Record = { + "1h": "1h", + "24h": "24h", + always: "always", + deny: "deny always", + }; + await writeStderr( + `${choices + .map( + (o, i) => + ` ${chalk.bold(`${i + 1}`)}. ${o === "deny" ? chalk.red(labels[o]) : chalk.green(labels[o])}` + ) + .join("\n")}\n` + ); + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + const answer = await new Promise((resolve) => + rl.question(chalk.dim("\n Choice (1-4): "), (a) => { + rl.close(); + resolve(a.trim()); + }) + ); + const idx = Number.parseInt(answer, 10) - 1; + decision = choices[idx] || "deny"; + } const approveUrl = sseUrl .replace(/\/events$/, "") diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index c7ec62c33..2b8c8901e 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -2,12 +2,20 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; +import { createServer } from "node:net"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import chalk from "chalk"; import ora from "ora"; import { parseEnvContent } from "../internal/index.js"; +export interface DevOptions { + port?: string; + quiet?: boolean; + verbose?: boolean; + logLevel?: string; +} + /** * `lobu run` — start the embedded Lobu stack. * @@ -19,7 +27,7 @@ import { parseEnvContent } from "../internal/index.js"; */ export async function devCommand( cwd: string, - passthroughArgs: string[] + options: DevOptions = {} ): Promise { const spinner = ora("Validating environment...").start(); @@ -40,14 +48,18 @@ export async function devCommand( console.error(chalk.dim(` DATABASE_URL=`)); console.error( chalk.dim( - "\n Lobu connects to a user-provided Postgres. Run one yourself" + "\n Lobu connects to a user-provided Postgres with pgvector. Pick one:" ) ); + console.error( + chalk.dim(" Docker: docker run -d --name lobu-pg -p 5432:5432 \\") + ); console.error( chalk.dim( - " (managed instance or local: e.g. `brew services start postgresql`).\n" + " -e POSTGRES_PASSWORD=lobu pgvector/pgvector:pg16" ) ); + console.error(chalk.dim(" macOS: brew services start postgresql\n")); process.exit(1); } @@ -70,24 +82,52 @@ export async function devCommand( ) ); console.error(chalk.dim(" In the monorepo, build it via:")); - console.error( - chalk.dim(" bun run --filter '@lobu/server' build:server\n") - ); + console.error(chalk.dim(" make build-packages\n")); process.exit(1); } spinner.succeed("Environment ready"); - const port = mergedEnv.GATEWAY_PORT || mergedEnv.PORT || "8787"; + const port = + options.port ?? mergedEnv.GATEWAY_PORT ?? mergedEnv.PORT ?? "8787"; + const portNum = Number(port); + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { + console.error(chalk.red(`\n Invalid port "${port}" — must be 1-65535.\n`)); + process.exit(1); + } 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(mergedEnv.DATABASE_URL!)}`) - ); - console.log(chalk.dim(` api docs: ${gatewayUrl}/api/docs`)); - console.log(); + const portFree = await isPortFree(portNum); + if (!portFree) { + console.error(chalk.red(`\n Port ${port} is already in use.`)); + console.error( + chalk.dim( + " Stop the other process, or pass `--port ` / set `GATEWAY_PORT` to a free port.\n" + ) + ); + console.error( + chalk.dim( + process.platform === "darwin" || process.platform === "linux" + ? ` Find what's holding it: lsof -iTCP:${port} -sTCP:LISTEN\n` + : ` Find what's holding it: netstat -ano | findstr :${port}\n` + ) + ); + process.exit(1); + } + + if (!options.quiet) { + console.log(chalk.cyan(`\n Starting Lobu...\n`)); + console.log(chalk.dim(` bundle: ${bundlePath}`)); + console.log( + chalk.dim(` database: ${redactUrl(mergedEnv.DATABASE_URL!)}`) + ); + console.log(chalk.dim(` api docs: ${gatewayUrl}/api/docs`)); + console.log(); + } + + const logLevel = + options.logLevel ?? + (options.quiet ? "warn" : options.verbose ? "debug" : undefined); // Pass-through env: process.env wins so users can override per-invocation, // .env values fill in the rest. LOBU_DEV_PROJECT_PATH is optional and only @@ -97,9 +137,11 @@ export async function devCommand( LOBU_DEV_PROJECT_PATH: process.env.LOBU_DEV_PROJECT_PATH || envVars.LOBU_DEV_PROJECT_PATH || cwd, PORT: port, + GATEWAY_PORT: port, + ...(logLevel ? { LOG_LEVEL: logLevel } : {}), }; - const child = spawn("node", [bundlePath, ...passthroughArgs], { + const child = spawn("node", [bundlePath], { cwd, env: childEnv, stdio: "inherit", @@ -140,10 +182,6 @@ export function resolveBackendBundle( const here = startDir; const require_ = createRequire(import.meta.url); - // 1. Bundled inside the CLI tarball at `dist/server.bundle.mjs`. The - // compiled command module lives under `dist/commands/`, so check both - // the module directory (legacy/local builds) and the dist root where - // `packages/cli/scripts/build.cjs` copies the bundle. for (const bundled of [ join(here, "server.bundle.mjs"), join(here, "..", "server.bundle.mjs"), @@ -151,16 +189,12 @@ export function resolveBackendBundle( if (existsSync(bundled)) return bundled; } - // 2. Resolved via node_modules — covers a workspace consumer that has - // `@lobu/server` linked locally (e.g. internal monorepo). try { return require_.resolve("@lobu/server/dist/server.bundle.mjs"); } catch { // not installed as a dep } - // 3. Monorepo-relative lookup — covers `bun run packages/cli/...` from a - // fresh clone before the CLI itself has been published. let cur = here; for (let i = 0; i < 6; i++) { const candidate = join(cur, "packages/server/dist/server.bundle.mjs"); @@ -173,6 +207,23 @@ export function resolveBackendBundle( return null; } +export function isPortFree(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + const settle = (free: boolean) => { + server.removeAllListeners(); + server.close(() => resolve(free)); + }; + server.once("error", () => settle(false)); + server.once("listening", () => settle(true)); + try { + server.listen({ port, host: "127.0.0.1", exclusive: true }); + } catch { + settle(false); + } + }); +} + function redactUrl(url: string): string { try { const u = new URL(url); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index dbb62aab4..9767b26b2 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,7 +1,13 @@ import { execFileSync } from "node:child_process"; +import { access, constants, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; import chalk from "chalk"; import { checkMemoryHealth } from "./memory/_lib/openclaw-cmd.js"; import { resolveServerUrl } from "./memory/_lib/openclaw-auth.js"; +import { isPortFree } from "./dev.js"; +import { parseEnvContent } from "../internal/env-file.js"; +import { loadProviderRegistry } from "./providers/registry.js"; +import { isLoadError, loadConfig } from "../config/loader.js"; interface Check { name: string; @@ -51,8 +57,127 @@ async function checkServerReachable(url: string): Promise { } } +async function loadProjectEnv(cwd: string): Promise> { + try { + const raw = await readFile(join(cwd, ".env"), "utf-8"); + return parseEnvContent(raw); + } catch { + return {}; + } +} + +async function checkDatabaseAndPgvector(databaseUrl: string): Promise { + const results: Check[] = []; + const { default: postgres } = await import("postgres"); + const sql = postgres(databaseUrl, { + connect_timeout: 5, + max: 1, + idle_timeout: 1, + onnotice: () => undefined, + }); + + try { + const rows = await sql<{ version: string }[]>`SELECT version() AS version`; + const version = String(rows[0]?.version ?? "").split(" on ")[0]; + results.push({ + name: "database", + status: "ok", + detail: version || "connected", + }); + } catch (err) { + results.push({ + name: "database", + status: "fail", + detail: `connect failed: ${(err as Error).message}`, + }); + await sql.end({ timeout: 1 }).catch(() => undefined); + return results; + } + + try { + const rows = await sql< + { extname: string; extversion: string }[] + >`SELECT extname, extversion FROM pg_extension WHERE extname = 'vector'`; + if (rows.length === 0) { + results.push({ + name: "pgvector", + status: "fail", + detail: "extension not installed (CREATE EXTENSION vector)", + }); + } else { + results.push({ + name: "pgvector", + status: "ok", + detail: `v${rows[0]?.extversion}`, + }); + } + } catch (err) { + results.push({ + name: "pgvector", + status: "warn", + detail: `check failed: ${(err as Error).message}`, + }); + } + + await sql.end({ timeout: 5 }).catch(() => undefined); + return results; +} + +async function checkPortAvailability(port: number): Promise { + const free = await isPortFree(port); + return { + name: `port:${port}`, + status: free ? "ok" : "fail", + detail: free ? "available" : "in use", + }; +} + +async function checkProviderKeys( + cwd: string, + env: Record +): Promise { + const result = await loadConfig(cwd); + if (isLoadError(result)) return []; + + const registry = loadProviderRegistry(); + const checks: Check[] = []; + const seen = new Set(); + + for (const agent of Object.values(result.config.agents)) { + for (const provider of agent.providers ?? []) { + const reg = registry.find((r) => r.id === provider.id); + const envVar = reg?.providers?.[0]?.envVarName; + if (!envVar || seen.has(envVar)) continue; + seen.add(envVar); + + const value = env[envVar] ?? process.env[envVar]; + checks.push({ + name: `provider:${provider.id}`, + status: value ? "ok" : "fail", + detail: value ? `${envVar} set` : `${envVar} missing`, + }); + } + } + return checks; +} + +async function checkWorkspaceDir(cwd: string): Promise { + const dir = join(cwd, "workspaces"); + try { + await access(dir, constants.F_OK); + const info = await stat(dir); + return info.isDirectory() + ? { name: "workspaces", status: "ok", detail: dir } + : { name: "workspaces", status: "warn", detail: "not a directory" }; + } catch { + // Missing is fine — gateway creates it on first run. + return null; + } +} + interface DoctorOptions { memoryOnly?: boolean; + cwd?: string; } export async function doctorCommand( @@ -63,11 +188,34 @@ export async function doctorCommand( return; } + const cwd = options.cwd ?? process.cwd(); + const env = await loadProjectEnv(cwd); const checks: Check[] = []; checks.push(checkNodeVersion()); checks.push(checkBinaryExists("git")); + const databaseUrl = env.DATABASE_URL ?? process.env.DATABASE_URL; + if (databaseUrl) { + checks.push(...(await checkDatabaseAndPgvector(databaseUrl))); + } else { + checks.push({ + name: "database", + status: "warn", + detail: "DATABASE_URL not set (set in .env or environment)", + }); + } + + const port = Number(env.GATEWAY_PORT ?? env.PORT ?? "8787"); + if (Number.isInteger(port) && port > 0) { + checks.push(await checkPortAvailability(port)); + } + + checks.push(...(await checkProviderKeys(cwd, env))); + + const ws = await checkWorkspaceDir(cwd); + if (ws) checks.push(ws); + const serverUrl = await resolveServerUrl(); if (serverUrl) checks.push(await checkServerReachable(serverUrl)); diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index 99ea2868d..0405289d1 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -1,4 +1,10 @@ -import { readFile } from "node:fs/promises"; +import { + access, + constants, + mkdir, + readFile, + writeFile, +} from "node:fs/promises"; import { basename, join } from "node:path"; import chalk from "chalk"; import { parse as parseYaml } from "yaml"; @@ -226,6 +232,82 @@ export async function evalCommand( } } +const EVAL_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; + +export interface EvalNewOptions { + cwd?: string; + agent?: string; + description?: string; + trials?: number; +} + +/** Scaffold a YAML eval into agents//evals/. */ +export async function evalNewCommand( + name: string, + options: EvalNewOptions = {} +): Promise { + if (!EVAL_NAME_PATTERN.test(name)) { + console.error( + chalk.red( + `\n Invalid eval name "${name}". Use lowercase alphanumeric, dashes, or underscores.\n` + ) + ); + process.exit(1); + } + + const cwd = options.cwd ?? process.cwd(); + const result = await loadConfig(cwd); + if (isLoadError(result)) { + console.error(chalk.red(`\n ${result.error}\n`)); + process.exit(1); + } + + const agentIds = Object.keys(result.config.agents); + const agentId = options.agent ?? agentIds[0]; + if (!agentId) { + console.error(chalk.red("\n No agents found in lobu.toml\n")); + process.exit(1); + } + const agent = result.config.agents[agentId]; + if (!agent) { + console.error(chalk.red(`\n Agent "${agentId}" not found in lobu.toml\n`)); + process.exit(1); + } + + const evalsDir = join(cwd, agent.dir, "evals"); + await mkdir(evalsDir, { recursive: true }); + const file = join(evalsDir, `${name}.yaml`); + try { + await access(file, constants.F_OK); + console.error(chalk.red(`\n ${file} already exists.\n`)); + process.exit(1); + } catch { + // expected + } + + const description = + options.description ?? `Eval for ${name} (edit me to assert real behavior)`; + const trials = options.trials ?? 3; + const yaml = `version: 1 +name: ${name} +description: ${JSON.stringify(description)} +trials: ${trials} +timeout: 30 +tags: [] + +turns: + - content: "Replace this with the user message you want to test." + assert: + - type: llm-rubric + value: "Response is correct, helpful, and on-topic." + weight: 1.0 +`; + + await writeFile(file, yaml); + console.log(chalk.green(`\n Created ${file}\n`)); + console.log(chalk.dim(` Run it with: lobu eval ${name}\n`)); +} + async function discoverEvals( evalsDir: string, filterName?: string diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e28032cc0..5694a8ac4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { constants } from "node:fs"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; import { confirm, input, password, select } from "@inquirer/prompts"; import chalk from "chalk"; import ora from "ora"; @@ -16,96 +16,199 @@ import { renderTemplate } from "../utils/template.js"; const DEFAULT_OWLETTO_MCP_URL = "https://lobu.ai/mcp"; +const PROJECT_NAME_PATTERN = /^[a-z0-9-]+$/; +const PLATFORM_CHOICES = [ + "telegram", + "slack", + "discord", + "whatsapp", + "teams", + "gchat", +] as const; +type PlatformChoice = (typeof PLATFORM_CHOICES)[number]; +const NETWORK_CHOICES = ["restricted", "open", "isolated"] as const; +type NetworkChoice = (typeof NETWORK_CHOICES)[number]; +const MEMORY_CHOICES = ["none", "owletto-cloud", "owletto-custom"] as const; +type MemoryChoice = (typeof MEMORY_CHOICES)[number]; + +export interface InitOptions { + yes?: boolean; + here?: boolean; + port?: string; + publicUrl?: string; + network?: string; + provider?: string; + providerKey?: string; + platform?: string; + memory?: string; + memoryUrl?: string; + otelEndpoint?: string; + sentry?: boolean; + noSentry?: boolean; +} + export async function initCommand( cwd: string = process.cwd(), - projectNameArg?: string + projectNameArg?: string, + options: InitOptions = {} ): Promise { - console.log(chalk.bold.cyan("\n🤖 Welcome to Lobu!\n")); - - // Get CLI version const cliVersion = await getCliVersion(); - - // Validate project name if provided as argument - if (projectNameArg && !/^[a-z0-9-]+$/.test(projectNameArg)) { - console.log( - chalk.red( - "\n✗ Project name must be lowercase alphanumeric with hyphens only\n" - ) + const useDefaults = options.yes === true; + + // Catch flag combos that can't satisfy a prompt before we mkdir anything. + if ( + useDefaults && + options.memory === "owletto-custom" && + !options.memoryUrl + ) { + console.error( + chalk.red("\n✗ --memory owletto-custom requires --memory-url .\n") ); process.exit(1); } - // Interactive prompts - basic setup - const projectName = - projectNameArg || - (await input({ - message: "Project name?", - default: "my-lobu", - validate: (value: string) => { - if (!/^[a-z0-9-]+$/.test(value)) { - return "Project name must be lowercase alphanumeric with hyphens only"; - } - return true; - }, - })); - const projectDir = join(cwd, projectName); - try { - await access(projectDir, constants.F_OK); - console.log( - chalk.red( - `\n✗ Directory "${projectName}" already exists. Please choose a different project name or remove the existing directory.\n` - ) + const here = options.here || projectNameArg === "."; + let projectName: string; + let projectDir: string; + + if (here) { + projectDir = cwd; + projectName = basename(resolve(cwd)); + if (!PROJECT_NAME_PATTERN.test(projectName)) { + // Common when cwd is e.g. "My Project". Force user to pick a slug. + projectName = + projectNameArg && projectNameArg !== "." + ? projectNameArg + : await promptOrDefault({ + flag: undefined, + useDefaults, + defaultValue: slugify(basename(resolve(cwd))) || "my-lobu", + prompt: () => + input({ + message: "Project slug?", + default: slugify(basename(resolve(cwd))) || "my-lobu", + validate: validateProjectName, + }), + }); + } + const entries = await readdir(projectDir).catch(() => [] as string[]); + const conflict = entries.some( + (n) => n === "lobu.toml" || n === "agents" || n === ".env" ); - process.exit(1); - } catch { - // Directory doesn't exist - good to proceed - await mkdir(projectDir, { recursive: true }); + if (conflict) { + console.log( + chalk.red( + `\n✗ ${projectDir} already contains a Lobu project (lobu.toml / agents/ / .env).\n Remove them or pick another directory.\n` + ) + ); + process.exit(1); + } console.log( - chalk.dim(`\nCreating project in: ${chalk.cyan(projectDir)}\n`) + chalk.dim( + `\nScaffolding into current directory: ${chalk.cyan(projectDir)}\n` + ) ); + } else { + if (projectNameArg && !PROJECT_NAME_PATTERN.test(projectNameArg)) { + console.log( + chalk.red( + `\n✗ Project name must be lowercase alphanumeric with hyphens only (got: ${projectNameArg}).\n` + ) + ); + process.exit(1); + } + projectName = + projectNameArg ?? + (await promptOrDefault({ + flag: undefined, + useDefaults, + defaultValue: "my-lobu", + prompt: () => + input({ + message: "Project name?", + default: "my-lobu", + validate: validateProjectName, + }), + })); + projectDir = join(cwd, projectName); + try { + await access(projectDir, constants.F_OK); + console.log( + chalk.red( + `\n✗ Directory "${projectName}" already exists. Pick a different name, remove it, or pass \`--here\` to scaffold into the current directory.\n` + ) + ); + process.exit(1); + } catch { + await mkdir(projectDir, { recursive: true }); + console.log( + chalk.dim(`\nCreating project in: ${chalk.cyan(projectDir)}\n`) + ); + } } - // Gateway port selection - const gatewayPort = await input({ - message: "Gateway port?", - default: "8787", + const gatewayPort = await promptOrDefault({ + flag: options.port, + useDefaults, + defaultValue: "8787", validate: (value: string) => { - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - return "Please enter a valid port number (1-65535)"; - } - return true; + const p = Number(value); + return Number.isInteger(p) && p >= 1 && p <= 65535 + ? true + : "Please enter a valid port (1-65535)"; }, + prompt: () => + input({ + message: "Gateway port?", + default: "8787", + validate: (value: string) => { + const p = Number(value); + if (!Number.isInteger(p) || p < 1 || p > 65535) { + return "Please enter a valid port number (1-65535)"; + } + return true; + }, + }), }); - // Public gateway URL (optional — only needed for OAuth callbacks and external webhooks) - const publicGatewayUrl = await input({ - message: - "Public gateway URL? (leave empty for local dev, set for OAuth/webhooks)", - default: "", + const publicGatewayUrl = await promptOrDefault({ + flag: options.publicUrl, + useDefaults, + defaultValue: "", + prompt: () => + input({ + message: + "Public gateway URL? (leave empty for local dev, set for OAuth/webhooks)", + default: "", + }), }); - // Worker network access policy - const networkPolicy = await select<"restricted" | "open" | "isolated">({ - message: "Worker network access?", - choices: [ - { - name: "Restricted (recommended) — common registries only (npm, GitHub, PyPI)", - value: "restricted", - }, - { - name: "Open — workers can access any domain", - value: "open", - }, - { - name: "Isolated — workers have no internet access", - value: "isolated", - }, - ], - default: "restricted", - }); + const networkPolicy = (await promptOrDefault({ + flag: options.network, + useDefaults, + defaultValue: "restricted", + validate: (v: string) => + (NETWORK_CHOICES as readonly string[]).includes(v) + ? true + : `Must be one of: ${NETWORK_CHOICES.join(", ")}`, + prompt: () => + select({ + message: "Worker network access?", + choices: [ + { + name: "Restricted (recommended) — common registries only (npm, GitHub, PyPI)", + value: "restricted", + }, + { name: "Open — workers can access any domain", value: "open" }, + { + name: "Isolated — workers have no internet access", + value: "isolated", + }, + ], + default: "restricted", + }), + })) as NetworkChoice; - // Provider selection (from the bundled providers registry) const providerSkills = loadProviderRegistry(); const providerChoices = [ { name: "Skip — I'll add a provider later", value: "" }, @@ -115,10 +218,23 @@ export async function initCommand( })), ]; - const providerId = await select({ - message: "AI provider?", - choices: providerChoices, - default: "", + const providerId = await promptOrDefault({ + flag: options.provider, + useDefaults, + defaultValue: "", + validate: (v: string) => + v === "" || providerChoices.some((c) => c.value === v) + ? true + : `Unknown provider "${v}". Available: ${providerChoices + .filter((c) => c.value) + .map((c) => c.value) + .join(", ")}`, + prompt: () => + select({ + message: "AI provider?", + choices: providerChoices, + default: "", + }), }); let providerApiKey = ""; @@ -127,17 +243,20 @@ export async function initCommand( selectedProvider = getProviderById(providerId); const p = selectedProvider?.providers?.[0]; if (p) { - providerApiKey = await password({ - message: `${p.displayName} API key:`, - mask: true, - }); + if (options.providerKey) { + providerApiKey = options.providerKey; + } else if (process.env[p.envVarName]) { + // Inherit from env so `--yes` can pick up keys set in the shell. + providerApiKey = process.env[p.envVarName] ?? ""; + } else if (!useDefaults) { + providerApiKey = await password({ + message: `${p.displayName} API key:`, + mask: true, + }); + } } } - // Define skills locally via skills//SKILL.md or - // agents//skills//SKILL.md. - - // Chat platform selection const platformChoices = [ { name: "Skip — I'll connect a platform later", value: "" }, { name: "Telegram", value: "telegram" }, @@ -148,28 +267,54 @@ export async function initCommand( { name: "Google Chat", value: "gchat" }, ]; - const platformType = await select({ - message: "Connect a chat platform?", - choices: platformChoices, - default: "", + const platformType = await promptOrDefault({ + flag: options.platform, + useDefaults, + defaultValue: "", + validate: (v: string) => + v === "" || (PLATFORM_CHOICES as readonly string[]).includes(v) + ? true + : `Unknown platform "${v}". Available: ${PLATFORM_CHOICES.join(", ")}`, + prompt: () => + select({ + message: "Connect a chat platform?", + choices: platformChoices, + default: "", + }), }); - const { platformConfig, platformSecrets } = platformType - ? await promptPlatformConfig(platformType) - : { platformConfig: {}, platformSecrets: [] }; - - // Memory - const memoryChoice = await select< - "none" | "owletto-cloud" | "owletto-custom" - >({ - message: "Memory:", - choices: [ - { name: "None (filesystem memory)", value: "none" }, - { name: "Lobu Cloud (app.lobu.ai)", value: "owletto-cloud" }, - { name: "Custom Lobu memory URL", value: "owletto-custom" }, - ], - default: "none", - }); + // Interactive: prompt for real secrets. --yes: write placeholder env-var + // refs into lobu.toml; the user fills .env afterwards. + let platformConfig: Record = {}; + let platformSecrets: Array<{ envVar: string; value: string }> = []; + if (platformType) { + if (useDefaults) { + platformConfig = PLATFORM_PLACEHOLDERS[platformType as PlatformChoice]; + } else { + ({ platformConfig, platformSecrets } = + await promptPlatformConfig(platformType)); + } + } + + const memoryChoice = (await promptOrDefault({ + flag: options.memory, + useDefaults, + defaultValue: "none", + validate: (v: string) => + (MEMORY_CHOICES as readonly string[]).includes(v) + ? true + : `Must be one of: ${MEMORY_CHOICES.join(", ")}`, + prompt: () => + select({ + message: "Memory:", + choices: [ + { name: "None (filesystem memory)", value: "none" }, + { name: "Lobu Cloud (app.lobu.ai)", value: "owletto-cloud" }, + { name: "Custom Lobu memory URL", value: "owletto-custom" }, + ], + default: "none", + }), + })) as MemoryChoice; const envSecrets: Array<{ envVar: string; value: string }> = []; const includeOwlettoMemory = memoryChoice !== "none"; @@ -178,19 +323,25 @@ export async function initCommand( if (memoryChoice === "owletto-cloud") { owlettoUrl = DEFAULT_OWLETTO_MCP_URL; } else if (memoryChoice === "owletto-custom") { - owlettoUrl = await input({ - message: "Lobu memory MCP URL:", - validate: (v: string) => (v ? true : "URL is required"), - }); + owlettoUrl = + options.memoryUrl ?? + (await input({ + message: "Lobu memory MCP URL:", + validate: (v: string) => (v ? true : "URL is required"), + })); envSecrets.push({ envVar: "MEMORY_URL", value: owlettoUrl }); } - // "none" — no memory scaffold, gateway defaults to filesystem memory - // Observability — OTEL tracing endpoint - const otelEndpoint = await input({ - message: - "OpenTelemetry collector endpoint? (leave empty to disable tracing)", - default: "", + const otelEndpoint = await promptOrDefault({ + flag: options.otelEndpoint, + useDefaults, + defaultValue: "", + prompt: () => + input({ + message: + "OpenTelemetry collector endpoint? (leave empty to disable tracing)", + default: "", + }), }); if (otelEndpoint) { @@ -200,12 +351,18 @@ export async function initCommand( }); } - // Observability — Sentry error reporting - const enableSentry = await confirm({ - message: - "Help improve Lobu by sharing anonymous error reports with Sentry?", - default: true, - }); + let enableSentry = false; + if (options.sentry === true) { + enableSentry = true; + } else if (options.noSentry === true) { + enableSentry = false; + } else if (!useDefaults) { + enableSentry = await confirm({ + message: + "Share anonymous error reports with Sentry to help improve Lobu?", + default: false, + }); + } if (enableSentry) { envSecrets.push({ @@ -215,7 +372,6 @@ export async function initCommand( }); } - // Compute network domains from selected policy let allowedDomains: string; let disallowedDomains: string; if (networkPolicy === "open") { @@ -225,7 +381,6 @@ export async function initCommand( allowedDomains = ""; disallowedDomains = ""; } else { - // restricted (default) allowedDomains = [ "registry.npmjs.org", ".npmjs.org", @@ -250,7 +405,6 @@ export async function initCommand( const spinner = ora("Creating Lobu project...").start(); try { - // Create data directory in project directory await mkdir(join(projectDir, "data"), { recursive: true }); if (includeOwlettoMemory) { @@ -267,7 +421,6 @@ export async function initCommand( ); } - // Generate lobu.toml await generateLobuToml(projectDir, { agentName: projectName, allowedDomains: answers.allowedDomains, @@ -291,10 +444,8 @@ export async function initCommand( WORKER_DISALLOWED_DOMAINS: answers.disallowedDomains, }; - // Create .env file await renderTemplate(".env.tmpl", variables, join(projectDir, ".env")); - // Save public gateway URL if explicitly set if (publicGatewayUrl) { await setLocalEnvValue( projectDir, @@ -303,7 +454,6 @@ export async function initCommand( ); } - // Save provider API key to .env if (providerApiKey && selectedProvider?.providers?.[0]?.envVarName) { await setLocalEnvValue( projectDir, @@ -312,27 +462,21 @@ export async function initCommand( ); } - // Save platform secrets to .env for (const secret of platformSecrets) { await setLocalEnvValue(projectDir, secret.envVar, secret.value); } - // Save OAuth secrets to .env for (const secret of envSecrets) { await setLocalEnvValue(projectDir, secret.envVar, secret.value); } - // Create .gitignore await renderTemplate(".gitignore.tmpl", {}, join(projectDir, ".gitignore")); - - // Create README await renderTemplate( "README.md.tmpl", variables, join(projectDir, "README.md") ); - // Create agent directory with instruction files const agentDir = join(projectDir, "agents", projectName); await mkdir(agentDir, { recursive: true }); await writeFile( @@ -348,46 +492,20 @@ export async function initCommand( `# User Context\n\n\n` ); - // Create agent-specific skills directory await mkdir(join(agentDir, "skills"), { recursive: true }); await writeFile(join(agentDir, "skills", ".gitkeep"), ""); - // Create evals directory with sample eval await mkdir(join(agentDir, "evals"), { recursive: true }); - await writeFile( - join(agentDir, "evals", "ping.yaml"), - `version: 1 -name: ping -description: Agent responds to a simple greeting -trials: 3 -timeout: 30 -tags: [smoke, fast] + await writeFile(join(agentDir, "evals", "ping.yaml"), DEFAULT_EVAL_YAML); -turns: - - content: "Hello, are you there?" - assert: - - type: contains - value: "hello" - options: { case_insensitive: true } - weight: 0.3 - - type: llm-rubric - value: "Response is friendly and acknowledges the greeting" - weight: 0.7 -` - ); - - // Create shared skills directory await mkdir(join(projectDir, "skills"), { recursive: true }); await writeFile(join(projectDir, "skills", ".gitkeep"), ""); - // Create AGENTS.md await renderTemplate( "AGENTS.md.tmpl", variables, join(projectDir, "AGENTS.md") ); - - // Create TESTING.md await renderTemplate( "TESTING.md.tmpl", variables, @@ -396,78 +514,118 @@ turns: spinner.succeed("Project created successfully!"); - // Print next steps + const gatewayUrl = `http://localhost:${gatewayPort}`; console.log(chalk.green("\n✓ Lobu initialized!\n")); console.log(chalk.bold("Next steps:\n")); - console.log(chalk.cyan(" 1. Navigate to your project:")); - console.log(chalk.dim(` cd ${projectName}\n`)); - console.log(chalk.cyan(" 2. Review your configuration:")); - console.log( - chalk.dim( - " - lobu.toml (agents, providers, skills, network)" - ) - ); - console.log( - chalk.dim( - ` - agents/${projectName}/ (IDENTITY.md, SOUL.md, USER.md, skills/)` - ) - ); + let n = 1; + if (!here) { + console.log(chalk.cyan(` ${n++}. cd ${projectName}`)); + } console.log( - chalk.dim( - " - skills/ (shared skills — all agents)" - ) + chalk.cyan(` ${n++}. Set DATABASE_URL in .env (Postgres + pgvector):`) ); - if (includeOwlettoMemory) { - console.log( - chalk.dim(" - models/ (memory model files)") - ); - console.log( - chalk.dim(" - data/ (memory seed data)") - ); - } - console.log(chalk.dim(" - .env (secrets)")); - console.log(); - - const gatewayUrl = `http://localhost:${gatewayPort}`; - console.log(chalk.cyan(" 3. Set DATABASE_URL in .env:")); console.log( chalk.dim( - " Lobu connects to a user-provided Postgres. Run one yourself" + " docker run -d --name lobu-pg -p 5432:5432 -e POSTGRES_PASSWORD=lobu pgvector/pgvector:pg16" ) ); console.log( chalk.dim( - " (managed instance or local: e.g. `brew services start postgresql`)\n" + " DATABASE_URL=postgresql://postgres:lobu@localhost:5432/postgres" ) ); if (owlettoUrl) { - console.log(chalk.cyan(" Lobu memory:")); - console.log(chalk.dim(` ${owlettoUrl}`)); console.log( - chalk.dim( - " Run `lobu memory init` to configure local MCP clients.\n" - ) + chalk.cyan(` ${n++}. Wire memory clients: lobu memory init`) ); } - console.log(chalk.cyan(" 4. Start the services:")); - console.log(chalk.dim(" npx @lobu/cli@latest run\n")); - console.log(chalk.cyan(" 5. Open the API docs:")); - console.log(chalk.dim(` ${gatewayUrl}/api/docs\n`)); - console.log(chalk.cyan(" 6. Build with a coding agent:")); + console.log(chalk.cyan(` ${n++}. Start the stack: lobu run`)); + console.log(chalk.cyan(` ${n++}. API docs: ${gatewayUrl}/api/docs`)); console.log( chalk.dim( - " Ask Codex or Claude Code to read AGENTS.md, lobu.toml, and agents/*/{IDENTITY,SOUL,USER}.md" + "\n See README.md for layout, AGENTS.md for the agent contract.\n" ) ); - console.log(chalk.dim(" Optional external skill: lobu-builder\n")); - console.log(chalk.cyan(" 7. Stop the services:")); - console.log(chalk.dim(" Ctrl+C in the terminal running `lobu run`\n")); } catch (error) { spinner.fail("Failed to create project"); throw error; } } +const DEFAULT_EVAL_YAML = `version: 1 +name: ping +description: Agent responds to a simple greeting +trials: 3 +timeout: 30 +tags: [smoke, fast] + +turns: + - content: "Hello, are you there?" + assert: + - type: contains + value: "hello" + options: { case_insensitive: true } + weight: 0.3 + - type: llm-rubric + value: "Response is friendly and acknowledges the greeting" + weight: 0.7 +`; + +function validateProjectName(value: string): string | true { + if (!PROJECT_NAME_PATTERN.test(value)) { + return "Project name must be lowercase alphanumeric with hyphens only"; + } + return true; +} + +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +interface PromptOrDefaultOptions { + flag: string | undefined; + useDefaults: boolean; + defaultValue: T; + prompt: () => Promise; + validate?: (value: string) => true | string; +} + +async function promptOrDefault( + opts: PromptOrDefaultOptions +): Promise { + if (opts.flag !== undefined) { + if (opts.validate) { + const result = opts.validate(opts.flag); + if (result !== true) { + throw new Error(result); + } + } + return opts.flag as T; + } + if (opts.useDefaults) return opts.defaultValue; + return opts.prompt(); +} + +// Placeholder env-var refs for `--yes` mode; the user fills the values into .env. +const PLATFORM_PLACEHOLDERS: Record> = { + telegram: { botToken: "$TELEGRAM_BOT_TOKEN" }, + slack: { + botToken: "$SLACK_BOT_TOKEN", + signingSecret: "$SLACK_SIGNING_SECRET", + }, + discord: { botToken: "$DISCORD_BOT_TOKEN" }, + whatsapp: { + accessToken: "$WHATSAPP_ACCESS_TOKEN", + phoneNumberId: "$WHATSAPP_PHONE_NUMBER_ID", + }, + teams: { appId: "$TEAMS_APP_ID", appPassword: "$TEAMS_APP_PASSWORD" }, + gchat: { credentials: "$GOOGLE_CHAT_CREDENTIALS" }, +}; + function humanizeSlug(slug: string): string { return slug .split("-") @@ -560,7 +718,6 @@ export async function generateLobuToml( '# client_id = "$MY_MCP_CLIENT_ID"' ); - // Network lines.push("", `[agents.${id}.network]`); if (options.allowedDomains) { const domains = options.allowedDomains @@ -590,7 +747,7 @@ export async function generateLobuToml( ); } - lines.push(""); // trailing newline + lines.push(""); await writeFile(join(projectDir, "lobu.toml"), lines.join("\n")); } diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts new file mode 100644 index 000000000..eca0369f1 --- /dev/null +++ b/packages/cli/src/commands/link.ts @@ -0,0 +1,46 @@ +import chalk from "chalk"; +import { getActiveOrg, resolveContext } from "../internal/index.js"; +import { + loadProjectLink, + removeProjectLink, + saveProjectLink, +} from "../internal/project-link.js"; + +interface LinkOptions { + context?: string; + org?: string; + cwd?: string; +} + +export async function linkCommand(options: LinkOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const target = await resolveContext(options.context); + const org = options.org?.trim() || (await getActiveOrg(target.name)) || ""; + if (!org) { + console.error( + chalk.red( + "\n No org selected. Run `lobu org set ` or pass `--org `.\n" + ) + ); + process.exit(1); + } + + const link = await saveProjectLink(cwd, { context: target.name, org }); + console.log(chalk.green("\n Project linked.")); + console.log(chalk.dim(` Context: ${link.context}`)); + console.log(chalk.dim(` Org: ${link.org}`)); + console.log(chalk.dim(` Path: ${cwd}/.lobu/project.json\n`)); +} + +export async function unlinkCommand( + options: { cwd?: string } = {} +): Promise { + const cwd = options.cwd ?? process.cwd(); + const existing = await loadProjectLink(cwd); + if (!existing) { + console.log(chalk.dim("\n No project link found.\n")); + return; + } + await removeProjectLink(cwd); + console.log(chalk.green("\n Project unlinked.\n")); +} diff --git a/packages/cli/src/commands/telemetry.ts b/packages/cli/src/commands/telemetry.ts new file mode 100644 index 000000000..cd99446cd --- /dev/null +++ b/packages/cli/src/commands/telemetry.ts @@ -0,0 +1,82 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import chalk from "chalk"; +import { setLocalEnvValue } from "../internal/local-env.js"; +import { parseEnvContent } from "../internal/env-file.js"; + +const SENTRY_DSN_DEFAULT = + "https://c5910e58d1a134d64ff93a95a9c535bb@o4507291398897664.ingest.us.sentry.io/4511097466781696"; + +interface TelemetryOptions { + cwd?: string; +} + +async function loadEnv(cwd: string): Promise> { + try { + const raw = await readFile(join(cwd, ".env"), "utf-8"); + return parseEnvContent(raw); + } catch { + return {}; + } +} + +export async function telemetryStatusCommand( + options: TelemetryOptions = {} +): Promise { + const cwd = options.cwd ?? process.cwd(); + const env = await loadEnv(cwd); + const dsn = env.SENTRY_DSN ?? process.env.SENTRY_DSN; + if (dsn) { + console.log(chalk.green("\n Telemetry: on")); + console.log(chalk.dim(` SENTRY_DSN: ${redactDsn(dsn)}\n`)); + } else { + console.log(chalk.dim("\n Telemetry: off")); + console.log(chalk.dim(" No SENTRY_DSN configured.\n")); + } +} + +export async function telemetryOnCommand( + options: TelemetryOptions & { dsn?: string } = {} +): Promise { + const cwd = options.cwd ?? process.cwd(); + const dsn = options.dsn ?? SENTRY_DSN_DEFAULT; + await setLocalEnvValue(cwd, "SENTRY_DSN", dsn); + console.log(chalk.green("\n Telemetry enabled.")); + console.log(chalk.dim(` Wrote SENTRY_DSN to ${join(cwd, ".env")}\n`)); +} + +export async function telemetryOffCommand( + options: TelemetryOptions = {} +): Promise { + const cwd = options.cwd ?? process.cwd(); + const envPath = join(cwd, ".env"); + let raw = ""; + try { + raw = await readFile(envPath, "utf-8"); + } catch { + console.log(chalk.dim("\n No .env to update — telemetry already off.\n")); + return; + } + const filtered = raw + .split("\n") + .filter((line) => !line.trim().startsWith("SENTRY_DSN=")) + .join("\n"); + await writeFile( + envPath, + filtered.endsWith("\n") ? filtered : `${filtered}\n` + ); + console.log(chalk.green("\n Telemetry disabled.")); + console.log(chalk.dim(` Removed SENTRY_DSN from ${envPath}\n`)); +} + +function redactDsn(dsn: string): string { + try { + const url = new URL(dsn); + // The Sentry public key (URL username) is genuinely public. Only + // a deprecated DSN format includes a secret in the URL password. + if (url.password) url.password = "***"; + return url.toString(); + } catch { + return "***"; + } +} diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index bd2185483..cade8e76e 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -28,9 +28,7 @@ export async function whoamiCommand(options?: { console.log(chalk.dim("\n Not logged in.")); console.log(chalk.dim(` Context: ${target.name}`)); console.log(chalk.dim(` API URL: ${target.apiUrl}`)); - console.log( - chalk.dim(" Run `npx @lobu/cli@latest login` to authenticate.\n") - ); + console.log(chalk.dim(" Run `lobu login` to authenticate.\n")); return; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2efdc25d7..b79f7e1d7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -39,26 +39,125 @@ export async function runCli( .description("CLI for deploying and managing AI agents on Lobu") .version(version); + // Group commands in --help output. Commander v14 has no native grouping, + // so we override the help formatter via addHelpText to print our own + // categorized list. The flat command list is still available. + program.addHelpText( + "after", + ` +Local dev: + init [name] Scaffold a new agent project + run | dev | start Boot the embedded Lobu stack + chat Send a prompt to an agent and stream the response + eval [name] Run agent evaluations + validate Validate lobu.toml + doctor Health checks (deps, DB, pgvector, ports, keys) + telemetry Show / toggle anonymous error reporting + +Cloud: + login | logout OAuth device-code login (or --token for CI) + whoami | status Show user / agent state + context Manage API contexts + org Manage active org slug + link | unlink Bind this directory to a (context, org) + apply | deploy Sync lobu.toml to cloud (idempotent) + agent CRUD agents via REST + token [create] Print or mint personal access tokens + +Memory: + memory run [tool] Invoke a memory MCP tool + memory exec