All files / src agent-integration.ts

100% Statements 104/104
100% Branches 75/75
100% Functions 17/17
100% Lines 91/91

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251              7x   7x 7x   7x 7x 7x 7x 7x 7x     237x         73x 73x         62x 62x 60x 60x 60x                           29x 29x 20x   29x 29x 29x 26x   29x               4x 4x 4x               20x 20x                     18x         15x 18x   13x 13x 13x 12x 12x 12x 12x   13x                             44x       35x 35x       5x         4x 4x 2x 2x           7x                                           30x 30x   30x         30x 29x 27x   27x     2x   25x 1x     24x 30x 24x     20x     30x 30x   2x       18x 30x       20x         16x 9x 9x 9x 8x 8x 6x 5x 5x   1x   4x         33x   13x 13x   2x     11x 11x       2x   9x 8x       25x      
import { App, DataAdapter } from "obsidian";
import { LilbeeClient } from "./api";
import { MESSAGES } from "./locales/en";
import { AGENT_CLIENT, type AgentClient } from "./types";
import { ok, err, Result } from "./result";
 
/** opencode reads this from the directory it is launched in, so it lives at the vault root. */
export const OPENCODE_CONFIG_PATH = "opencode.json";
/** Claude Code's project-scoped MCP registration, also read from the working directory. */
export const CLAUDE_MCP_CONFIG_PATH = ".mcp.json";
export const CLAUDIAN_PLUGIN_ID = "realclaudian";
/** The key lilbee owns inside every agent config container it merges into. */
const LILBEE_KEY = "lilbee";
const PROVIDER_KEY = "provider";
const MCP_KEY = "mcp";
const MCP_SERVERS_KEY = "mcpServers";
const MODEL_KEY = "model";
const SCHEMA_KEY = "$schema";
 
