← octto
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { AgentConfig } from "@opencode-ai/sdk";
import * as v from "valibot";
import { AGENTS, type AgentName, isAgentName } from "@/agents";
import { AgentOverrideSchema, type Fragments, MAX_PORT, type OcttoConfig, OcttoConfigSchema } from "./schema";
export type { AgentOverride, Fragments, OcttoConfig } from "./schema";
export interface CustomConfig {
agents: Record<AgentName, AgentConfig>;
port: number;
fragments: Fragments;
}
const OCTTO_PORT_ENV = "OCTTO_PORT";
const DEFAULT_PORT = 0;
/**
* Resolve port from environment variable or config.
* Priority: OCTTO_PORT env var > config port > default (0 = random)
*/
export function resolvePort(configPort?: number): number {
const envValue = process.env[OCTTO_PORT_ENV];
if (envValue !== undefined) {
const parsed = Number(envValue);
if (Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_PORT) {
return parsed;
}
}
return configPort ?? DEFAULT_PORT;
}
const VALID_AGENT_NAMES = Object.values(AGENTS);
function formatValidationErrors(issues: v.BaseIssue<unknown>[]): string {
return issues
.map((issue) => {
const path = issue.path?.map((p) => p.key).join(".") ?? "root";
return ` - ${path}: ${issue.message}`;
})
.join("\n");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function salvageModel(parsed: Record<string, unknown>): string | undefined {
if ("model" in parsed && typeof parsed.model === "string" && parsed.model.length > 0) {
return parsed.model;
}
return undefined;
}
function salvageAgentOverrides(parsed: Record<string, unknown>): OcttoConfig["agents"] | undefined {
if (!("agents" in parsed) || !isRecord(parsed.agents)) {
return undefined;
}
const validAgents: OcttoConfig["agents"] = {};
let hasValidAgent = false;
for (const [name, override] of Object.entries(parsed.agents)) {
if (!isAgentName(name)) {
console.warn(`[octto] Unknown agent "${name}" - valid names: ${VALID_AGENT_NAMES.join(", ")}`);
continue;
}
const agentResult = v.safeParse(AgentOverrideSchema, override);
if (agentResult.success) {
validAgents[name] = agentResult.output;
hasValidAgent = true;
} else {
console.warn(`[octto] Invalid config for agent "${name}":`);
console.warn(formatValidationErrors(agentResult.issues));
}
}
return hasValidAgent ? validAgents : undefined;
}
function salvageValidConfig(parsed: Record<string, unknown>): OcttoConfig | null {
const model = salvageModel(parsed);
const agents = salvageAgentOverrides(parsed);
if (model || agents) {
console.warn("[octto] Partial config loaded - some overrides applied despite errors");
return { ...(model ? { model } : {}), ...(agents ? { agents } : {}) };
}
console.warn("[octto] No valid overrides found in config, using defaults");
return null;
}
async function load(configDir?: string): Promise<OcttoConfig | null> {
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
const configPath = join(baseDir, "octto.json");
let parsed: unknown;
try {
const content = await readFile(configPath, "utf-8");
parsed = JSON.parse(content);
} catch (_error: unknown) {
/* config file not found or unreadable */
return null;
}
const configValidation = v.safeParse(OcttoConfigSchema, parsed);
if (configValidation.success) {
return configValidation.output;
}
console.warn(`[octto] Config validation errors in ${configPath}:`);
console.warn(formatValidationErrors(configValidation.issues));
if (!isRecord(parsed)) {
console.warn("[octto] No valid agents found in config, using defaults");
return null;
}
return salvageValidConfig(parsed);
}
/**
* Load user configuration and merge with plugin agents.
* Returns merged agent configs with user overrides applied, and resolved port.
*
* Merge order (later wins):
* 1. Built-in agent defaults
* 2. Top-level `model` (applied to every agent)
* 3. Per-agent `agents.<name>` overrides
*/
export async function loadCustomConfig(
agents: Record<AgentName, AgentConfig>,
configDir?: string,
): Promise<CustomConfig> {
const config = await load(configDir);
// Copy each agent, not just the outer record: callers prefix prompts in place,
// and a shallow copy would mutate the shared built-in definitions.
const mergedAgents = { ...agents };
for (const name of Object.values(AGENTS)) {
mergedAgents[name] = { ...mergedAgents[name] };
}
// Apply top-level model to all agents first
if (config?.model) {
for (const name of Object.values(AGENTS)) {
mergedAgents[name] = { ...mergedAgents[name], model: config.model };
}
}
// Then apply per-agent overrides (these take precedence)
for (const [name, override] of Object.entries(config?.agents ?? {})) {
if (!isAgentName(name)) continue;
mergedAgents[name] = { ...mergedAgents[name], ...override };
}
return {
agents: mergedAgents,
port: resolvePort(config?.port),
fragments: config?.fragments,
};
}