diff --git a/bun.lock b/bun.lock index b388ec3f4..452000d07 100644 --- a/bun.lock +++ b/bun.lock @@ -177,6 +177,7 @@ "@electric-sql/pglite-socket": "^0.1.3", "@hono/node-server": "^1.13.7", "@jitl/quickjs-ng-wasmfile-release-asyncify": "0.31.0", + "@lobu/core": "workspace:*", "@lobu/owletto-sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.27.1", "@polyglot-sql/sdk": "^0.1.13", @@ -202,6 +203,7 @@ "playwright": "npm:patchright@^1.57.0", "postgres": "^3.4.7", "resend": "^6.6.0", + "smol-toml": "^1.3.1", "tsx": "^4.19.2", "turndown": "^7.2.2", "vite": "^6.0.0", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35841c9c8..76438c985 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,7 +56,9 @@ export { type McpServerEntry as TomlMcpServerEntry, type MemoryEntry as TomlMemoryEntry, type NetworkEntry as TomlNetworkEntry, + type OwlettoEntry as TomlOwlettoEntry, type OwlettoMemoryEntry as TomlOwlettoMemoryEntry, + type OwlettoProfileEntry as TomlOwlettoProfileEntry, type ProviderEntry as TomlProviderEntry, type ScheduleEntry as TomlScheduleEntry, type SkillsEntry as TomlSkillsEntry, diff --git a/packages/core/src/lobu-toml-schema.ts b/packages/core/src/lobu-toml-schema.ts index 4523af6f4..0ab05bfa4 100644 --- a/packages/core/src/lobu-toml-schema.ts +++ b/packages/core/src/lobu-toml-schema.ts @@ -205,11 +205,29 @@ const memorySchema = z.object({ owletto: owlettoMemorySchema.optional(), }); +// ── Owletto CLI ──────────────────────────────────────────────────────────── + +const owlettoProfileSchema = z + .object({ + url: z.string().optional(), + api_url: z.string().optional(), + mcp_url: z.string().optional(), + database_url: z.string().optional(), + embeddings_url: z.string().optional(), + env_file: z.string().optional(), + }) + .passthrough(); + +const owlettoSchema = z.object({ + profiles: z.record(z.string(), owlettoProfileSchema).optional(), +}); + // ── Top Level ─────────────────────────────────────────────────────────────── export const lobuConfigSchema = z.object({ agents: z.record(z.string().regex(/^[a-z0-9][a-z0-9-]*$/), agentEntrySchema), memory: memorySchema.optional(), + owletto: owlettoSchema.optional(), }); // ── Inferred Types ────────────────────────────────────────────────────────── @@ -226,3 +244,5 @@ export type WorkerEntry = z.infer; export type ScheduleEntry = z.infer; export type OwlettoMemoryEntry = z.infer; export type MemoryEntry = z.infer; +export type OwlettoProfileEntry = z.infer; +export type OwlettoEntry = z.infer; diff --git a/packages/owletto-cli/package.json b/packages/owletto-cli/package.json index 09d6e2e16..453676663 100644 --- a/packages/owletto-cli/package.json +++ b/packages/owletto-cli/package.json @@ -26,6 +26,7 @@ "@electric-sql/pglite-socket": "^0.1.3", "@hono/node-server": "^1.13.7", "@jitl/quickjs-ng-wasmfile-release-asyncify": "0.31.0", + "@lobu/core": "workspace:*", "@lobu/owletto-sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.27.1", "@polyglot-sql/sdk": "^0.1.13", @@ -51,6 +52,7 @@ "playwright": "npm:patchright@^1.57.0", "postgres": "^3.4.7", "resend": "^6.6.0", + "smol-toml": "^1.3.1", "tsx": "^4.19.2", "turndown": "^7.2.2", "vite": "^6.0.0", diff --git a/packages/owletto-cli/src/lib/config.ts b/packages/owletto-cli/src/lib/config.ts index 5989f02cf..effb62c3a 100644 --- a/packages/owletto-cli/src/lib/config.ts +++ b/packages/owletto-cli/src/lib/config.ts @@ -1,5 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; +import { lobuConfigSchema } from '@lobu/core'; +import { parse as parseToml } from 'smol-toml'; import { ValidationError } from './errors.ts'; import { getActiveSession } from './openclaw-auth.ts'; @@ -7,14 +9,12 @@ export interface ProfileConfig { url?: string; apiUrl?: string; mcpUrl?: string; + databaseUrl?: string; + embeddingsUrl?: string; envFile?: string; [key: string]: unknown; } -interface OwlettoConfig { - profiles: Record; -} - export interface ResolvedProfile { name: string; config: ProfileConfig; @@ -25,6 +25,8 @@ const DEFAULT_PROFILE: ProfileConfig = { url: 'http://localhost:8787/mcp', }; +const CONFIG_FILENAME = 'lobu.toml'; + function interpolateEnv(value: string): string { return value.replace(/\$\{([^}]+)\}/g, (_, key) => { const envVal = process.env[key]; @@ -35,10 +37,23 @@ function interpolateEnv(value: string): string { }); } -function interpolateProfile(profile: ProfileConfig): ProfileConfig { +/** + * Map a raw TOML profile (snake_case keys) into the CLI's camelCase + * ProfileConfig, interpolating `${ENV_VAR}` references in string values. + */ +function normalizeProfile(raw: Record): ProfileConfig { + const keyMap: Record = { + url: 'url', + api_url: 'apiUrl', + mcp_url: 'mcpUrl', + database_url: 'databaseUrl', + embeddings_url: 'embeddingsUrl', + env_file: 'envFile', + }; const result: ProfileConfig = {}; - for (const [key, value] of Object.entries(profile)) { - result[key] = typeof value === 'string' ? interpolateEnv(value) : value; + for (const [key, value] of Object.entries(raw)) { + const mapped = keyMap[key] ?? key; + result[mapped] = typeof value === 'string' ? interpolateEnv(value) : value; } return result; } @@ -46,7 +61,7 @@ function interpolateProfile(profile: ProfileConfig): ProfileConfig { export function findConfigFile(startDir: string = process.cwd()): string | null { let dir = resolve(startDir); while (true) { - const candidate = resolve(dir, 'owletto.config.json'); + const candidate = resolve(dir, CONFIG_FILENAME); if (existsSync(candidate)) return candidate; const parent = dirname(dir); if (parent === dir) return null; @@ -54,13 +69,25 @@ export function findConfigFile(startDir: string = process.cwd()): string | null } } -function loadConfig(configPath: string): OwlettoConfig { +function loadProfiles(configPath: string): Record> { const raw = readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as OwlettoConfig; - if (!parsed.profiles || typeof parsed.profiles !== 'object') { - throw new ValidationError(`Invalid config: "profiles" object required in ${configPath}`); + let parsed: Record; + try { + parsed = parseToml(raw) as Record; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new ValidationError(`Invalid TOML syntax in ${configPath}: ${msg}`); + } + + const result = lobuConfigSchema.safeParse(parsed); + if (!result.success) { + const details = result.error.issues + .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + throw new ValidationError(`Invalid lobu.toml schema in ${configPath}:\n${details}`); } - return parsed; + + return (result.data.owletto?.profiles ?? {}) as Record>; } export function resolveProfile( @@ -75,7 +102,6 @@ export function resolveProfile( // 3. contextName (from project-local or global context file) // 4. First profile in config // 5. Built-in defaults (or active session) - const requestedName = profileFlag ?? process.env.OWLETTO_PROFILE ?? contextName ?? null; if (!configPath) { @@ -94,23 +120,37 @@ export function resolveProfile( }; } - const config = loadConfig(configPath); - const profileNames = Object.keys(config.profiles); + const profiles = loadProfiles(configPath); + const profileNames = Object.keys(profiles); if (profileNames.length === 0) { - throw new ValidationError(`No profiles defined in ${configPath}`); + const { session } = getActiveSession(); + if (session?.mcpUrl) { + return { + name: requestedName ?? 'default', + config: { url: session.mcpUrl }, + configPath, + }; + } + return { + name: requestedName ?? 'default', + config: DEFAULT_PROFILE, + configPath, + }; } const name = requestedName ?? profileNames[0]!; - const raw = config.profiles[name]; + const raw = profiles[name]; if (!raw) { - throw new ValidationError(`Profile "${name}" not found. Available: ${profileNames.join(', ')}`); + throw new ValidationError( + `Profile "${name}" not found in [owletto.profiles] of ${configPath}. Available: ${profileNames.join(', ')}` + ); } return { name, - config: interpolateProfile(raw), + config: normalizeProfile(raw), configPath, }; }