From 768c639f5a26f2f4bb8b47d61f373cdb096f89a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 15:08:02 +0000 Subject: [PATCH 1/6] feat(cli): non-interactive init, REPL chat, project link, beefier doctor Closes the largest UX gaps in the lobu CLI relative to comparable tools (Wrangler, Convex, Vercel, Stripe CLI). Each change addresses a concrete foot-gun rather than adding new surface for its own sake. init - `--yes` plus per-prompt flags (`--port`, `--public-url`, `--network`, `--provider`, `--provider-key`, `--platform`, `--memory`, `--memory-url`, `--otel-endpoint`, `--sentry`/`--no-sentry`) so init can run unattended. - `lobu init .` / `--here` scaffolds into the current directory; bails if it would clobber an existing lobu.toml/agents/.env. - Sentry now defaults to off (was opt-in true). `lobu telemetry {status,on,off}` toggles SENTRY_DSN in .env after the fact. - Next-steps prints a Docker pgvector one-liner and a real DATABASE_URL template instead of a Mac-only brew hint. - Recovery messages standardized on `lobu ` (drops the `npx @lobu/cli@latest` mix). run (with `dev` / `start` aliases) - Pre-flights the gateway port and prints the platform-specific lsof/netstat hint when busy, instead of letting the bundle dump EADDRINUSE. - `--port`, `--quiet`, `--verbose`, `--log-level` forwarded to the bundle. chat - No prompt -> REPL bound to the agent session, with /exit, /help, /thread, /clear slash-commands and per-(context, agent) thread persistence in ~/.config/lobu/threads.json. - `--continue` resumes the last thread; `--auto-approve` skips tool prompts for trusted runs; `--json` emits raw SSE events for piping. doctor - Adds Postgres connectivity, pgvector extension presence, gateway port availability, provider API keys (cross-referenced against lobu.toml), and workspace dir checks. Driver pulled from the `postgres` package already on the dependency list. apply (alias: deploy) - `.lobu/project.json` binds a directory to a (context, org). `lobu link` / `lobu unlink` manage it, and `apply` refuses mismatched targets unless `--force` is set. Mirrors `vercel link` / `convex dev`. scaffolders - `lobu agent scaffold ` adds a second/third agent + lobu.toml block. - `lobu eval new ` writes a YAML eval template into the agent's evals/ directory. discovery - `lobu --help` now groups commands by Local dev / Cloud / Memory. - One npm-registry version check per 24h prints a stderr nudge when a newer @lobu/cli is published. `LOBU_DISABLE_UPDATE_CHECK=1` opts out. Tests cover init --yes, --here, --sentry, project-link round-trip + .gitignore de-dup, agent scaffold's TOML appending, eval new YAML output, and isPortFree's two states. CLI tests pass (95). Out of scope (each is its own concern): hot reload, tunnel integration, `lobu logs` for cloud agents. https://claude.ai/code/session_01KiUnJEGTUUrrywc5Nsftnd --- packages/cli/README.md | 92 ++- packages/cli/src/__tests__/cli-ux.test.ts | 193 ++++++ .../cli/src/commands/_lib/apply/apply-cmd.ts | 37 ++ packages/cli/src/commands/agent.ts | 108 +++- packages/cli/src/commands/chat.ts | 361 ++++++++--- packages/cli/src/commands/dev.ts | 100 ++- packages/cli/src/commands/doctor.ts | 178 ++++++ packages/cli/src/commands/eval.ts | 81 ++- packages/cli/src/commands/init.ts | 596 ++++++++++++------ packages/cli/src/commands/link.ts | 80 +++ packages/cli/src/commands/telemetry.ts | 80 +++ packages/cli/src/commands/whoami.ts | 4 +- packages/cli/src/index.ts | 267 +++++++- packages/cli/src/internal/context.ts | 4 +- packages/cli/src/internal/project-link.ts | 71 +++ packages/cli/src/internal/threads.ts | 57 ++ packages/cli/src/internal/version-check.ts | 94 +++ packages/cli/src/templates/README.md.tmpl | 4 +- 18 files changed, 2071 insertions(+), 336 deletions(-) create mode 100644 packages/cli/src/__tests__/cli-ux.test.ts create mode 100644 packages/cli/src/commands/link.ts create mode 100644 packages/cli/src/commands/telemetry.ts create mode 100644 packages/cli/src/internal/project-link.ts create mode 100644 packages/cli/src/internal/threads.ts create mode 100644 packages/cli/src/internal/version-check.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 9e79d336e..ef2fd569f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8,37 +8,95 @@ 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 — Docker, managed, or local. `lobu doctor` will tell you if anything is missing. + +```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 +``` ## Commands ### `lobu init [name]` -Scaffold a new Lobu project with interactive prompts: +Scaffold a new Lobu project. Interactive by default; pass `--yes` (and any of the per-prompt flags below) for non-interactive / CI scaffolding. + +```bash +# Fully interactive +lobu init my-bot + +# Non-interactive, all-defaults +lobu init my-bot --yes + +# Mixed: pick provider + platform up front, prompt for the rest +lobu init my-bot --provider anthropic --platform telegram + +# Scaffold into the current directory (or `lobu init .`) +lobu init --here --yes +``` + +Flags: + +- `-y, --yes` — skip prompts; use defaults / flag values +- `--here` — scaffold into the current directory (or pass `.` as the name) +- `--port ` — gateway port (default `8787`) +- `--public-url ` — public gateway URL (OAuth/webhooks) +- `--network ` — worker network policy +- `--provider ` — provider id from `config/providers.json` +- `--provider-key ` — provider API key (else read from env) +- `--platform ` +- `--memory ` +- `--memory-url ` — required with `--memory owletto-custom` +- `--otel-endpoint ` +- `--sentry` / `--no-sentry` — Sentry error reporting (off by default) + +**Generates:** `lobu.toml`, `.env`, `agents//` (`IDENTITY.md`, `SOUL.md`, `USER.md`, `skills/`, `evals/`), `skills/`, `AGENTS.md`, `TESTING.md`, `README.md`, `.gitignore`. + +### `lobu run` (aliases: `lobu dev`, `lobu start`) + +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`. Ctrl+C cleans up worker subprocesses. + +Flags: `--port `, `--quiet`, `--verbose`, `--log-level `. Pre-flights the port and prints a friendly message if it's already in use. + +### `lobu chat [prompt]` + +With a prompt: send one message, stream the response. With no prompt: open a REPL bound to the agent's session. Useful flags: + +- `-C, --continue` — resume the last thread for this (context, agent) +- `--auto-approve` — auto-approve every tool call (trusted environments only) +- `--json` — emit raw SSE events as JSON lines (good for piping into other tools) +- `-t, --thread ` — pin a specific thread +- `--new` — force a fresh session + +REPL slash-commands: `/exit`, `/help`, `/thread`, `/clear`. + +### `lobu doctor` + +Runs `node`, `git`, Postgres reachability, **pgvector** extension presence, port availability, provider API keys (read from `lobu.toml` + `.env`), and workspace dir checks. + +### `lobu link` / `lobu unlink` + +Bind the current directory to a (context, org). Stored at `.lobu/project.json` (auto-gitignored). Once linked, `lobu apply` refuses to push to a different cloud target unless you pass `--force`. Mirrors `vercel link` / `convex dev`. + +### `lobu apply` (alias: `lobu deploy`) -- **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) +Idempotent sync of `lobu.toml` + agent dirs to your Lobu Cloud org. `--dry-run`, `--yes`, `--only agents|memory`, `--force`. -**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`. +### `lobu telemetry [status|on|off]` -When Owletto-backed memory is enabled, `lobu init` also scaffolds the file-first memory layout: +Show or toggle anonymous error reporting. Defaults to **off**. -- `[memory.owletto]` in `lobu.toml` (org, name, description, models, data) -- `models/` -- `data/` +### `lobu agent scaffold ` -For a custom Owletto deployment, `.env` keeps `MEMORY_URL` as the optional base MCP URL override. +Add a second (or third…) agent to an existing project — generates `agents//{IDENTITY,SOUL,USER}.md` + `skills/` + `evals/` and appends `[agents.]` to `lobu.toml`. -### `lobu run` +### `lobu eval new ` -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. +Scaffold a YAML eval into the current agent's `evals/` directory. ## 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..a6f6b49a0 --- /dev/null +++ b/packages/cli/src/__tests__/cli-ux.test.ts @@ -0,0 +1,193 @@ +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"); + // Sentry now defaults to off in --yes mode. + 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); + }); +}); + +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..68d4049f7 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,39 @@ export async function applyCommand(opts: ApplyOptions = {}): Promise { }); printText(chalk.dim(`Org: ${orgSlug}`)); + // Project-link guard: refuse to apply against an org/context that + // doesn't match the link file unless --force is set. Prevents the + // `cd ~/projects/customer-A && lobu apply` foot-gun where the global + // context still points at customer-B. + 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..4051d0a72 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -1,4 +1,5 @@ -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 +182,108 @@ 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])?$/; + +/** + * `lobu agent scaffold ` — create agents//{IDENTITY,SOUL,USER}.md + * + skills/ + evals/ in the local project, and append a `[agents.]` block + * to lobu.toml. Used to add a second (or third…) agent to an existing project + * without hand-copying the init template. + */ +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 { + // expected + } + + 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 = "${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..a7937bb82 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -2,34 +2,39 @@ import { createInterface } from "node:readline"; import chalk from "chalk"; import { apiBaseFromContextUrl, + getCurrentContextName, getToken, resolveContext, resolveGatewayUrl, } from "../internal/index.js"; +import { getLastThread, setLastThread } from "../internal/threads.js"; import { isLoadError, loadConfig } from "../config/loader.js"; import { renderMarkdown } from "../utils/markdown.js"; +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 [prompt]` — 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. + * No prompt → REPL. 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; - } + prompt: string | undefined, + options: ChatOptions ): Promise { - // Resolve gateway URL: explicit flag > named context > .env fallback let gatewayUrl: string; if (options.gateway) { gatewayUrl = options.gateway; @@ -45,34 +50,69 @@ export async function chatCommand( if (!authToken) { console.error( chalk.red( - "\n Session expired or not logged in. Run `npx @lobu/cli@latest login`.\n" + "\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 (prompt === undefined) { + if (platformUser) { + console.error( + chalk.red( + "\n REPL mode is not supported with --user. Pass a prompt or drop --user.\n" + ) + ); + process.exit(1); + } + await runRepl(gatewayUrl, authToken, { + agentId, + thread: threadId, + dryRun: options.dryRun, + forceNew: options.new && !threadId, + autoApprove: options.autoApprove, + json: options.json, + contextName, + }); + return; + } 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 +121,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 +137,7 @@ async function sendViaPlatform( userId: string; message: string; thread?: string; + json?: boolean; } ): Promise { const agentId = opts.agentId || `test-${opts.platform}`; @@ -112,14 +146,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 +183,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 +201,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 +247,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 +260,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 +311,118 @@ async function sendViaApi( } await streaming; + + if (opts.contextName && session.threadId) { + await setLastThread(opts.contextName, session.agentId, session.threadId); + } +} + +interface ReplOptions { + agentId?: string; + thread?: string; + dryRun?: boolean; + forceNew?: boolean; + autoApprove?: boolean; + json?: boolean; + contextName?: string; +} + +async function runRepl( + gatewayUrl: string, + authToken: string, + opts: ReplOptions +): 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`; + + if (!opts.json) { + console.log( + chalk.dim( + `\n ${chalk.bold(session.agentId)} ${session.threadId ? `(thread: ${session.threadId.slice(0, 8)}…)` : ""}\n Type your message. Ctrl+D or /exit to quit.\n` + ) + ); + } + + if (opts.contextName && session.threadId) { + await setLastThread(opts.contextName, session.agentId, session.threadId); + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + const ask = (prompt: string): Promise => + new Promise((resolve) => { + rl.question(prompt, (answer) => resolve(answer)); + rl.once("close", () => resolve(null)); + }); + + while (true) { + const line = await ask(chalk.cyan("\n> ")); + if (line === null) break; + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed === "/exit" || trimmed === "/quit") break; + if (trimmed === "/help") { + console.log( + chalk.dim( + " /exit Leave the REPL\n /thread Show current thread id\n /clear Start a new thread\n" + ) + ); + continue; + } + if (trimmed === "/thread") { + console.log(chalk.dim(` thread: ${session.threadId ?? "(none)"}\n`)); + continue; + } + if (trimmed === "/clear") { + const fresh = await createSession(gatewayUrl, authToken, { + agentId: session.agentId, + forceNew: true, + }); + session.token = fresh.token; + session.threadId = fresh.threadId; + if (opts.contextName && fresh.threadId) { + await setLastThread(opts.contextName, fresh.agentId, fresh.threadId); + } + console.log(chalk.dim(" new thread started.\n")); + continue; + } + + const sseController = new AbortController(); + const streaming = streamResponse(sseUrl, session.token, sseController, { + autoApprove: opts.autoApprove, + json: opts.json, + }); + + const msgRes = await fetch(messagesUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.token}`, + }, + body: JSON.stringify({ content: trimmed }), + }); + + if (!msgRes.ok) { + sseController.abort(); + const body = await msgRes.text().catch(() => ""); + console.error( + chalk.red(`\n Failed to send (${msgRes.status}): ${body}\n`) + ); + continue; + } + + await streaming; + } + + rl.close(); } async function resolveAgentId(cwd: string): Promise { @@ -278,11 +456,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 +513,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 +565,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..190e7a121 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,8 @@ import { parseEnvContent } from "../internal/index.js"; */ export async function devCommand( cwd: string, - passthroughArgs: string[] + passthroughArgs: string[], + options: DevOptions = {} ): Promise { const spinner = ora("Validating environment...").start(); @@ -40,14 +49,22 @@ 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 +87,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"; + // CLI flag wins, then env, then default + const port = + options.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(mergedEnv.DATABASE_URL!)}`) - ); - console.log(chalk.dim(` api docs: ${gatewayUrl}/api/docs`)); - console.log(); + // Pre-flight: is the port already in use? Catches the common + // "EADDRINUSE" foot-gun before the bundle boots and dumps a stack. + const portFree = await isPortFree(Number(port)); + 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,6 +142,8 @@ 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, LOBU_LOG_LEVEL: logLevel } : {}), }; const child = spawn("node", [bundlePath, ...passthroughArgs], { @@ -140,10 +187,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 +194,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 +212,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..b680ba854 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,157 @@ 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[] = []; + let postgres: any; + try { + postgres = (await import("postgres")).default; + } catch (err) { + results.push({ + name: "postgres-driver", + status: "fail", + detail: `postgres package missing: ${(err as Error).message}`, + }); + return results; + } + + let sql: any; + try { + sql = postgres(databaseUrl, { + connect_timeout: 5, + max: 1, + idle_timeout: 1, + onnotice: () => undefined, + }); + } catch (err) { + results.push({ + name: "database", + status: "fail", + detail: `cannot init driver: ${(err as Error).message}`, + }); + return results; + } + + try { + const rows = await sql`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}`, + }); + try { + await sql.end({ timeout: 1 }); + } catch { + // ignore + } + return results; + } + + try { + const rows = + await sql`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}`, + }); + } + + try { + await sql.end({ timeout: 5 }); + } catch { + // ignore + } + 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 +218,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..080047065 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -1,4 +1,4 @@ -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 +226,85 @@ 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; +} + +/** + * `lobu eval new ` — scaffold a YAML eval into agents//evals/. + * Picks the first agent from lobu.toml unless --agent is given. + */ +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..a595e2b70 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,188 @@ 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" - ) - ); - 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 useDefaults = options.yes === true; + + // ── Project name + target dir ──────────────────────────────────────── + 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", + // ── Gateway port + public URL ──────────────────────────────────────── + 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: "", - }); - - // 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 publicGatewayUrl = await promptOrDefault({ + flag: options.publicUrl, + useDefaults, + defaultValue: "", + prompt: () => + input({ + message: + "Public gateway URL? (leave empty for local dev, set for OAuth/webhooks)", + default: "", + }), }); - // Provider selection (from the bundled providers registry) + // ── Network policy ─────────────────────────────────────────────────── + 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 ───────────────────────────────────────────────────────── const providerSkills = loadProviderRegistry(); const providerChoices = [ { name: "Skip — I'll add a provider later", value: "" }, @@ -115,10 +207,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 +232,21 @@ 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 + // ── Platform ───────────────────────────────────────────────────────── const platformChoices = [ { name: "Skip — I'll connect a platform later", value: "" }, { name: "Telegram", value: "telegram" }, @@ -148,28 +257,51 @@ 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", - }); + // Platform secrets only flow through interactive prompts. With --yes, + // the user wires them up by editing .env after init. + const { platformConfig, platformSecrets } = + platformType && !useDefaults + ? await promptPlatformConfig(platformType) + : platformType + ? scaffoldPlatformConfigPlaceholders(platformType as PlatformChoice) + : { platformConfig: {}, platformSecrets: [] }; + + // ── Memory ─────────────────────────────────────────────────────────── + 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 +310,36 @@ 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 ?? + (useDefaults + ? "" + : await input({ + message: "Lobu memory MCP URL:", + validate: (v: string) => (v ? true : "URL is required"), + })); + if (!owlettoUrl) { + console.log( + chalk.red( + "\n✗ --memory owletto-custom requires --memory-url .\n" + ) + ); + process.exit(1); + } 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: "", + // ── Observability ──────────────────────────────────────────────────── + 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 +349,20 @@ 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, - }); + // Sentry now defaults to OFF — opt-in only. Use --sentry to enable + // non-interactively, --no-sentry to suppress the prompt. + 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,7 @@ export async function initCommand( }); } - // Compute network domains from selected policy + // ── Compute network domains ────────────────────────────────────────── let allowedDomains: string; let disallowedDomains: string; if (networkPolicy === "open") { @@ -225,7 +382,6 @@ export async function initCommand( allowedDomains = ""; disallowedDomains = ""; } else { - // restricted (default) allowedDomains = [ "registry.npmjs.org", ".npmjs.org", @@ -250,7 +406,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 +422,6 @@ export async function initCommand( ); } - // Generate lobu.toml await generateLobuToml(projectDir, { agentName: projectName, allowedDomains: answers.allowedDomains, @@ -291,10 +445,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 +455,6 @@ export async function initCommand( ); } - // Save provider API key to .env if (providerApiKey && selectedProvider?.providers?.[0]?.envVarName) { await setLocalEnvValue( projectDir, @@ -312,27 +463,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 +493,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,12 +515,20 @@ turns: spinner.succeed("Project created successfully!"); - // Print next steps + // ── Next steps ─────────────────────────────────────────────────── + const cdHint = here ? "" : `cd ${projectName}\n `; + 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:")); + + if (!here) { + console.log(chalk.cyan(" 1. Navigate to your project:")); + console.log(chalk.dim(` cd ${projectName}\n`)); + } + + const stepNum = (n: number) => (here ? n - 1 : n); + console.log(chalk.cyan(` ${stepNum(2)}. Review your configuration:`)); console.log( chalk.dim( " - lobu.toml (agents, providers, skills, network)" @@ -425,49 +552,151 @@ turns: chalk.dim(" - data/ (memory seed data)") ); } - console.log(chalk.dim(" - .env (secrets)")); - console.log(); + console.log(chalk.dim(" - .env (secrets)\n")); - const gatewayUrl = `http://localhost:${gatewayPort}`; - console.log(chalk.cyan(" 3. Set DATABASE_URL in .env:")); + console.log(chalk.cyan(` ${stepNum(3)}. Set DATABASE_URL in .env:`)); console.log( chalk.dim( - " Lobu connects to a user-provided Postgres. Run one yourself" + " Lobu connects to a user-provided Postgres with pgvector. Pick one:" ) ); console.log( chalk.dim( - " (managed instance or local: e.g. `brew services start postgresql`)\n" + " Docker: docker run -d --name lobu-pg -p 5432:5432 \\" ) ); + console.log( + chalk.dim( + " -e POSTGRES_PASSWORD=lobu pgvector/pgvector:pg16" + ) + ); + console.log( + chalk.dim( + ' DATABASE_URL=postgresql://postgres:lobu@localhost:5432/postgres' + ) + ); + console.log( + chalk.dim(" macOS: brew install postgresql && brew services start postgresql") + ); + console.log( + chalk.dim(" Cloud: any managed Postgres with the pgvector extension\n") + ); + 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.dim(" Run `lobu memory init` to configure local MCP clients.\n") ); } - 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.cyan(` ${stepNum(4)}. Start the services:`)); + console.log(chalk.dim(` ${cdHint}lobu run\n`)); + console.log(chalk.cyan(` ${stepNum(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(` ${stepNum(6)}. Build with a coding agent:`)); console.log( chalk.dim( " Ask Codex or Claude Code to read AGENTS.md, lobu.toml, and agents/*/{IDENTITY,SOUL,USER}.md" ) ); 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(); +} + +function scaffoldPlatformConfigPlaceholders(platform: PlatformChoice): { + platformConfig: Record; + platformSecrets: Array<{ envVar: string; value: string }>; +} { + // Non-interactive scaffolding — write placeholder env-var refs into + // lobu.toml, leave .env values empty for the user to fill in. + const config: Record = {}; + switch (platform) { + case "telegram": + config.botToken = "$TELEGRAM_BOT_TOKEN"; + break; + case "slack": + config.botToken = "$SLACK_BOT_TOKEN"; + config.signingSecret = "$SLACK_SIGNING_SECRET"; + break; + case "discord": + config.botToken = "$DISCORD_BOT_TOKEN"; + break; + case "whatsapp": + config.accessToken = "$WHATSAPP_ACCESS_TOKEN"; + config.phoneNumberId = "$WHATSAPP_PHONE_NUMBER_ID"; + break; + case "teams": + config.appId = "$TEAMS_APP_ID"; + config.appPassword = "$TEAMS_APP_PASSWORD"; + break; + case "gchat": + config.credentials = "$GOOGLE_CHAT_CREDENTIALS"; + break; + } + return { platformConfig: config, platformSecrets: [] }; +} + function humanizeSlug(slug: string): string { return slug .split("-") @@ -560,7 +789,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 +818,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..e996f0d98 --- /dev/null +++ b/packages/cli/src/commands/link.ts @@ -0,0 +1,80 @@ +import chalk from "chalk"; +import { + getActiveOrg, + getCurrentContextName, + resolveContext, +} from "../internal/index.js"; +import { + loadProjectLink, + saveProjectLink, +} from "../internal/project-link.js"; + +interface LinkOptions { + context?: string; + org?: string; + cwd?: string; +} + +/** + * `lobu link` — bind the current project directory to a (context, org) + * pair so that `lobu apply` and `lobu chat` refuse to run against the + * wrong cloud target. Mirrors `vercel link` / `convex dev`'s `.convex/`. + */ +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< + void +> { + const cwd = options.cwd ?? process.cwd(); + const existing = await loadProjectLink(cwd); + if (!existing) { + console.log(chalk.dim("\n No project link found.\n")); + return; + } + const { rm } = await import("node:fs/promises"); + const { join } = await import("node:path"); + await rm(join(cwd, ".lobu", "project.json"), { force: true }); + console.log(chalk.green("\n Project unlinked.\n")); +} + +export async function linkStatusCommand( + options: { cwd?: string } = {} +): Promise { + const cwd = options.cwd ?? process.cwd(); + const link = await loadProjectLink(cwd); + if (!link) { + const ctx = await getCurrentContextName(); + console.log(chalk.dim("\n Project not linked.")); + console.log( + chalk.dim( + ` Run \`lobu link\` to bind this directory to context "${ctx}".\n` + ) + ); + return; + } + console.log(chalk.bold("\n Lobu project link")); + console.log(chalk.dim(` Context: ${link.context}`)); + console.log(chalk.dim(` Org: ${link.org}`)); + console.log(chalk.dim(` Linked: ${link.linkedAt}\n`)); +} diff --git a/packages/cli/src/commands/telemetry.ts b/packages/cli/src/commands/telemetry.ts new file mode 100644 index 000000000..a5352217a --- /dev/null +++ b/packages/cli/src/commands/telemetry.ts @@ -0,0 +1,80 @@ +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); + if (url.password) url.password = "***"; + if (url.username) { + url.username = `${url.username.slice(0, 4)}…`; + } + 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..397e17a63 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import chalk from "chalk"; import { Command } from "commander"; import { GATEWAY_DEFAULT_URL } from "./internal/index.js"; +import { maybePrintUpdateNotice } from "./internal/version-check.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -39,26 +40,123 @@ 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 [prompt] Talk to an agent (REPL when no prompt) + 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