function isRecord(value: unknown): value is Record<string, unknown> {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
 
/** The existing container at *key*, or a fresh object when it is missing or malformed. */
function containerAt(source: Record<string, unknown>, key: string): Record<string, unknown> {
    const existing = source[key];
    return isRecord(existing) ? { ...existing } : {};
}
 
/** Replace only lilbee's entry inside `target[key]`, leaving the user's other entries alone. */
function adoptLilbeeEntry(target: Record<string, unknown>, fresh: Record<string, unknown>, key: string): void {
    const freshContainer = fresh[key];
    if (!isRecord(freshContainer) || !(LILBEE_KEY in freshContainer)) return;
    const merged = containerAt(target, key);
    merged[LILBEE_KEY] = freshContainer[LILBEE_KEY];
    target[key] = merged;
}
 
/**
 * Fold the server's opencode block into the user's `opencode.json`.
 *
 * lilbee owns `provider.lilbee` and `mcp.lilbee` and overwrites them outright —
 * they carry the port and token, which change every boot. Everything else in the
 * file survives, and `model` is only filled in when the user has not pinned one.
 */
export function mergeOpencodeConfig(
    existing: Record<string, unknown> | null,
    fresh: Record<string, unknown>,
): Record<string, unknown> {
    const merged: Record<string, unknown> = { ...(existing ?? {}) };
    if (merged[SCHEMA_KEY] === undefined && fresh[SCHEMA_KEY] !== undefined) {
        merged[SCHEMA_KEY] = fresh[SCHEMA_KEY];
    }
    adoptLilbeeEntry(merged, fresh, PROVIDER_KEY);
    adoptLilbeeEntry(merged, fresh, MCP_KEY);
    if (merged[MODEL_KEY] === undefined && fresh[MODEL_KEY] !== undefined) {
        merged[MODEL_KEY] = fresh[MODEL_KEY];
    }
    return merged;
}
 
/** Fold the server's `mcpServers.lilbee` block into the user's `.mcp.json`. */
export function mergeClaudeConfig(
    existing: Record<string, unknown> | null,
    fresh: Record<string, unknown>,
): Record<string, unknown> {
    const merged: Record<string, unknown> = { ...(existing ?? {}) };
    adoptLilbeeEntry(merged, fresh, MCP_SERVERS_KEY);
    return merged;
}
 
/**
 * The `provider/model-id` string opencode starts on, which is also the key
 * Claudian lists in `visibleModels`. Null when the server pinned no default.
 */
export function agentModelKey(config: Record<string, unknown>): string | null {
    const model = config[MODEL_KEY];
    return typeof model === "string" && model !== "" ? model : null;
}
 
/**
 * Turn on Claudian's opencode provider without disturbing the rest of its data.
 *
 * Returns null when the file is not the shape this expects — Claudian owns that
 * file and a blind write would corrupt its settings, so the caller points the
 * user at Claudian's own settings instead.
 */
export function mergeClaudianData(existing: unknown, modelKey: string | null): Record<string, unknown> | null {
    if (!isRecord(existing)) return null;
    // Claudian prunes default-valued settings from disk, so a virgin install
    // has no providerConfigs key at all. Absent means "defaults", which is a
    // shape this merge understands; refuse only a key that exists with a
    // shape it does not.
    const providerConfigs = existing.providerConfigs === undefined ? {} : existing.providerConfigs;
    if (!isRecord(providerConfigs)) return null;
 
    const opencode = containerAt(providerConfigs, AGENT_CLIENT.OPENCODE);
    opencode.enabled = true;
    if (modelKey !== null) {
        const visible = opencode.visibleModels;
        const models = Array.isArray(visible) ? [...(visible as unknown[])] : [];
        if (!models.includes(modelKey)) models.push(modelKey);
        opencode.visibleModels = models;
    }
    return {
        ...existing,
        providerConfigs: { ...providerConfigs, [AGENT_CLIENT.OPENCODE]: opencode },
    };
}
 
/** Obsidian's undocumented-but-stable plugin registry, used to detect and reload Claudian. */
interface PluginsApi {
    manifests?: Record<string, unknown>;
    enabledPlugins?: Set<string>;
    disablePlugin?: (id: string) => Promise<void>;
    enablePlugin?: (id: string) => Promise<void>;
}
 
function pluginsApi(app: App): PluginsApi | null {
    return (app as unknown as { plugins?: PluginsApi }).plugins ?? null;
}
 
export function isClaudianInstalled(app: App): boolean {
    const manifests = pluginsApi(app)?.manifests;
    return isRecord(manifests) && CLAUDIAN_PLUGIN_ID in manifests;
}
 
export function isClaudianLoaded(app: App): boolean {
    return pluginsApi(app)?.enabledPlugins?.has(CLAUDIAN_PLUGIN_ID) === true;
}
 
/** Toggle Claudian off and on so it re-reads the data file lilbee just changed. */
export async function reloadClaudian(app: App): Promise<void> {
    const plugins = pluginsApi(app);
    if (!plugins?.disablePlugin || !plugins.enablePlugin) return;
    await plugins.disablePlugin(CLAUDIAN_PLUGIN_ID);
    await plugins.enablePlugin(CLAUDIAN_PLUGIN_ID);
}
 
/** What happened to Claudian's data file during a wiring pass. */
export type ClaudianOutcome = "absent" | "written" | "unchanged" | "skipped";
 
export const CLAUDIAN_OUTCOME = {
    /** Claudian is not installed, so there was nothing to bridge. */
    ABSENT: "absent",
    WRITTEN: "written",
    UNCHANGED: "unchanged",
    /** Installed, but its data file is not the shape lilbee knows how to edit. */
    SKIPPED: "skipped",
} as const satisfies Record<string, ClaudianOutcome>;
 
export interface AgentWireOutcome {
    client: AgentClient;
    /** Vault-relative file written, or null for a client whose config is global. */
    path: string | null;
    claudian: ClaudianOutcome;
    writtenAt: number;
}
 
/** Writes the server's agent config into the vault, merging rather than replacing. */
export class AgentWiring {
    private adapter: DataAdapter;
 
    constructor(
        private app: App,
        private api: LilbeeClient,
    ) {
        this.adapter = app.vault.adapter;
    }
 
    /** Fetch *client*'s config and write it where that client looks for it. */
    async apply(client: AgentClient): Promise<Result<AgentWireOutcome, Error>> {
        const fetched = await this.api.getAgentConfig(client);
        if (fetched.isErr()) return err(fetched.error);
        const config = fetched.value.config;
 
        if (client === AGENT_CLIENT.HERMES) {
            // hermes reads a single global config.yaml, so there is no vault file
            // to own; the settings row offers the block to copy instead.
            return ok(this.outcome(client, null, CLAUDIAN_OUTCOME.ABSENT));
        }
        if (!isRecord(config)) {
            return err(new Error(MESSAGES.ERROR_AGENT_CONFIG_EMPTY(client)));
        }
 
        const path = client === AGENT_CLIENT.OPENCODE ? OPENCODE_CONFIG_PATH : CLAUDE_MCP_CONFIG_PATH;
        const existing = await this.readJson(path);
        if (existing.isErr()) return err(existing.error);
 
        const merged =
            client === AGENT_CLIENT.OPENCODE
                ? mergeOpencodeConfig(existing.value, config)
                : mergeClaudeConfig(existing.value, config);
        try {
            await this.writeJson(path, merged);
        } catch (e) {
            return err(e instanceof Error ? e : new Error(String(e)));
        }
 
        const claudian =
            client === AGENT_CLIENT.OPENCODE ? await this.syncClaudian(agentModelKey(config)) : CLAUDIAN_OUTCOME.ABSENT;
        return ok(this.outcome(client, path, claudian));
    }
 
    private outcome(client: AgentClient, path: string | null, claudian: ClaudianOutcome): AgentWireOutcome {
        return { client, path, claudian, writtenAt: Date.now() };
    }
 
    /** Point Claudian's opencode provider at lilbee's models. Never fails the wiring. */
    private async syncClaudian(modelKey: string | null): Promise<ClaudianOutcome> {
        if (!isClaudianInstalled(this.app)) return CLAUDIAN_OUTCOME.ABSENT;
        const path = `${this.app.vault.configDir}/plugins/${CLAUDIAN_PLUGIN_ID}/data.json`;
        const existing = await this.readJson(path);
        if (existing.isErr()) return CLAUDIAN_OUTCOME.SKIPPED;
        const merged = mergeClaudianData(existing.value, modelKey);
        if (merged === null) return CLAUDIAN_OUTCOME.SKIPPED;
        if (JSON.stringify(merged) === JSON.stringify(existing.value)) return CLAUDIAN_OUTCOME.UNCHANGED;
        try {
            await this.writeJson(path, merged);
        } catch {
            return CLAUDIAN_OUTCOME.SKIPPED;
        }
        return CLAUDIAN_OUTCOME.WRITTEN;
    }
 
    /** Null when the file is absent; an error when it exists but cannot be parsed. */
    private async readJson(path: string): Promise<Result<Record<string, unknown> | null, Error>> {
        if (!(await this.adapter.exists(path))) return ok(null);
        let raw: string;
        try {
            raw = await this.adapter.read(path);
        } catch (e) {
            return err(e instanceof Error ? e : new Error(String(e)));
        }
        let parsed: unknown;
        try {
            parsed = JSON.parse(raw);
        } catch {
            // Overwriting a file the user hand-edited into a broken state would
            // destroy work; refuse and let them fix it.
            return err(new Error(MESSAGES.ERROR_AGENT_CONFIG_UNREADABLE(path)));
        }
        if (!isRecord(parsed)) return err(new Error(MESSAGES.ERROR_AGENT_CONFIG_UNREADABLE(path)));
        return ok(parsed);
    }
 
    private async writeJson(path: string, value: Record<string, unknown>): Promise<void> {
        await this.adapter.write(path, `${JSON.stringify(value, null, 2)}\n`);
    }
}