Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 20 additions & 27 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,37 @@
# @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

```bash
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/<name>/` (`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 <cmd> --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 <prompt>` — 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 <id>` — add a second/third agent to an existing project.
- `lobu eval new <name>` — scaffold a YAML eval into the current agent.
- `lobu telemetry {status,on,off}` — Sentry is off by default; toggle here.

## License

Expand Down
196 changes: 196 additions & 0 deletions packages/cli/src/__tests__/cli-ux.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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/<name>.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");
});
});
34 changes: 34 additions & 0 deletions packages/cli/src/commands/_lib/apply/apply-cmd.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -205,6 +209,36 @@ export async function applyCommand(opts: ApplyOptions = {}): Promise<void> {
});
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 <slug>` 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 });

Expand Down
Loading
Loading