Skip to content
Closed
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
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/lobu-toml-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand All @@ -226,3 +244,5 @@ export type WorkerEntry = z.infer<typeof workerSchema>;
export type ScheduleEntry = z.infer<typeof scheduleSchema>;
export type OwlettoMemoryEntry = z.infer<typeof owlettoMemorySchema>;
export type MemoryEntry = z.infer<typeof memorySchema>;
export type OwlettoProfileEntry = z.infer<typeof owlettoProfileSchema>;
export type OwlettoEntry = z.infer<typeof owlettoSchema>;
2 changes: 2 additions & 0 deletions packages/owletto-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
80 changes: 60 additions & 20 deletions packages/owletto-cli/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
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';

export interface ProfileConfig {
url?: string;
apiUrl?: string;
mcpUrl?: string;
databaseUrl?: string;
embeddingsUrl?: string;
envFile?: string;
[key: string]: unknown;
}

interface OwlettoConfig {
profiles: Record<string, ProfileConfig>;
}

export interface ResolvedProfile {
name: string;
config: ProfileConfig;
Expand All @@ -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];
Expand All @@ -35,32 +37,57 @@ 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<string, unknown>): ProfileConfig {
const keyMap: Record<string, string> = {
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;
}

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;
dir = parent;
}
}

function loadConfig(configPath: string): OwlettoConfig {
function loadProfiles(configPath: string): Record<string, Record<string, unknown>> {
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<string, unknown>;
try {
parsed = parseToml(raw) as Record<string, unknown>;
} 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept profile-only lobu.toml files

Validating lobu.toml with lobuConfigSchema.safeParse(parsed) requires the top-level agents table, so owletto now fails on files that only define [owletto.profiles.*] (or have unrelated agent-schema issues) even when the selected profile itself is valid. This is a regression from the previous profile-only config behavior and breaks CLI profile resolution for users outside a full Lobu agent workspace.

Useful? React with 👍 / 👎.

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<string, Record<string, unknown>>;
}

export function resolveProfile(
Expand All @@ -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) {
Expand All @@ -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,
};
}
Loading