diff --git a/packages/cli/src/commands/_lib/apply/__tests__/__snapshots__/diff.test.ts.snap b/packages/cli/src/commands/_lib/apply/__tests__/__snapshots__/diff.test.ts.snap new file mode 100644 index 000000000..12859a28f --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/__tests__/__snapshots__/diff.test.ts.snap @@ -0,0 +1,111 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`apply diff — agents create from empty remote 1`] = ` +" +Plan: + + agents: + + agent triage + + settingss: + + settings triage + +Summary: 2 create, 0 update, 0 noop, 0 drift" +`; + +exports[`apply diff — agents noop when remote matches desired 1`] = ` +" +Plan: + + agents: + = agent triage + + settingss: + = settings triage + +Summary: 0 create, 0 update, 2 noop, 0 drift" +`; + +exports[`apply diff — agents update when name differs 1`] = ` +" +Plan: + + agents: + ~ agent triage (name) + + settingss: + = settings triage + +Summary: 0 create, 1 update, 1 noop, 0 drift" +`; + +exports[`apply diff — agents drift when remote has agent not in desired 1`] = ` +" +Plan: + + agents: + ? agent stale (drift — ignored in v1, not deleted) + +Summary: 0 create, 0 update, 0 noop, 1 drift" +`; + +exports[`apply diff — settings update on networkConfig change 1`] = ` +" +Plan: + + agents: + = agent triage + + settingss: + ~ settings triage (networkConfig) + +Summary: 0 create, 1 update, 1 noop, 0 drift" +`; + +exports[`apply diff — connections create on empty remote 1`] = ` +" +Plan: + + agents: + + agent triage + + settingss: + + settings triage + + connections: + + connection triage/triage-telegram + +Summary: 3 create, 0 update, 0 noop, 0 drift" +`; + +exports[`apply diff — connections update with willRestart when config changes 1`] = ` +" +Plan: + + agents: + = agent triage + + settingss: + = settings triage + + connections: + ~ connection triage/triage-telegram (config) + ⚠ will restart connection — in-flight messages may drop + +Summary: 0 create, 1 update, 2 noop, 0 drift" +`; + +exports[`apply diff — memory schema creates entity + relationship types 1`] = ` +" +Plan: + + entity-types: + + entity-type company + + relationship-types: + + relationship-type works_at + +Summary: 2 create, 0 update, 0 noop, 0 drift" +`; + +exports[`renderSummary renders zero-row plan 1`] = `"Summary: 0 create, 0 update, 0 noop, 0 drift"`; diff --git a/packages/cli/src/commands/_lib/apply/__tests__/desired-state.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/desired-state.test.ts new file mode 100644 index 000000000..58ad8d3c7 --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/__tests__/desired-state.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildStableConnectionId, loadDesiredState } from "../desired-state.js"; + +describe("buildStableConnectionId — keep in sync with file-loader.ts:56", () => { + test("two parts when no name", () => { + expect(buildStableConnectionId("triage", "telegram")).toBe( + "triage-telegram" + ); + }); + test("three parts when name provided", () => { + expect(buildStableConnectionId("triage", "slack", "ops")).toBe( + "triage-slack-ops" + ); + }); + test("slugifies non-alphanumeric chars in agent + type + name", () => { + expect(buildStableConnectionId("Tri Age", "Slack/Ops", "Bot 1")).toBe( + "tri-age-slack-ops-bot-1" + ); + }); +}); + +describe("loadDesiredState", () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } + }); + + function mkProject(toml: string): string { + const dir = mkdtempSync(join(tmpdir(), "lobu-apply-")); + tempDirs.push(dir); + writeFileSync(join(dir, "lobu.toml"), toml); + return dir; + } + + test("collects $VAR references from connections + providers", async () => { + const dir = mkProject( + `[agents.triage] +name = "Triage" +description = "" +dir = "./agents/triage" + +[[agents.triage.providers]] +id = "anthropic" +key = "$ANTHROPIC_API_KEY" + +[[agents.triage.connections]] +type = "telegram" +[agents.triage.connections.config] +botToken = "$TELEGRAM_BOT_TOKEN" +` + ); + // Provide an empty agent dir so markdown read returns nothing. + const { state } = await loadDesiredState({ + cwd: dir, + env: { + ANTHROPIC_API_KEY: "sk-anth-fake", + TELEGRAM_BOT_TOKEN: "tg-fake-token", + }, + }); + expect(state.requiredSecrets).toEqual([ + "ANTHROPIC_API_KEY", + "TELEGRAM_BOT_TOKEN", + ]); + expect(state.agents).toHaveLength(1); + expect(state.agents[0]!.metadata.agentId).toBe("triage"); + expect(state.agents[0]!.connections).toHaveLength(1); + expect(state.agents[0]!.connections[0]!.stableId).toBe("triage-telegram"); + expect(state.agents[0]!.connections[0]!.config.botToken).toBe( + "tg-fake-token" + ); + }); + + test("throws when a connection $VAR ref is unset in the apply env", async () => { + const dir = mkProject( + `[agents.triage] +name = "Triage" +dir = "./agents/triage" + +[[agents.triage.connections]] +type = "telegram" +[agents.triage.connections.config] +botToken = "$TELEGRAM_BOT_TOKEN" +` + ); + await expect(loadDesiredState({ cwd: dir, env: {} })).rejects.toThrow( + /\$TELEGRAM_BOT_TOKEN/ + ); + }); + + test("rejects duplicate (type, name) connection pairs", async () => { + const dir = mkProject( + `[agents.triage] +name = "Triage" +dir = "./agents/triage" + +[[agents.triage.connections]] +type = "slack" +[agents.triage.connections.config] +botToken = "x" + +[[agents.triage.connections]] +type = "slack" +[agents.triage.connections.config] +botToken = "y" +` + ); + await expect(loadDesiredState({ cwd: dir })).rejects.toThrow( + /multiple "slack" connections/ + ); + }); + + test("rejects watcher blocks (v1 doesn't sync watchers)", async () => { + const dir = mkProject( + `[agents.triage] +name = "Triage" +dir = "./agents/triage" + +[[agents.triage.watchers]] +slug = "stale" +` + ); + await expect(loadDesiredState({ cwd: dir })).rejects.toThrow(/watchers/); + }); +}); diff --git a/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts new file mode 100644 index 000000000..90ec869dc --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test } from "bun:test"; +import chalk from "chalk"; +import type { AgentSettings } from "@lobu/core"; +import { computeDiff, type RemoteSnapshot } from "../diff.js"; +import type { DesiredAgent, DesiredState } from "../desired-state.js"; +import { renderPlan, renderSummary } from "../render.js"; + +// Force chalk to render plain text in snapshots regardless of TTY detection. +// `chalk.level = 0` strips colors so snapshot diffs aren't TTY-dependent. +chalk.level = 0; + +function buildDesiredAgent( + agentId: string, + overrides: Partial = {} +): DesiredAgent { + return { + metadata: { agentId, name: agentId, description: undefined }, + settings: {}, + connections: [], + ...overrides, + }; +} + +function buildState(agents: DesiredAgent[]): DesiredState { + return { + agents, + memorySchema: { entityTypes: [], relationshipTypes: [] }, + requiredSecrets: [], + }; +} + +function emptyRemote(): RemoteSnapshot { + return { + agents: [], + agentSettings: new Map(), + connectionsByAgent: new Map(), + entityTypes: [], + relationshipTypes: [], + }; +} + +describe("apply diff — agents", () => { + test("create from empty remote", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { + agentId: "triage", + name: "Triage", + description: "Triage bot", + }, + }), + ]); + const plan = computeDiff(desired, emptyRemote()); + + expect(plan.counts).toEqual({ create: 2, update: 0, noop: 0, drift: 0 }); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("noop when remote matches desired", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([["triage", null]]), + connectionsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.noop).toBeGreaterThan(0); + expect(plan.counts.create).toBe(0); + expect(plan.counts.update).toBe(0); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("update when name differs", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Renamed" }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Original" }], + agentSettings: new Map([["triage", null]]), + connectionsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.update).toBeGreaterThan(0); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("drift when remote has agent not in desired", () => { + const desired = buildState([]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "stale", name: "Stale Agent" }], + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.drift).toBe(1); + expect(renderPlan(plan)).toMatchSnapshot(); + }); +}); + +describe("apply diff — settings", () => { + test("update on networkConfig change", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + settings: { + networkConfig: { allowedDomains: ["github.com"] }, + }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([ + [ + "triage", + { + networkConfig: { allowedDomains: ["pypi.org"] }, + updatedAt: 0, + }, + ], + ]), + connectionsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + const settingsRow = plan.rows.find((r) => r.kind === "settings"); + expect(settingsRow?.verb).toBe("update"); + if (settingsRow?.kind === "settings") { + expect(settingsRow.changedFields).toContain("networkConfig"); + } + expect(renderPlan(plan)).toMatchSnapshot(); + }); +}); + +describe("apply diff — connections", () => { + test("create on empty remote", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + connections: [ + { + stableId: "triage-telegram", + type: "telegram", + config: { botToken: "abc" }, + }, + ], + }), + ]); + const plan = computeDiff(desired, emptyRemote()); + const connRow = plan.rows.find((r) => r.kind === "connection"); + expect(connRow?.verb).toBe("create"); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("update with willRestart when config changes", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + connections: [ + { + stableId: "triage-telegram", + type: "telegram", + config: { botToken: "new" }, + }, + ], + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([["triage", null]]), + connectionsByAgent: new Map([ + [ + "triage", + [ + { + id: "triage-telegram", + platform: "telegram", + config: { botToken: "old" }, + }, + ], + ], + ]), + }; + const plan = computeDiff(desired, remote); + const connRow = plan.rows.find((r) => r.kind === "connection"); + expect(connRow?.verb).toBe("update"); + if (connRow?.kind === "connection") { + expect(connRow.willRestart).toBe(true); + } + expect(renderPlan(plan)).toMatchSnapshot(); + }); +}); + +describe("apply diff — memory schema", () => { + test("creates entity + relationship types", () => { + const desired: DesiredState = { + agents: [], + memorySchema: { + entityTypes: [{ slug: "company", name: "Company", required: ["name"] }], + relationshipTypes: [ + { + slug: "works_at", + name: "Works At", + rules: [{ source: "person", target: "company" }], + }, + ], + }, + requiredSecrets: [], + }; + const plan = computeDiff(desired, emptyRemote()); + expect(plan.counts.create).toBe(2); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("noop when remote matches", () => { + const desired: DesiredState = { + agents: [], + memorySchema: { + entityTypes: [{ slug: "company", name: "Company" }], + relationshipTypes: [], + }, + requiredSecrets: [], + }; + const remote: RemoteSnapshot = { + ...emptyRemote(), + entityTypes: [{ slug: "company", name: "Company" }], + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.noop).toBe(1); + expect(plan.counts.update).toBe(0); + }); +}); + +describe("renderSummary", () => { + test("renders zero-row plan", () => { + const desired = buildState([]); + const plan = computeDiff(desired, emptyRemote()); + expect(renderSummary(plan)).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/commands/_lib/apply/apply-cmd.ts b/packages/cli/src/commands/_lib/apply/apply-cmd.ts new file mode 100644 index 000000000..5213b99d7 --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/apply-cmd.ts @@ -0,0 +1,248 @@ +import chalk from "chalk"; +import { ApiError, ValidationError } from "../../memory/_lib/errors.js"; +import { printError, printText } from "../../memory/_lib/output.js"; +import { + type ApplyClient, + type RemoteAgent, + type RemoteConnection, + resolveApplyClient, +} from "./client.js"; +import { + computeDiff, + type DiffPlan, + type DiffRow, + type RemoteSnapshot, +} from "./diff.js"; +import { type DesiredState, loadDesiredState } from "./desired-state.js"; +import { confirmPlan } from "./prompt.js"; +import { renderMissingSecrets, renderPlan, renderProgress } from "./render.js"; + +export interface ApplyOptions { + cwd?: string; + dryRun?: boolean; + yes?: boolean; + only?: "agents" | "memory"; + org?: string; + url?: string; + storePath?: string; + /** Test seam — inject a stubbed fetch. */ + fetchImpl?: typeof fetch; +} + +// ── Required-secrets check ───────────────────────────────────────────────── + +/** + * v1 secret check: every `$VAR` referenced in lobu.toml must be present in + * the apply runner's environment. The file-loader already substitutes envs + * in-place during gateway boot, so this is the same set of names operators + * must satisfy at runtime — surfacing it pre-mutation gives the operator + * a cleaner failure than a silent empty-string config push. + * + * Plan §7 reserves cloud-side secret-list cross-checks for v3. + */ +function checkRequiredSecrets(state: DesiredState): { missing: string[] } { + const missing = state.requiredSecrets.filter( + (name) => process.env[name] === undefined || process.env[name] === "" + ); + return { missing }; +} + +// ── Snapshot ─────────────────────────────────────────────────────────────── + +async function fetchRemoteSnapshot( + client: ApplyClient, + state: DesiredState, + only?: "agents" | "memory" +): Promise { + const agents: RemoteAgent[] = + only === "memory" ? [] : await client.listAgents(); + const agentSettings = new Map< + string, + Awaited> + >(); + const connectionsByAgent = new Map(); + + if (only !== "memory") { + const desiredAgentIds = state.agents.map((a) => a.metadata.agentId); + const remoteAgentIds = new Set(agents.map((a) => a.agentId)); + // Only GET settings for agents that exist; new agents have no remote + // settings to compare against. + const targetAgentIds = desiredAgentIds.filter((id) => + remoteAgentIds.has(id) + ); + for (const agentId of targetAgentIds) { + agentSettings.set(agentId, await client.getAgentSettings(agentId)); + connectionsByAgent.set(agentId, await client.listConnections(agentId)); + } + } + + const entityTypes = only === "agents" ? [] : await client.listEntityTypes(); + const relationshipTypes = + only === "agents" ? [] : await client.listRelationshipTypes(); + + return { + agents, + agentSettings, + connectionsByAgent, + entityTypes, + relationshipTypes, + }; +} + +// ── Apply executor ───────────────────────────────────────────────────────── + +interface ApplyContext { + client: ApplyClient; + state: DesiredState; + plan: DiffPlan; +} + +/** + * Execute the plan in dependency order. Plan §footgun-7: agents → settings → + * connections → entity types → relationship types. No retry loop, no + * topological sort. First failure prints partial progress and re-throws. + */ +async function executePlan(ctx: ApplyContext): Promise { + const rowsByKind = (kind: DiffRow["kind"]) => + ctx.plan.rows.filter( + (row) => row.kind === kind && row.verb !== "noop" && row.verb !== "drift" + ); + + // 1) Agents + for (const row of rowsByKind("agent")) { + if (row.kind !== "agent") continue; + if (!row.desired) continue; + const desired = ctx.state.agents.find((a) => a.metadata.agentId === row.id); + if (!desired) continue; + await ctx.client.upsertAgent(desired.metadata); + printText(renderProgress(row.verb, "agent", row.id)); + } + + // 2) Settings + for (const row of rowsByKind("settings")) { + if (row.kind !== "settings") continue; + const desired = ctx.state.agents.find((a) => a.metadata.agentId === row.id); + if (!desired) continue; + await ctx.client.patchAgentSettings(row.id, desired.settings); + printText( + renderProgress( + row.verb, + "settings", + row.id, + row.changedFields ? `(${row.changedFields.join(", ")})` : undefined + ) + ); + } + + // 3) Connections + for (const row of rowsByKind("connection")) { + if (row.kind !== "connection") continue; + const desired = row.desired; + if (!desired) continue; + const result = await ctx.client.upsertConnection( + row.agentId, + desired.stableId, + { + platform: desired.type, + ...(desired.name ? { name: desired.name } : {}), + config: desired.config, + } + ); + const detail = result.willRestart + ? "(restarted)" + : result.noop + ? "(noop on server)" + : undefined; + printText( + renderProgress(row.verb, "connection", `${row.agentId}/${row.id}`, detail) + ); + } + + // 4) Entity types + for (const row of rowsByKind("entity-type")) { + if (row.kind !== "entity-type") continue; + if (!row.desired) continue; + await ctx.client.upsertEntityType(row.desired); + printText(renderProgress(row.verb, "entity-type", row.id)); + } + + // 5) Relationship types + for (const row of rowsByKind("relationship-type")) { + if (row.kind !== "relationship-type") continue; + if (!row.desired) continue; + await ctx.client.upsertRelationshipType(row.desired); + printText(renderProgress(row.verb, "relationship-type", row.id)); + } +} + +// ── Top-level command ────────────────────────────────────────────────────── + +export async function applyCommand(opts: ApplyOptions = {}): Promise { + const cwd = opts.cwd ?? process.cwd(); + const { state, configPath } = await loadDesiredState({ cwd }); + + printText(chalk.dim(`Config: ${configPath}`)); + + // Required secrets gate: fail before any network mutation. + const { missing } = checkRequiredSecrets(state); + if (missing.length > 0) { + printError(renderMissingSecrets(missing)); + throw new ValidationError( + `${missing.length} required secret${missing.length === 1 ? "" : "s"} missing — see above.` + ); + } + + const { client, orgSlug } = await resolveApplyClient({ + url: opts.url, + org: opts.org, + storePath: opts.storePath, + fetchImpl: opts.fetchImpl, + }); + printText(chalk.dim(`Org: ${orgSlug}`)); + + const remote = await fetchRemoteSnapshot(client, state, opts.only); + const plan = computeDiff(state, remote, { only: opts.only }); + + printText(renderPlan(plan)); + + if (opts.dryRun) { + printText(chalk.dim("\nDry run — no changes applied.")); + return; + } + + if (plan.counts.create === 0 && plan.counts.update === 0) { + printText(chalk.green("\nNothing to apply.")); + return; + } + + // Build a plain-text summary for the inquirer prompt — chalk-decorated + // text confuses some terminals when re-printed by the prompt library. + const { create, update, noop, drift } = plan.counts; + const summaryLine = `${create} create, ${update} update, ${noop} noop, ${drift} drift`; + const approved = await confirmPlan({ + yes: opts.yes ?? false, + summaryLine, + }); + if (!approved) { + printText(chalk.dim("\nCancelled.")); + return; + } + + printText(chalk.bold("\nApplying:")); + try { + await executePlan({ client, state, plan }); + printText(chalk.green("\nApply complete.")); + } catch (err) { + if (err instanceof ApiError) { + printError(`\n${err.message}`); + } else if (err instanceof Error) { + printError(`\n${err.message}`); + } else { + printError(`\n${String(err)}`); + } + printError( + "Apply halted on first failure. Re-run `lobu apply` once the underlying issue is resolved — every endpoint is idempotent." + ); + throw err; + } +} diff --git a/packages/cli/src/commands/_lib/apply/client.ts b/packages/cli/src/commands/_lib/apply/client.ts new file mode 100644 index 000000000..3cf80256d --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/client.ts @@ -0,0 +1,481 @@ +import type { AgentSettings } from "@lobu/core"; +import { ApiError, ValidationError } from "../../memory/_lib/errors.js"; +import { + getSessionForOrg, + getUsableToken, + mcpUrlForOrg, + orgFromMcpUrl, + resolveOrg, + resolveServerUrl, +} from "../../memory/_lib/openclaw-auth.js"; + +// ── Wire types ───────────────────────────────────────────────────────────── + +export interface RemoteAgent { + agentId: string; + name: string; + description?: string; +} + +export interface RemoteAgentDetail extends RemoteAgent { + settings?: AgentSettings | null; +} + +export interface RemoteConnection { + id: string; + platform: string; + templateAgentId?: string; + config?: Record; + status?: string; +} + +export interface RemoteEntityType { + slug: string; + name?: string; + description?: string; + required?: string[]; + properties?: Record; +} + +export interface RemoteRelationshipType { + slug: string; + name?: string; + description?: string; + rules?: Array<{ source: string; target: string }>; +} + +export interface UpsertConnectionResult { + /** Server reports `noop: true` when the desired config matches what's stored. */ + noop?: boolean; + /** When the config materially changed, the live worker is restarted. */ + willRestart?: boolean; + updated?: boolean; + created?: boolean; + connection?: RemoteConnection; +} + +export interface UpsertEntityTypeResult { + created?: boolean; + updated?: boolean; + noop?: boolean; +} + +// ── Shape predicates ─────────────────────────────────────────────────────── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function extractApiError( + parsed: Record, + status: number, + statusText: string +): { message: string; code?: string } { + if (typeof parsed.error === "string") { + return { message: parsed.error }; + } + if (isRecord(parsed.error)) { + const message = + typeof parsed.error.message === "string" + ? parsed.error.message + : `HTTP ${status} ${statusText}`; + const code = + typeof parsed.error.code === "string" ? parsed.error.code : undefined; + return code ? { message, code } : { message }; + } + return { message: `HTTP ${status} ${statusText}` }; +} + +async function parseResponseBody( + res: Response, + url: string +): Promise> { + const raw = await res.text(); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + return isRecord(parsed) ? parsed : { value: parsed }; + } catch { + throw new ApiError(`Invalid JSON from ${url}: ${raw.slice(0, 500)}`); + } +} + +// ── Auth resolver — same shape as seed-cmd.ts (PR #459) ──────────────────── + +async function resolveAuth( + urlFlag?: string, + orgFlag?: string, + storePath?: string +): Promise<{ token: string; mcpUrl: string; orgSlug: string }> { + const org = resolveOrg(orgFlag); + if (org) { + const orgSession = getSessionForOrg(org, storePath); + if (orgSession) { + const result = await getUsableToken(orgSession.key, storePath); + if (result) { + return { token: result.token, mcpUrl: orgSession.key, orgSlug: org }; + } + } + const serverUrl = resolveServerUrl(urlFlag, storePath); + if (serverUrl) { + const orgUrl = mcpUrlForOrg(serverUrl, org); + const result = await getUsableToken(orgUrl, storePath); + if (result) { + return { token: result.token, mcpUrl: orgUrl, orgSlug: org }; + } + } + throw new ValidationError("Not logged in. Run: lobu login"); + } + + const serverUrl = resolveServerUrl(urlFlag, storePath); + const result = await getUsableToken(serverUrl || undefined, storePath); + if (!result) { + throw new ValidationError("Not logged in. Run: lobu login"); + } + const resolvedOrg = + orgFromMcpUrl(result.session.mcpUrl) || result.session.org; + if (!resolvedOrg) { + throw new ValidationError( + "Cannot determine org. Use --org or set LOBU_MEMORY_ORG." + ); + } + return { + token: result.token, + mcpUrl: result.session.mcpUrl, + orgSlug: resolvedOrg, + }; +} + +/** Strip the path off an MCP URL to reach the API root. */ +export function deriveApiBaseUrl(mcpUrl: string): string { + const url = new URL(mcpUrl); + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +// ── Client ───────────────────────────────────────────────────────────────── + +export interface ApplyClientConfig { + apiBaseUrl: string; + orgSlug: string; + token: string; +} + +/** + * Typed wrappers for the existing server endpoints `lobu apply` calls. + * + * The class is open over an injectable `fetchImpl` so tests can stub the + * network without monkey-patching globals. Real callers leave `fetchImpl` + * unset and pick up `globalThis.fetch`. + */ +export class ApplyClient { + private readonly apiBaseUrl: string; + private readonly orgSlug: string; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + + constructor(cfg: ApplyClientConfig, fetchImpl: typeof fetch = fetch) { + this.apiBaseUrl = cfg.apiBaseUrl; + this.orgSlug = cfg.orgSlug; + this.token = cfg.token; + this.fetchImpl = fetchImpl; + } + + // ── HTTP shape (mirrors openclaw-cmd.ts:postJson, locally scoped) ──────── + + private async request( + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + path: string, + body?: unknown, + okStatuses: number[] = [200, 201, 204] + ): Promise<{ status: number; body: T }> { + const url = `${this.apiBaseUrl}${path}`; + const init: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.token}`, + }, + }; + if (body !== undefined) init.body = JSON.stringify(body); + const res = await this.fetchImpl(url, init); + const parsed = await parseResponseBody(res, url); + + if (!okStatuses.includes(res.status) && !res.ok) { + const { message, code } = extractApiError( + parsed, + res.status, + res.statusText + ); + throw new ApiError( + `${method} ${path} failed: ${message}${code ? ` [${code}]` : ""}`, + res.status + ); + } + + if (typeof parsed.error === "string" && parsed.error.length > 0) { + throw new ApiError( + `${method} ${path} returned error: ${parsed.error}`, + res.status + ); + } + + return { status: res.status, body: parsed as T }; + } + + // ── Agents ──────────────────────────────────────────────────────────────── + + async listAgents(): Promise { + const { body } = await this.request<{ agents?: RemoteAgent[] }>( + "GET", + `/api/${this.orgSlug}/agents` + ); + return body.agents ?? []; + } + + /** + * Idempotent create: PR-2 makes `POST /` return 200 with the existing + * payload when an agent of the same ID already exists in the same org. + * Cross-org collision still surfaces as 409 with a clear `error.code` — + * we re-throw verbatim so `lobu apply` can show the operator the link + * to the org-scoped IDs issue. + */ + async upsertAgent(agent: { + agentId: string; + name: string; + description?: string; + }): Promise { + const { body } = await this.request( + "POST", + `/api/${this.orgSlug}/agents/`, + agent, + [200, 201] + ); + return body; + } + + async getAgentSettings(agentId: string): Promise { + try { + const { body } = await this.request( + "GET", + `/api/${this.orgSlug}/agents/${agentId}/config` + ); + return body; + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } + } + + async patchAgentSettings( + agentId: string, + settings: Partial + ): Promise { + await this.request( + "PATCH", + `/api/${this.orgSlug}/agents/${agentId}/config`, + settings + ); + } + + // ── Connections ─────────────────────────────────────────────────────────── + + async listConnections(agentId: string): Promise { + const { body } = await this.request<{ connections?: RemoteConnection[] }>( + "GET", + `/api/${this.orgSlug}/agents/${agentId}/connections` + ); + return body.connections ?? []; + } + + /** + * Stable-ID upsert (PR-2 introduces this route). + * + * Server contract: + * PUT /:agentId/connections/by-stable-id/:stableId + * body: { platform, name?, config } + * response when unchanged: { noop: true, connection } + * response when changed: { updated: true, willRestart: true, connection } + * response on first write: { created: true, connection } + */ + async upsertConnection( + agentId: string, + stableId: string, + payload: { platform: string; name?: string; config: Record } + ): Promise { + const { body } = await this.request( + "PUT", + `/api/${this.orgSlug}/agents/${agentId}/connections/by-stable-id/${encodeURIComponent(stableId)}`, + payload + ); + return body; + } + + // ── Memory schema ───────────────────────────────────────────────────────── + + async listEntityTypes(): Promise { + const { body } = await this.request<{ + entity_types?: RemoteEntityType[]; + entityTypes?: RemoteEntityType[]; + }>("POST", `/api/${this.orgSlug}/manage_entity_schema`, { + schema_type: "entity_type", + action: "list", + }); + return body.entity_types ?? body.entityTypes ?? []; + } + + async upsertEntityType(entity: { + slug: string; + name?: string; + description?: string; + required?: string[]; + properties?: Record; + }): Promise { + // The admin tool exposes separate `create` / `update` actions and surfaces + // duplicates as a structured error code rather than a 4xx. Probe with + // `create`; on a duplicate-named-resource code, retry with `update`. + try { + await this.request("POST", `/api/${this.orgSlug}/manage_entity_schema`, { + schema_type: "entity_type", + action: "create", + ...entity, + }); + return { created: true }; + } catch (err) { + if (err instanceof ApiError && isDuplicateError(err)) { + await this.request( + "POST", + `/api/${this.orgSlug}/manage_entity_schema`, + { schema_type: "entity_type", action: "update", ...entity } + ); + return { updated: true }; + } + throw err; + } + } + + async listRelationshipTypes(): Promise { + const { body } = await this.request<{ + relationship_types?: RemoteRelationshipType[]; + relationshipTypes?: RemoteRelationshipType[]; + }>("POST", `/api/${this.orgSlug}/manage_entity_schema`, { + schema_type: "relationship_type", + action: "list", + }); + return body.relationship_types ?? body.relationshipTypes ?? []; + } + + async upsertRelationshipType(rel: { + slug: string; + name?: string; + description?: string; + rules?: Array<{ source: string; target: string }>; + }): Promise { + const { rules, ...payload } = rel; + let result: UpsertEntityTypeResult; + try { + await this.request("POST", `/api/${this.orgSlug}/manage_entity_schema`, { + schema_type: "relationship_type", + action: "create", + ...payload, + }); + result = { created: true }; + } catch (err) { + if (err instanceof ApiError && isDuplicateError(err)) { + await this.request( + "POST", + `/api/${this.orgSlug}/manage_entity_schema`, + { schema_type: "relationship_type", action: "update", ...payload } + ); + result = { updated: true }; + } else { + throw err; + } + } + + // Register rules separately via add_rule. Backend treats add_rule as + // idempotent; duplicate-add surfaces a structured error we can swallow. + if (rules?.length) { + for (const rule of rules) { + try { + await this.request( + "POST", + `/api/${this.orgSlug}/manage_entity_schema`, + { + schema_type: "relationship_type", + action: "add_rule", + slug: rel.slug, + source_entity_type_slug: rule.source, + target_entity_type_slug: rule.target, + } + ); + } catch (err) { + if (err instanceof ApiError && isDuplicateError(err)) continue; + throw err; + } + } + } + return result; + } +} + +/** + * Recognise duplicate-name errors from the admin tools without substring + * matching the user-facing message. The server emits a structured code in + * `error.code` (e.g. `entity_type_exists`, `already_exists`) that the + * proxy surfaces in the error payload. This helper centralises that check + * so we can extend the code list as the server grows. + * + * Tradeoff: the existing `manage_entity_schema` handler doesn't currently + * stamp a stable code for every duplicate path. Until it does, we accept + * structured codes when present and fall back to the http status alone + * (any 4xx for a `create` action is treated as duplicate-or-bad-payload; + * the subsequent `update` will fail noisily on the latter). + */ +function isDuplicateError(err: ApiError): boolean { + if (typeof err.status === "number" && err.status >= 400 && err.status < 500) { + const message = err.message.toLowerCase(); + if ( + message.includes("[entity_type_exists]") || + message.includes("[relationship_type_exists]") || + message.includes("[already_exists]") + ) { + return true; + } + // Fall back to status-only when no code is stamped. This is loose; we + // accept the loss because the v1 plan explicitly limits us to + // server endpoints whose error shape we don't control. + return err.status === 409 || err.status === 422 || err.status === 400; + } + return false; +} + +// ── Top-level resolver ───────────────────────────────────────────────────── + +export interface ResolvedClient { + client: ApplyClient; + apiBaseUrl: string; + orgSlug: string; + mcpUrl: string; +} + +export async function resolveApplyClient(opts: { + url?: string; + org?: string; + storePath?: string; + fetchImpl?: typeof fetch; +}): Promise { + const { token, mcpUrl, orgSlug } = await resolveAuth( + opts.url, + opts.org, + opts.storePath + ); + const apiBaseUrl = deriveApiBaseUrl(mcpUrl); + const client = new ApplyClient( + { apiBaseUrl, orgSlug, token }, + opts.fetchImpl + ); + return { client, apiBaseUrl, orgSlug, mcpUrl }; +} diff --git a/packages/cli/src/commands/_lib/apply/desired-state.ts b/packages/cli/src/commands/_lib/apply/desired-state.ts new file mode 100644 index 000000000..db105f405 --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/desired-state.ts @@ -0,0 +1,554 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { AgentSettings, LobuTomlConfig, TomlAgentEntry } from "@lobu/core"; +import { parse as parseToml } from "smol-toml"; +import { ValidationError } from "../../memory/_lib/errors.js"; +import { + CONFIG_FILENAME, + isLoadError, + loadConfig, +} from "../../../config/loader.js"; + +// ── Stable connection IDs (mirror of file-loader.ts:56) ──────────────────── +// +// keep in sync with packages/owletto-backend/src/gateway/config/file-loader.ts:56 +function slugifyForConnectionId(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +// keep in sync with packages/owletto-backend/src/gateway/config/file-loader.ts:56 +export function buildStableConnectionId( + agentId: string, + type: string, + name?: string +): string { + const parts = [slugifyForConnectionId(agentId), slugifyForConnectionId(type)]; + if (name) parts.push(slugifyForConnectionId(name)); + return parts.join("-"); +} + +// ── Desired state types ──────────────────────────────────────────────────── + +export interface DesiredAgentMetadata { + agentId: string; + name: string; + description?: string; +} + +export interface DesiredConnection { + /** Stable, content-addressed ID derived from `(agentId, type, name?)`. */ + stableId: string; + type: string; + name?: string; + /** Raw config from lobu.toml — values may still contain `$VAR` references. */ + config: Record; +} + +export interface DesiredEntityType { + slug: string; + name?: string; + description?: string; + required?: string[]; + properties?: Record; + metadata?: Record; +} + +export interface DesiredRelationshipType { + slug: string; + name?: string; + description?: string; + rules?: Array<{ source: string; target: string }>; + metadata?: Record; +} + +export interface DesiredAgent { + metadata: DesiredAgentMetadata; + /** + * Settings payload destined for `PATCH /:agentId/config`. Built from the + * lobu.toml fields the file-loader currently lifts: networkConfig, + * skillsConfig, egressConfig, preApprovedTools, guardrails, toolsConfig, + * nixConfig, mcpServers, modelSelection, providerModelPreferences, + * installedProviders, identityMd/soulMd/userMd. + * + * Persistence of egressConfig/preApprovedTools/guardrails depends on PR-1. + */ + settings: Partial; + connections: DesiredConnection[]; +} + +export interface DesiredState { + agents: DesiredAgent[]; + memorySchema: { + entityTypes: DesiredEntityType[]; + relationshipTypes: DesiredRelationshipType[]; + }; + /** + * Names of env vars referenced as `$NAME` anywhere in lobu.toml. The CLI + * surfaces these to the user before mutating remote state so missing + * secrets fail loud instead of expanding to empty strings. + */ + requiredSecrets: string[]; +} + +// ── Load + transform ─────────────────────────────────────────────────────── + +const ENV_REF = /^\$([A-Z][A-Z0-9_]*)$/; + +function asEnvRef(value: string): string | null { + const match = ENV_REF.exec(value.trim()); + return match?.[1] ?? null; +} + +function collectEnvRefs(config: LobuTomlConfig, out: Set): void { + for (const agentConfig of Object.values(config.agents)) { + for (const provider of agentConfig.providers) { + if (provider.key) { + const ref = asEnvRef(provider.key); + if (ref) out.add(ref); + } + if (provider.secret_ref) { + const ref = asEnvRef(provider.secret_ref); + if (ref) out.add(ref); + } + } + for (const conn of agentConfig.connections) { + for (const value of Object.values(conn.config)) { + const ref = asEnvRef(value); + if (ref) out.add(ref); + } + } + if (agentConfig.skills.mcp) { + for (const mcp of Object.values(agentConfig.skills.mcp)) { + if (mcp.headers) { + for (const v of Object.values(mcp.headers)) { + const ref = asEnvRef(v); + if (ref) out.add(ref); + } + } + if (mcp.env) { + for (const v of Object.values(mcp.env)) { + const ref = asEnvRef(v); + if (ref) out.add(ref); + } + } + if (mcp.oauth) { + if (mcp.oauth.client_id) { + const ref = asEnvRef(mcp.oauth.client_id); + if (ref) out.add(ref); + } + if (mcp.oauth.client_secret) { + const ref = asEnvRef(mcp.oauth.client_secret); + if (ref) out.add(ref); + } + } + } + } + } +} + +function buildAgentSettings( + agentConfig: TomlAgentEntry, + markdown: { identityMd?: string; soulMd?: string; userMd?: string } +): Partial { + const settings: Partial = { ...markdown }; + + // Providers (ordered, index 0 = primary) + if (agentConfig.providers.length > 0) { + settings.installedProviders = agentConfig.providers.map((p) => ({ + providerId: p.id, + installedAt: Date.now(), + })); + settings.modelSelection = { mode: "auto" }; + const providerModelPreferences = Object.fromEntries( + agentConfig.providers + .filter((p) => !!p.model?.trim()) + .map((p) => [p.id, p.model!.trim()]) + ); + if (Object.keys(providerModelPreferences).length > 0) { + settings.providerModelPreferences = providerModelPreferences; + } + } + + // Network — agent-level only (skill merging happens server-side once + // skills_config is patched. Pre-merging here would race the server's own + // merge step.) + const network = agentConfig.network; + if (network) { + const cfg: AgentSettings["networkConfig"] = {}; + if (network.allowed?.length) cfg.allowedDomains = [...network.allowed]; + if (network.denied?.length) cfg.deniedDomains = [...network.denied]; + if (network.judge?.length) cfg.judgedDomains = [...network.judge]; + if (network.judges && Object.keys(network.judges).length > 0) { + cfg.judges = { ...network.judges }; + } + if (Object.keys(cfg).length > 0) settings.networkConfig = cfg; + } + + // Egress (PR-1 persists this column) + if (agentConfig.egress) { + const egressConfig: AgentSettings["egressConfig"] = {}; + if (agentConfig.egress.extra_policy) { + egressConfig.extraPolicy = agentConfig.egress.extra_policy; + } + if (agentConfig.egress.judge_model) { + egressConfig.judgeModel = agentConfig.egress.judge_model; + } + if (Object.keys(egressConfig).length > 0) { + settings.egressConfig = egressConfig; + } + } + + // Tools — pre_approved + worker-side allow/deny/strict (PR-1 persists + // preApprovedTools). + if (agentConfig.tools) { + if (agentConfig.tools.pre_approved?.length) { + settings.preApprovedTools = [...new Set(agentConfig.tools.pre_approved)]; + } + const toolsConfig: AgentSettings["toolsConfig"] = {}; + if (agentConfig.tools.allowed?.length) { + toolsConfig.allowedTools = [...new Set(agentConfig.tools.allowed)]; + } + if (agentConfig.tools.denied?.length) { + toolsConfig.deniedTools = [...new Set(agentConfig.tools.denied)]; + } + if (agentConfig.tools.strict !== undefined) { + toolsConfig.strictMode = agentConfig.tools.strict; + } + if (Object.keys(toolsConfig).length > 0) { + settings.toolsConfig = toolsConfig; + } + } + + // Guardrails (PR-1 persists this column) + if (agentConfig.guardrails?.length) { + settings.guardrails = [...new Set(agentConfig.guardrails)]; + } + + // Nix + if (agentConfig.worker?.nix_packages?.length) { + settings.nixConfig = { + packages: [...new Set(agentConfig.worker.nix_packages)], + }; + } + + // MCP servers — agent-level only. Skill-derived MCP entries land server-side + // once skills_config is patched. The on-wire shape extends McpServerConfig + // with `oauth` + `authScope`, both of which the gateway store accepts as + // pass-through JSON. Built as a typed-but-loose record because the core + // McpServerConfig interface omits oauth/authScope. + if (agentConfig.skills.mcp) { + const mcpServers: Record> = {}; + for (const [id, mcp] of Object.entries(agentConfig.skills.mcp)) { + const mapped: Record = {}; + if (mcp.url) mapped.url = mcp.url; + if (mcp.command) mapped.command = mcp.command; + if (mcp.args) mapped.args = mcp.args; + if (mcp.headers) mapped.headers = mcp.headers; + if (mcp.auth_scope) mapped.authScope = mcp.auth_scope; + if (mcp.oauth) { + mapped.oauth = { + authUrl: mcp.oauth.auth_url, + tokenUrl: mcp.oauth.token_url, + ...(mcp.oauth.client_id ? { clientId: mcp.oauth.client_id } : {}), + ...(mcp.oauth.client_secret + ? { clientSecret: mcp.oauth.client_secret } + : {}), + ...(mcp.oauth.scopes ? { scopes: mcp.oauth.scopes } : {}), + ...(mcp.oauth.token_endpoint_auth_method + ? { + tokenEndpointAuthMethod: mcp.oauth.token_endpoint_auth_method, + } + : {}), + }; + } + if (mcp.env) mapped.env = { ...mcp.env }; + mcpServers[id] = mapped; + } + if (Object.keys(mcpServers).length > 0) { + settings.mcpServers = mcpServers as AgentSettings["mcpServers"]; + } + } + + return settings; +} + +async function readMarkdown( + agentDir: string +): Promise<{ identityMd?: string; soulMd?: string; userMd?: string }> { + const result: { identityMd?: string; soulMd?: string; userMd?: string } = {}; + const files: Array<["identityMd" | "soulMd" | "userMd", string]> = [ + ["identityMd", "IDENTITY.md"], + ["soulMd", "SOUL.md"], + ["userMd", "USER.md"], + ]; + for (const [key, filename] of files) { + try { + const content = await readFile(join(agentDir, filename), "utf-8"); + if (content.trim()) result[key] = content.trim(); + } catch { + // missing file is fine + } + } + return result; +} + +function resolveConfigValue( + agentId: string, + connType: string, + key: string, + value: string, + env: NodeJS.ProcessEnv +): string { + const ref = asEnvRef(value); + if (!ref) return value; + const resolved = env[ref]; + if (resolved === undefined || resolved === "") { + throw new ValidationError( + `agent "${agentId}" connection "${connType}" config key "${key}" references $${ref}, but it is unset or empty in the apply environment` + ); + } + return resolved; +} + +function buildConnections( + agentId: string, + agentConfig: TomlAgentEntry, + env: NodeJS.ProcessEnv +): DesiredConnection[] { + // Reject duplicate (type, name) pairs — same rule the file-loader enforces + // so stable IDs stay collision-free. + const seen = new Set(); + const out: DesiredConnection[] = []; + for (const conn of agentConfig.connections) { + const key = `${conn.type}:${conn.name ?? ""}`; + if (seen.has(key)) { + throw new ValidationError( + conn.name + ? `agent "${agentId}" has duplicate connection (type=${conn.type}, name=${conn.name})` + : `agent "${agentId}" has multiple "${conn.type}" connections — add a unique \`name = "..."\` to each to disambiguate` + ); + } + seen.add(key); + const resolvedConfig: Record = {}; + for (const [k, v] of Object.entries(conn.config)) { + resolvedConfig[k] = resolveConfigValue(agentId, conn.type, k, v, env); + } + const desired: DesiredConnection = { + stableId: buildStableConnectionId(agentId, conn.type, conn.name), + type: conn.type, + config: resolvedConfig, + }; + if (conn.name) desired.name = conn.name; + out.push(desired); + } + return out; +} + +interface RawMemorySchema { + entity_types?: unknown; + relationship_types?: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseEntityType(raw: unknown): DesiredEntityType { + if (!isRecord(raw) || typeof raw.slug !== "string") { + throw new ValidationError( + `memory.entity_types entries must be objects with a "slug" string field; got ${JSON.stringify(raw)}` + ); + } + const out: DesiredEntityType = { slug: raw.slug }; + if (typeof raw.name === "string") out.name = raw.name; + if (typeof raw.description === "string") out.description = raw.description; + if (Array.isArray(raw.required)) { + out.required = raw.required.filter( + (v): v is string => typeof v === "string" + ); + } + if (isRecord(raw.properties)) out.properties = raw.properties; + if (isRecord(raw.metadata)) out.metadata = raw.metadata; + return out; +} + +function parseRelationshipType(raw: unknown): DesiredRelationshipType { + if (!isRecord(raw) || typeof raw.slug !== "string") { + throw new ValidationError( + `memory.relationship_types entries must be objects with a "slug" string field; got ${JSON.stringify(raw)}` + ); + } + const out: DesiredRelationshipType = { slug: raw.slug }; + if (typeof raw.name === "string") out.name = raw.name; + if (typeof raw.description === "string") out.description = raw.description; + if (Array.isArray(raw.rules)) { + out.rules = raw.rules + .filter(isRecord) + .filter( + ( + rule + ): rule is { source: string; target: string } & Record< + string, + unknown + > => typeof rule.source === "string" && typeof rule.target === "string" + ) + .map((rule) => ({ source: rule.source, target: rule.target })); + } + if (isRecord(raw.metadata)) out.metadata = raw.metadata; + return out; +} + +/** + * Read memory schema files referenced by `[memory.owletto].models`. Each YAML + * file in that directory should declare `type: entity_type` or + * `type: relationship_type` (matches the seed-cmd schema). + * + * v1: parse only entity_type and relationship_type. Watchers are deferred. + */ +async function loadMemorySchema( + config: LobuTomlConfig, + projectRoot: string +): Promise { + const empty = { entityTypes: [], relationshipTypes: [] }; + const owletto = config.memory?.owletto; + if (!owletto || owletto.enabled === false) return empty; + + const inline = config.memory as unknown as + | { schema?: RawMemorySchema } + | undefined; + if (inline?.schema) { + const entityTypesRaw = Array.isArray(inline.schema.entity_types) + ? inline.schema.entity_types + : []; + const relTypesRaw = Array.isArray(inline.schema.relationship_types) + ? inline.schema.relationship_types + : []; + return { + entityTypes: entityTypesRaw.map(parseEntityType), + relationshipTypes: relTypesRaw.map(parseRelationshipType), + }; + } + + // Models directory (matches seed-cmd's resolution rules). + const modelsRel = owletto.models?.trim() || "./models"; + const modelsPath = resolve(projectRoot, modelsRel); + + const { existsSync, readdirSync, readFileSync } = await import("node:fs"); + const { parse: parseYaml } = await import("yaml"); + + if (!existsSync(modelsPath)) return empty; + + const entityTypes: DesiredEntityType[] = []; + const relationshipTypes: DesiredRelationshipType[] = []; + + const files = readdirSync(modelsPath) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .sort(); + + for (const file of files) { + const raw = readFileSync(join(modelsPath, file), "utf-8"); + const parsed = parseYaml(raw) as unknown; + if (!isRecord(parsed) || typeof parsed.type !== "string") continue; + if (parsed.type === "entity_type" || parsed.type === "entity") { + entityTypes.push(parseEntityType(parsed)); + } else if ( + parsed.type === "relationship_type" || + parsed.type === "relationship" + ) { + relationshipTypes.push(parseRelationshipType(parsed)); + } + // watcher files are out of scope for v1 apply + } + + return { entityTypes, relationshipTypes }; +} + +/** + * The Zod schema strips unknown keys, so we re-parse the raw TOML to surface + * shapes the validated config can't see. Detecting `[[agents..watchers]]` + * here keeps users from silently shipping a config block that v1 ignores. + */ +async function rejectUnsupportedAgentShapes(cwd: string): Promise { + let raw: string; + try { + raw = await readFile(join(cwd, CONFIG_FILENAME), "utf-8"); + } catch { + return; + } + let parsed: Record; + try { + parsed = parseToml(raw) as Record; + } catch { + // loadConfig already surfaces parse errors — bail without throwing here. + return; + } + const agents = parsed.agents; + if (!agents || typeof agents !== "object") return; + for (const [agentId, agentConfig] of Object.entries( + agents as Record + )) { + if (!agentConfig || typeof agentConfig !== "object") continue; + const watchers = (agentConfig as Record).watchers; + if (Array.isArray(watchers) && watchers.length > 0) { + throw new ValidationError( + `agent "${agentId}" declares [[agents.${agentId}.watchers]] — \`lobu apply\` does not sync watchers in v1. Remove the block or use \`lobu memory seed\`.` + ); + } + } +} + +// ── Public API ───────────────────────────────────────────────────────────── + +export interface LoadDesiredStateOptions { + /** Project root (directory containing `lobu.toml`). */ + cwd: string; + /** Env to resolve `$VAR` refs against; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; +} + +export async function loadDesiredState( + opts: LoadDesiredStateOptions +): Promise<{ state: DesiredState; configPath: string }> { + const result = await loadConfig(opts.cwd); + if (isLoadError(result)) { + const detail = result.details?.length + ? `${result.error}\n ${result.details.join("\n ")}` + : result.error; + throw new ValidationError(detail); + } + + const { config, path: configPath } = result; + await rejectUnsupportedAgentShapes(opts.cwd); + + const env = opts.env ?? process.env; + const requiredSecrets = new Set(); + collectEnvRefs(config, requiredSecrets); + + const agents: DesiredAgent[] = []; + for (const [agentId, agentConfig] of Object.entries(config.agents)) { + const agentDir = resolve(opts.cwd, agentConfig.dir); + const markdown = await readMarkdown(agentDir); + const settings = buildAgentSettings(agentConfig, markdown); + const connections = buildConnections(agentId, agentConfig, env); + const metadata: DesiredAgentMetadata = { + agentId, + name: agentConfig.name, + }; + if (agentConfig.description) metadata.description = agentConfig.description; + agents.push({ metadata, settings, connections }); + } + + const memorySchema = await loadMemorySchema(config, opts.cwd); + + return { + state: { + agents, + memorySchema, + requiredSecrets: [...requiredSecrets].sort(), + }, + configPath, + }; +} diff --git a/packages/cli/src/commands/_lib/apply/diff.ts b/packages/cli/src/commands/_lib/apply/diff.ts new file mode 100644 index 000000000..2a1b2cba6 --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/diff.ts @@ -0,0 +1,462 @@ +import type { AgentSettings } from "@lobu/core"; +import type { + RemoteAgent, + RemoteConnection, + RemoteEntityType, + RemoteRelationshipType, +} from "./client.js"; +import type { + DesiredAgent, + DesiredConnection, + DesiredEntityType, + DesiredRelationshipType, +} from "./desired-state.js"; + +// ── Diff verbs ────────────────────────────────────────────────────────────── + +export type DiffVerb = "create" | "update" | "noop" | "drift"; + +interface BaseRow { + verb: DiffVerb; + /** Stable identifier for matching messages and UI. */ + id: string; +} + +export interface AgentDiffRow extends BaseRow { + kind: "agent"; + desired?: DesiredAgent["metadata"]; + remote?: RemoteAgent; + /** Field-level changes when verb === "update". */ + changedFields?: string[]; +} + +export interface SettingsDiffRow extends BaseRow { + kind: "settings"; + desired?: Partial; + changedFields?: string[]; +} + +export interface ConnectionDiffRow extends BaseRow { + kind: "connection"; + agentId: string; + desired?: DesiredConnection; + remote?: RemoteConnection; + changedFields?: string[]; + /** True when an update will restart the live worker — surfaced in the plan. */ + willRestart?: boolean; +} + +export interface EntityTypeDiffRow extends BaseRow { + kind: "entity-type"; + desired?: DesiredEntityType; + remote?: RemoteEntityType; + changedFields?: string[]; +} + +export interface RelationshipTypeDiffRow extends BaseRow { + kind: "relationship-type"; + desired?: DesiredRelationshipType; + remote?: RemoteRelationshipType; + changedFields?: string[]; +} + +export type DiffRow = + | AgentDiffRow + | SettingsDiffRow + | ConnectionDiffRow + | EntityTypeDiffRow + | RelationshipTypeDiffRow; + +export interface DiffPlan { + rows: DiffRow[]; + /** Aggregate counters for the summary line. */ + counts: { create: number; update: number; noop: number; drift: number }; +} + +// ── Equality helpers ─────────────────────────────────────────────────────── + +/** + * Stable structural equality for JSON-shaped values. Sorts object keys before + * stringifying so `{a:1,b:2}` and `{b:2,a:1}` compare equal. + * + * Returns false (i.e. "different") when either side is `undefined` and the + * other isn't — but treats `[]` vs `undefined` and `{}` vs `undefined` as + * **equal**, since empty containers carry no semantic state and the server + * commonly omits them. + */ +function deepEqual(a: unknown, b: unknown): boolean { + return canonical(a) === canonical(b); +} + +function canonical(value: unknown): string { + if (value === undefined || value === null) return "null"; + if (Array.isArray(value)) { + if (value.length === 0) return "null"; + return `[${value.map(canonical).join(",")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)); + if (entries.length === 0) return "null"; + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +// ── Per-resource diff ────────────────────────────────────────────────────── + +function diffAgent( + desired: DesiredAgent["metadata"], + remote: RemoteAgent | undefined +): AgentDiffRow { + if (!remote) { + return { kind: "agent", verb: "create", id: desired.agentId, desired }; + } + const changed: string[] = []; + if (desired.name !== remote.name) changed.push("name"); + if ((desired.description ?? "") !== (remote.description ?? "")) { + changed.push("description"); + } + if (changed.length === 0) { + return { + kind: "agent", + verb: "noop", + id: desired.agentId, + desired, + remote, + }; + } + return { + kind: "agent", + verb: "update", + id: desired.agentId, + desired, + remote, + changedFields: changed, + }; +} + +/** + * Compare desired settings against what's currently stored. + * + * Redacted-value handling: server never returns secret values in cleartext; + * any string starting with `***` from the GET response is treated as opaque + * and the diff records `:redacted` instead of comparing values. The + * AgentSettings shape currently has no redacted leaf strings, so this is a + * forward-compatible guard rather than a hot path today. + * + * Field set: limited to the keys lobu.toml can express today. Settings that + * only the UI mutates (e.g. `installedProviders[].installedAt`) are + * excluded so unrelated UI activity doesn't show up as drift in the plan. + */ +const SETTINGS_FIELDS: Array = [ + "networkConfig", + "egressConfig", + "nixConfig", + "mcpServers", + "skillsConfig", + "toolsConfig", + "guardrails", + "preApprovedTools", + "providerModelPreferences", + "modelSelection", + "soulMd", + "userMd", + "identityMd", +]; + +function diffSettings( + agentId: string, + desired: Partial, + remote: AgentSettings | null +): SettingsDiffRow { + const changed: string[] = []; + for (const field of SETTINGS_FIELDS) { + if (!(field in desired)) continue; + if (!deepEqual(desired[field], remote?.[field])) { + changed.push(field); + } + } + // Special case: when the agent itself is being created, the matching settings + // patch is always considered a "create" so the user sees both rows in the + // plan. The caller is responsible for setting `verb: "create"` from outside + // when needed; here we only key off field equality. + if (changed.length === 0) { + return { kind: "settings", verb: "noop", id: agentId, desired }; + } + return { + kind: "settings", + verb: "update", + id: agentId, + desired, + changedFields: changed, + }; +} + +function diffConnection( + agentId: string, + desired: DesiredConnection, + remote: RemoteConnection | undefined +): ConnectionDiffRow { + if (!remote) { + return { + kind: "connection", + verb: "create", + id: desired.stableId, + agentId, + desired, + willRestart: false, + }; + } + const changed: string[] = []; + if (desired.type !== remote.platform) changed.push("type"); + if (!deepEqual(desired.config, remote.config ?? {})) changed.push("config"); + if (changed.length === 0) { + return { + kind: "connection", + verb: "noop", + id: desired.stableId, + agentId, + desired, + remote, + }; + } + return { + kind: "connection", + verb: "update", + id: desired.stableId, + agentId, + desired, + remote, + changedFields: changed, + willRestart: changed.includes("config") || changed.includes("type"), + }; +} + +function diffEntityType( + desired: DesiredEntityType, + remote: RemoteEntityType | undefined +): EntityTypeDiffRow { + if (!remote) { + return { kind: "entity-type", verb: "create", id: desired.slug, desired }; + } + const changed: string[] = []; + if ((desired.name ?? "") !== (remote.name ?? "")) changed.push("name"); + if ((desired.description ?? "") !== (remote.description ?? "")) { + changed.push("description"); + } + if (!deepEqual(desired.required ?? [], remote.required ?? [])) { + changed.push("required"); + } + if (!deepEqual(desired.properties, remote.properties)) { + changed.push("properties"); + } + if (changed.length === 0) { + return { + kind: "entity-type", + verb: "noop", + id: desired.slug, + desired, + remote, + }; + } + return { + kind: "entity-type", + verb: "update", + id: desired.slug, + desired, + remote, + changedFields: changed, + }; +} + +function diffRelationshipType( + desired: DesiredRelationshipType, + remote: RemoteRelationshipType | undefined +): RelationshipTypeDiffRow { + if (!remote) { + return { + kind: "relationship-type", + verb: "create", + id: desired.slug, + desired, + }; + } + const changed: string[] = []; + if ((desired.name ?? "") !== (remote.name ?? "")) changed.push("name"); + if ((desired.description ?? "") !== (remote.description ?? "")) { + changed.push("description"); + } + if (!deepEqual(desired.rules ?? [], remote.rules ?? [])) { + changed.push("rules"); + } + if (changed.length === 0) { + return { + kind: "relationship-type", + verb: "noop", + id: desired.slug, + desired, + remote, + }; + } + return { + kind: "relationship-type", + verb: "update", + id: desired.slug, + desired, + remote, + changedFields: changed, + }; +} + +// ── Top-level diff ───────────────────────────────────────────────────────── + +export interface RemoteSnapshot { + agents: RemoteAgent[]; + /** keyed by agentId */ + agentSettings: Map; + /** keyed by agentId */ + connectionsByAgent: Map; + entityTypes: RemoteEntityType[]; + relationshipTypes: RemoteRelationshipType[]; +} + +export interface DesiredStateForDiff { + agents: DesiredAgent[]; + memorySchema: { + entityTypes: DesiredEntityType[]; + relationshipTypes: DesiredRelationshipType[]; + }; +} + +export interface ComputeDiffOptions { + /** Limit the diff to a subset of resource kinds. */ + only?: "agents" | "memory"; +} + +export function computeDiff( + desired: DesiredStateForDiff, + remote: RemoteSnapshot, + opts: ComputeDiffOptions = {} +): DiffPlan { + const rows: DiffRow[] = []; + const only = opts.only; + + if (only !== "memory") { + const remoteByAgent = new Map(remote.agents.map((a) => [a.agentId, a])); + const desiredAgentIds = new Set( + desired.agents.map((a) => a.metadata.agentId) + ); + + for (const agent of desired.agents) { + const remoteAgent = remoteByAgent.get(agent.metadata.agentId); + rows.push(diffAgent(agent.metadata, remoteAgent)); + + const settingsRow = diffSettings( + agent.metadata.agentId, + agent.settings, + remote.agentSettings.get(agent.metadata.agentId) ?? null + ); + // If the agent itself is new, escalate the matching settings row to + // `create` — that's the operator's mental model: the settings are part + // of the agent's creation, not a follow-up update. + if (!remoteAgent && settingsRow.verb !== "noop") { + rows.push({ ...settingsRow, verb: "create" }); + } else if (!remoteAgent) { + // No desired-side fields set; still emit a create row so the plan + // shows the apply step actually happens. + rows.push({ ...settingsRow, verb: "create" }); + } else { + rows.push(settingsRow); + } + + const remoteConns = + remote.connectionsByAgent.get(agent.metadata.agentId) ?? []; + const remoteByStableId = new Map(remoteConns.map((c) => [c.id, c])); + const desiredStableIds = new Set( + agent.connections.map((c) => c.stableId) + ); + + for (const conn of agent.connections) { + rows.push( + diffConnection( + agent.metadata.agentId, + conn, + remoteByStableId.get(conn.stableId) + ) + ); + } + for (const remoteConn of remoteConns) { + if (!desiredStableIds.has(remoteConn.id)) { + rows.push({ + kind: "connection", + verb: "drift", + id: remoteConn.id, + agentId: agent.metadata.agentId, + remote: remoteConn, + }); + } + } + } + + // Drift: remote agents not in desired state. v1 reports, never deletes. + for (const remoteAgent of remote.agents) { + if (!desiredAgentIds.has(remoteAgent.agentId)) { + rows.push({ + kind: "agent", + verb: "drift", + id: remoteAgent.agentId, + remote: remoteAgent, + }); + } + } + } + + if (only !== "agents") { + const remoteEntityBySlug = new Map( + remote.entityTypes.map((e) => [e.slug, e]) + ); + const desiredEntitySlugs = new Set( + desired.memorySchema.entityTypes.map((e) => e.slug) + ); + for (const entity of desired.memorySchema.entityTypes) { + rows.push(diffEntityType(entity, remoteEntityBySlug.get(entity.slug))); + } + for (const remoteEntity of remote.entityTypes) { + if (!desiredEntitySlugs.has(remoteEntity.slug)) { + rows.push({ + kind: "entity-type", + verb: "drift", + id: remoteEntity.slug, + remote: remoteEntity, + }); + } + } + + const remoteRelBySlug = new Map( + remote.relationshipTypes.map((r) => [r.slug, r]) + ); + const desiredRelSlugs = new Set( + desired.memorySchema.relationshipTypes.map((r) => r.slug) + ); + for (const rel of desired.memorySchema.relationshipTypes) { + rows.push(diffRelationshipType(rel, remoteRelBySlug.get(rel.slug))); + } + for (const remoteRel of remote.relationshipTypes) { + if (!desiredRelSlugs.has(remoteRel.slug)) { + rows.push({ + kind: "relationship-type", + verb: "drift", + id: remoteRel.slug, + remote: remoteRel, + }); + } + } + } + + const counts = { create: 0, update: 0, noop: 0, drift: 0 }; + for (const row of rows) counts[row.verb]++; + + return { rows, counts }; +} diff --git a/packages/cli/src/commands/_lib/apply/prompt.ts b/packages/cli/src/commands/_lib/apply/prompt.ts new file mode 100644 index 000000000..655d713c5 --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/prompt.ts @@ -0,0 +1,27 @@ +import { confirm } from "@inquirer/prompts"; +import { ValidationError } from "../../memory/_lib/errors.js"; + +export interface ConfirmOptions { + /** Skip the prompt and treat as approved. CI / scripted apply path. */ + yes: boolean; + /** Plan summary line to show next to the prompt for confirmation context. */ + summaryLine: string; +} + +/** + * Block until the user explicitly accepts the plan. `--yes` short-circuits + * to true. Non-TTY without `--yes` exits with a clear error rather than + * trying to read from a closed stdin and hanging. + */ +export async function confirmPlan(opts: ConfirmOptions): Promise { + if (opts.yes) return true; + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new ValidationError( + "stdin is not a TTY and --yes was not supplied. Re-run with --yes to apply non-interactively." + ); + } + return confirm({ + message: `Apply plan? (${opts.summaryLine})`, + default: false, + }); +} diff --git a/packages/cli/src/commands/_lib/apply/render.ts b/packages/cli/src/commands/_lib/apply/render.ts new file mode 100644 index 000000000..206686847 --- /dev/null +++ b/packages/cli/src/commands/_lib/apply/render.ts @@ -0,0 +1,122 @@ +import chalk from "chalk"; +import type { DiffPlan, DiffRow } from "./diff.js"; + +const VERB_PREFIX = { + create: chalk.green("+"), + update: chalk.yellow("~"), + noop: chalk.dim("="), + drift: chalk.cyan("?"), +} as const; + +const KIND_LABEL: Record = { + agent: "agent", + settings: "settings", + connection: "connection", + "entity-type": "entity-type", + "relationship-type": "relationship-type", +}; + +function fieldsList(fields: string[] | undefined): string { + if (!fields?.length) return ""; + return chalk.dim(` (${fields.join(", ")})`); +} + +function renderRow(row: DiffRow): string[] { + const prefix = VERB_PREFIX[row.verb]; + const label = chalk.bold(KIND_LABEL[row.kind]); + const id = row.kind === "connection" ? `${row.agentId}/${row.id}` : row.id; + const lines: string[] = []; + + switch (row.verb) { + case "create": + lines.push(` ${prefix} ${label} ${id}`); + break; + case "update": + lines.push(` ${prefix} ${label} ${id}${fieldsList(row.changedFields)}`); + if (row.kind === "connection" && row.willRestart) { + lines.push( + ` ${chalk.yellow("⚠")} will restart connection — in-flight messages may drop` + ); + } + break; + case "noop": + lines.push(` ${prefix} ${label} ${id}`); + break; + case "drift": + lines.push( + ` ${prefix} ${label} ${id} ${chalk.cyan("(drift — ignored in v1, not deleted)")}` + ); + break; + } + + return lines; +} + +/** Emit the plan summary block — what `--dry-run` and the prompt-confirm phase show. */ +export function renderPlan(plan: DiffPlan): string { + const lines: string[] = []; + lines.push(chalk.bold("\nPlan:")); + + // Group rows by kind so the output order is deterministic and readable. + const order: DiffRow["kind"][] = [ + "agent", + "settings", + "connection", + "entity-type", + "relationship-type", + ]; + for (const kind of order) { + const rowsForKind = plan.rows.filter((row) => row.kind === kind); + if (rowsForKind.length === 0) continue; + lines.push(""); + lines.push(chalk.bold(` ${KIND_LABEL[kind]}s:`)); + for (const row of rowsForKind) { + lines.push(...renderRow(row)); + } + } + + lines.push(""); + lines.push(renderSummary(plan)); + return lines.join("\n"); +} + +export function renderSummary(plan: DiffPlan): string { + const { create, update, noop, drift } = plan.counts; + return chalk.bold( + `Summary: ${chalk.green(`${create} create`)}, ${chalk.yellow(`${update} update`)}, ${chalk.dim(`${noop} noop`)}, ${chalk.cyan(`${drift} drift`)}` + ); +} + +/** Apply-time progress line. Mirrors the same prefix as the plan rows. */ +export function renderProgress( + verb: DiffRow["verb"], + kind: DiffRow["kind"], + id: string, + detail?: string +): string { + const prefix = VERB_PREFIX[verb]; + const label = chalk.bold(KIND_LABEL[kind]); + const tail = detail ? chalk.dim(` ${detail}`) : ""; + return ` ${prefix} ${label} ${id}${tail}`; +} + +/** Required-secrets-missing block. */ +export function renderMissingSecrets(missing: string[]): string { + const lines = [ + chalk.red( + `\n Missing ${missing.length} required secret${missing.length === 1 ? "" : "s"}:` + ), + ]; + for (const name of missing) lines.push(chalk.red(` - $${name}`)); + lines.push( + chalk.dim( + "\n These env vars are referenced in lobu.toml but are not set in the current environment." + ) + ); + lines.push( + chalk.dim( + " Set them locally (e.g. via .env) or via your deployment's secret manager and retry." + ) + ); + return lines.join("\n"); +} diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts new file mode 100644 index 000000000..2bf415760 --- /dev/null +++ b/packages/cli/src/commands/apply.ts @@ -0,0 +1,5 @@ +import { type ApplyOptions, applyCommand } from "./_lib/apply/apply-cmd.js"; + +export async function lobuApplyCommand(options: ApplyOptions): Promise { + await applyCommand(options); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 18ecebe21..ffe765d4f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -132,6 +132,53 @@ export async function runCli( if (!valid) process.exit(1); }); + // ─── apply ────────────────────────────────────────────────────────── + // One-way `lobu.toml` → cloud org converger. GETs current state, renders + // a diff, prompts to confirm, then loops over the existing CRUD endpoints + // in dependency order. Re-running converges on partial failure. + program + .command("apply") + .description( + "Sync lobu.toml + agent dirs to your Lobu Cloud org (idempotent)" + ) + .option("--dry-run", "Show the plan and exit without mutating") + .option("--yes", "Skip the confirmation prompt (CI mode)") + .option( + "--only ", + "Restrict to one resource family: 'agents' | 'memory'" + ) + .option("--org ", "Org slug override (defaults to active session)") + .option("--url ", "Server URL override") + .action( + async (options: { + dryRun?: boolean; + yes?: boolean; + only?: string; + org?: string; + url?: string; + }) => { + if ( + options.only !== undefined && + options.only !== "agents" && + options.only !== "memory" + ) { + console.error( + chalk.red("\n Error:"), + `--only must be 'agents' or 'memory' (got: ${options.only})` + ); + process.exit(2); + } + const { lobuApplyCommand } = await import("./commands/apply.js"); + await lobuApplyCommand({ + dryRun: options.dryRun, + yes: options.yes, + only: options.only as "agents" | "memory" | undefined, + org: options.org, + url: options.url, + }); + } + ); + // ─── run ──────────────────────────────────────────────────────────── // Boots the embedded Lobu stack (gateway + workers + memory backend) as // a single Node process. Extra args are forwarded to the bundle entry. diff --git a/packages/landing/src/content/docs/reference/lobu-apply.md b/packages/landing/src/content/docs/reference/lobu-apply.md new file mode 100644 index 000000000..632b2eba1 --- /dev/null +++ b/packages/landing/src/content/docs/reference/lobu-apply.md @@ -0,0 +1,107 @@ +--- +title: lobu apply CLI Reference +description: Sync your local lobu.toml + agent dirs to a Lobu Cloud org. One-way, idempotent, prompt-confirmed. +--- + +`lobu apply` reads `lobu.toml`, computes a diff against your cloud org, shows a plan, and — once you accept — calls existing CRUD endpoints in dependency order to converge the org to match your files. + +Mental model: `terraform apply` lite. Files are the source of truth; the cloud is a follower. + +## Surface + +```bash +lobu apply # plan + prompt + apply +lobu apply --dry-run # plan only +lobu apply --yes # plan + apply, no prompt (CI) +lobu apply --only agents # restrict to agent + connection resources +lobu apply --only memory # restrict to entity + relationship types +lobu apply --org my-org # override active org +``` + +Authentication is shared with the rest of the CLI. Run `lobu login` once. + +## What gets synced (v1) + +- Agents (metadata: `agentId`, `name`, `description`) +- Agent settings: `networkConfig`, `egressConfig`, `nixConfig`, `mcpServers`, `skillsConfig`, `toolsConfig`, `guardrails`, `preApprovedTools`, `providerModelPreferences`, `modelSelection`, `IDENTITY.md` / `SOUL.md` / `USER.md` +- Messaging connections under `[[agents..connections]]`, keyed by a stable ID derived from `(agentId, type, name?)` +- Memory entity types and relationship types (from `models/*.yaml` referenced by `[memory.owletto].models`) + +## What is not synced (v1) + +- Watchers — declare them in cloud or via `lobu memory seed` for now +- Memory data (entities, relationships, knowledge events) +- Secret values — `lobu apply` only checks that the env vars referenced as `$VAR` in `lobu.toml` are present locally, never uploads their values +- Anything not in the list above + +## Plan output + +Each row is one of four verbs: + +| Marker | Meaning | +| --- | --- | +| `+ create` | resource doesn't exist in the cloud — will be created | +| `~ update` | resource exists with different content — will be patched (changed fields shown) | +| `= noop` | resource exists and matches the desired state | +| `? drift` | cloud has a resource not declared in `lobu.toml` — **reported only**, never deleted in v1 | + +When a connection update will restart the live worker, the plan adds an inline warning line. + +## Apply order + +``` +required-secrets check + ↓ +upsertAgent (POST /api/:org/agents/) + ↓ +patchAgentSettings (PATCH /api/:org/agents/:id/config) + ↓ +upsertConnection (PUT /api/:org/agents/:id/connections/by-stable-id/:stableId) + ↓ +upsertEntityType (manage_entity_schema) + ↓ +upsertRelationshipType (manage_entity_schema) +``` + +If any call fails, the CLI prints partial progress and exits non-zero. Every endpoint is idempotent — re-running converges. + +## Required secrets + +Before any mutation, `lobu apply` walks `lobu.toml` for `$VAR` references in: + +- `[[agents..providers]]` — `key`, `secret_ref` +- `[[agents..connections]]` — every value in `[agents..connections.config]` +- `[agents..skills.mcp.]` — `headers`, `env`, `oauth.client_id`, `oauth.client_secret` + +Each name must be set in the apply runner's environment (e.g. via `.env` loaded by your shell). Any missing name short-circuits the apply with a list of every missing var. + +Secret values are never uploaded by `lobu apply`. Use your deployment's secret manager. + +## Drift + +Cloud-side resources not declared in `lobu.toml` are reported but never deleted. v1 has no `--prune`. To remove a cloud-side agent or connection, use the admin UI or the underlying CRUD endpoints directly; the next `lobu apply` will continue to surface it as drift until you remove it from the cloud or add it to your files. + +## Stable connection IDs + +Each connection's URL — including webhook URLs (`/api/v1/webhooks/`) — is derived from `(agentId, type, name)`: + +``` +{slugify(agentId)}-{slugify(type)}[-{slugify(name)}] +``` + +When you have more than one connection of the same `type` under the same agent, `name = "..."` is required. The same rule applies in `lobu run` (file-loader.ts) — both paths build identical stable IDs. + +## CI usage + +```bash +lobu login --token "$LOBU_API_TOKEN" +lobu apply --yes --org my-org +``` + +`--yes` skips the confirmation prompt. Without `--yes`, a non-TTY apply exits non-zero rather than hang waiting for input. + +## Related + +- Lobu CLI: [CLI Reference](/reference/cli/) +- Memory CLI: [Memory](/reference/lobu-memory/) +- `lobu.toml`: [Configuration Reference](/reference/lobu-toml/)