refactor: enforce modular code rules — split 25+ files, rename catch-all modules, SRP compliance

refactor: enforce modular code architecture (waves 1-2)
This commit is contained in:
YeonGyu-Kim
2026-02-09 13:39:36 +09:00
committed by GitHub
566 changed files with 28563 additions and 20347 deletions

View File

@@ -61,7 +61,7 @@ agents/
## HOW TO ADD ## HOW TO ADD
1. Create `src/agents/my-agent.ts` exporting factory + metadata. 1. Create `src/agents/my-agent.ts` exporting factory + metadata.
2. Add to `agentSources` in `src/agents/utils.ts`. 2. Add to `agentSources` in `src/agents/builtin-agents.ts`.
3. Update `AgentNameSchema` in `src/config/schema.ts`. 3. Update `AgentNameSchema` in `src/config/schema.ts`.
4. Register in `src/index.ts` initialization. 4. Register in `src/index.ts` initialization.

View File

@@ -0,0 +1,52 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentFactory } from "./types"
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
import type { BrowserAutomationProvider } from "../config/schema"
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
export type AgentSource = AgentFactory | AgentConfig
export function isFactory(source: AgentSource): source is AgentFactory {
return typeof source === "function"
}
export function buildAgent(
source: AgentSource,
model: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
browserProvider?: BrowserAutomationProvider,
disabledSkills?: Set<string>
): AgentConfig {
const base = isFactory(source) ? source(model) : { ...source }
const categoryConfigs: Record<string, CategoryConfig> = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
if (agentWithCategory.category) {
const categoryConfig = categoryConfigs[agentWithCategory.category]
if (categoryConfig) {
if (!base.model) {
base.model = categoryConfig.model
}
if (base.temperature === undefined && categoryConfig.temperature !== undefined) {
base.temperature = categoryConfig.temperature
}
if (base.variant === undefined && categoryConfig.variant !== undefined) {
base.variant = categoryConfig.variant
}
}
}
if (agentWithCategory.skills?.length) {
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
if (resolved.size > 0) {
const skillContent = Array.from(resolved.values()).join("\n\n")
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
}
}
return base
}

142
src/agents/atlas/agent.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* Atlas - Master Orchestrator Agent
*
* Orchestrates work via task() to complete ALL tasks in a todo list until fully done.
* You are the conductor of a symphony of specialized agents.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt"
import {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./prompt-section-builder"
const MODE: AgentMode = "primary"
export type AtlasPromptSource = "default" | "gpt"
/**
* Determines which Atlas prompt to use based on model.
*/
export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
export interface OrchestratorContext {
model?: string
availableAgents?: AvailableAgent[]
availableSkills?: AvailableSkill[]
userCategories?: Record<string, CategoryConfig>
}
/**
* Gets the appropriate Atlas prompt based on model.
*/
export function getAtlasPrompt(model?: string): string {
const source = getAtlasPromptSource(model)
switch (source) {
case "gpt":
return getGptAtlasPrompt()
case "default":
default:
return getDefaultAtlasPrompt()
}
}
function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
const agents = ctx?.availableAgents ?? []
const skills = ctx?.availableSkills ?? []
const userCategories = ctx?.userCategories
const model = ctx?.model
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
name,
description: getCategoryDescription(name, userCategories),
}))
const categorySection = buildCategorySection(userCategories)
const agentSection = buildAgentSelectionSection(agents)
const decisionMatrix = buildDecisionMatrix(agents, userCategories)
const skillsSection = buildSkillsSection(skills)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
const basePrompt = getAtlasPrompt(model)
return basePrompt
.replace("{CATEGORY_SECTION}", categorySection)
.replace("{AGENT_SECTION}", agentSection)
.replace("{DECISION_MATRIX}", decisionMatrix)
.replace("{SKILLS_SECTION}", skillsSection)
.replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide)
}
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
])
const baseConfig = {
description:
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
mode: MODE,
...(ctx.model ? { model: ctx.model } : {}),
temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx),
color: "#10B981",
...restrictions,
}
return baseConfig as AgentConfig
}
createAtlasAgent.mode = MODE
export const atlasPromptMetadata: AgentPromptMetadata = {
category: "advisor",
cost: "EXPENSIVE",
promptAlias: "Atlas",
triggers: [
{
domain: "Todo list orchestration",
trigger: "Complete ALL tasks in a todo list with verification",
},
{
domain: "Multi-agent coordination",
trigger: "Parallel task execution across specialized agents",
},
],
useWhen: [
"User provides a todo list path (.sisyphus/plans/{name}.md)",
"Multiple tasks need to be completed in sequence or parallel",
"Work requires coordination across multiple specialized agents",
],
avoidWhen: [
"Single simple task that doesn't require orchestration",
"Tasks that can be handled directly by one agent",
"When user wants to execute tasks manually",
],
keyTrigger:
"Todo list path provided OR multiple tasks requiring multi-agent orchestration",
}

View File

@@ -1,33 +1,3 @@
/**
* Atlas - Master Orchestrator Agent
*
* Orchestrates work via task() to complete ALL tasks in a todo list until fully done.
* You are the conductor of a symphony of specialized agents.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
import { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
import {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./utils"
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default" export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt" export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
export { export {
@@ -36,118 +6,9 @@ export {
buildCategorySection, buildCategorySection,
buildSkillsSection, buildSkillsSection,
buildDecisionMatrix, buildDecisionMatrix,
} from "./utils" } from "./prompt-section-builder"
export { isGptModel }
const MODE: AgentMode = "primary" export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
export type AtlasPromptSource = "default" | "gpt" export { isGptModel } from "../types"
/**
* Determines which Atlas prompt to use based on model.
*/
export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
export interface OrchestratorContext {
model?: string
availableAgents?: AvailableAgent[]
availableSkills?: AvailableSkill[]
userCategories?: Record<string, CategoryConfig>
}
/**
* Gets the appropriate Atlas prompt based on model.
*/
export function getAtlasPrompt(model?: string): string {
const source = getAtlasPromptSource(model)
switch (source) {
case "gpt":
return getGptAtlasPrompt()
case "default":
default:
return getDefaultAtlasPrompt()
}
}
function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
const agents = ctx?.availableAgents ?? []
const skills = ctx?.availableSkills ?? []
const userCategories = ctx?.userCategories
const model = ctx?.model
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
name,
description: getCategoryDescription(name, userCategories),
}))
const categorySection = buildCategorySection(userCategories)
const agentSection = buildAgentSelectionSection(agents)
const decisionMatrix = buildDecisionMatrix(agents, userCategories)
const skillsSection = buildSkillsSection(skills)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
const basePrompt = getAtlasPrompt(model)
return basePrompt
.replace("{CATEGORY_SECTION}", categorySection)
.replace("{AGENT_SECTION}", agentSection)
.replace("{DECISION_MATRIX}", decisionMatrix)
.replace("{SKILLS_SECTION}", skillsSection)
.replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide)
}
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
])
const baseConfig = {
description:
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
mode: MODE,
...(ctx.model ? { model: ctx.model } : {}),
temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx),
color: "#10B981",
...restrictions,
}
return baseConfig as AgentConfig
}
createAtlasAgent.mode = MODE
export const atlasPromptMetadata: AgentPromptMetadata = {
category: "advisor",
cost: "EXPENSIVE",
promptAlias: "Atlas",
triggers: [
{
domain: "Todo list orchestration",
trigger: "Complete ALL tasks in a todo list with verification",
},
{
domain: "Multi-agent coordination",
trigger: "Parallel task execution across specialized agents",
},
],
useWhen: [
"User provides a todo list path (.sisyphus/plans/{name}.md)",
"Multiple tasks need to be completed in sequence or parallel",
"Work requires coordination across multiple specialized agents",
],
avoidWhen: [
"Single simple task that doesn't require orchestration",
"Tasks that can be handled directly by one agent",
"When user wants to execute tasks manually",
],
keyTrigger:
"Todo list path provided OR multiple tasks requiring multi-agent orchestration",
}

View File

@@ -0,0 +1,179 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
import type { CategoriesConfig, GitMasterConfig } from "../config/schema"
import type { LoadedSkill } from "../features/opencode-skill-loader/types"
import type { BrowserAutomationProvider } from "../config/schema"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import { fetchAvailableModels, readConnectedProvidersCache } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { buildAvailableSkills } from "./builtin-agents/available-skills"
import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents"
import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"
import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent"
import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent"
import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries"
type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
sisyphus: createSisyphusAgent,
hephaestus: createHephaestusAgent,
oracle: createOracleAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
"multimodal-looker": createMultimodalLookerAgent,
metis: createMetisAgent,
momus: createMomusAgent,
// Note: Atlas is handled specially in createBuiltinAgents()
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as AgentFactory,
}
/**
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
* (Delegation Table, Tool Selection, Key Triggers, etc.)
*/
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
oracle: ORACLE_PROMPT_METADATA,
librarian: LIBRARIAN_PROMPT_METADATA,
explore: EXPLORE_PROMPT_METADATA,
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
metis: metisPromptMetadata,
momus: momusPromptMetadata,
atlas: atlasPromptMetadata,
}
export async function createBuiltinAgents(
disabledAgents: string[] = [],
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
customAgentSummaries?: unknown,
browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string,
disabledSkills?: Set<string>
): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache()
// IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization.
// This function is called from config handler, and calling client API causes deadlock.
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
const availableModels = await fetchAvailableModels(undefined, {
connectedProviders: connectedProviders ?? undefined,
})
const isFirstRunNoCache =
availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0)
const result: Record<string, AgentConfig> = {}
const mergedCategories = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
name,
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
}))
const availableSkills = buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills)
// Collect general agents first (for availableAgents), but don't add to result yet
const { pendingAgentConfigs, availableAgents } = collectPendingBuiltinAgents({
agentSources,
agentMetadata,
disabledAgents,
agentOverrides,
directory,
systemDefaultModel,
mergedCategories,
gitMasterConfig,
browserProvider,
uiSelectedModel,
availableModels,
disabledSkills,
})
const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries)
const builtinAgentNames = new Set(Object.keys(agentSources).map((name) => name.toLowerCase()))
const disabledAgentNames = new Set(disabledAgents.map((name) => name.toLowerCase()))
for (const agent of registeredAgents) {
const lowerName = agent.name.toLowerCase()
if (builtinAgentNames.has(lowerName)) continue
if (disabledAgentNames.has(lowerName)) continue
if (availableAgents.some((availableAgent) => availableAgent.name.toLowerCase() === lowerName)) continue
availableAgents.push({
name: agent.name,
description: agent.description,
metadata: buildCustomAgentMetadata(agent.name, agent.description),
})
}
const sisyphusConfig = maybeCreateSisyphusConfig({
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
userCategories: categories,
})
if (sisyphusConfig) {
result["sisyphus"] = sisyphusConfig
}
const hephaestusConfig = maybeCreateHephaestusConfig({
disabledAgents,
agentOverrides,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
})
if (hephaestusConfig) {
result["hephaestus"] = hephaestusConfig
}
// Add pending agents after sisyphus and hephaestus to maintain order
for (const [name, config] of pendingAgentConfigs) {
result[name] = config
}
const atlasConfig = maybeCreateAtlasConfig({
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
availableAgents,
availableSkills,
mergedCategories,
userCategories: categories,
})
if (atlasConfig) {
result["atlas"] = atlasConfig
}
return result
}

View File

@@ -0,0 +1,65 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrideConfig } from "../types"
import type { CategoryConfig } from "../../config/schema"
import { deepMerge, migrateAgentConfig } from "../../shared"
/**
* Expands a category reference from an agent override into concrete config properties.
* Category properties are applied unconditionally (overwriting factory defaults),
* because the user's chosen category should take priority over factory base values.
* Direct override properties applied later via mergeAgentConfig() will supersede these.
*/
export function applyCategoryOverride(
config: AgentConfig,
categoryName: string,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
const categoryConfig = mergedCategories[categoryName]
if (!categoryConfig) return config
const result = { ...config } as AgentConfig & Record<string, unknown>
if (categoryConfig.model) result.model = categoryConfig.model
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
if (categoryConfig.prompt_append && typeof result.prompt === "string") {
result.prompt = result.prompt + "\n" + categoryConfig.prompt_append
}
return result as AgentConfig
}
export function mergeAgentConfig(base: AgentConfig, override: AgentOverrideConfig): AgentConfig {
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
}
return merged
}
export function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
result = applyCategoryOverride(result, overrideCategory, mergedCategories)
}
if (override) {
result = mergeAgentConfig(result, override)
}
return result
}

View File

@@ -0,0 +1,63 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrides } from "../types"
import type { CategoriesConfig, CategoryConfig } from "../../config/schema"
import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared"
import { applyOverrides } from "./agent-overrides"
import { applyModelResolution } from "./model-resolution"
import { createAtlasAgent } from "../atlas"
export function maybeCreateAtlasConfig(input: {
disabledAgents: string[]
agentOverrides: AgentOverrides
uiSelectedModel?: string
availableModels: Set<string>
systemDefaultModel?: string
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
mergedCategories: Record<string, CategoryConfig>
userCategories?: CategoriesConfig
}): AgentConfig | undefined {
const {
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
availableAgents,
availableSkills,
mergedCategories,
userCategories,
} = input
if (disabledAgents.includes("atlas")) return undefined
const orchestratorOverride = agentOverrides["atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
const atlasResolution = applyModelResolution({
uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel,
userModel: orchestratorOverride?.model,
requirement: atlasRequirement,
availableModels,
systemDefaultModel,
})
if (!atlasResolution) return undefined
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
let orchestratorConfig = createAtlasAgent({
model: atlasModel,
availableAgents,
availableSkills,
userCategories,
})
if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
return orchestratorConfig
}

View File

@@ -0,0 +1,35 @@
import type { AvailableSkill } from "../dynamic-agent-prompt-builder"
import type { BrowserAutomationProvider } from "../../config/schema"
import type { LoadedSkill, SkillScope } from "../../features/opencode-skill-loader/types"
import { createBuiltinSkills } from "../../features/builtin-skills"
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
if (scope === "user" || scope === "opencode") return "user"
if (scope === "project" || scope === "opencode-project") return "project"
return "plugin"
}
export function buildAvailableSkills(
discoveredSkills: LoadedSkill[],
browserProvider?: BrowserAutomationProvider,
disabledSkills?: Set<string>
): AvailableSkill[] {
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
name: skill.name,
description: skill.description,
location: "plugin" as const,
}))
const discoveredAvailable: AvailableSkill[] = discoveredSkills
.filter(s => !builtinSkillNames.has(s.name) && !disabledSkills?.has(s.name))
.map((skill) => ({
name: skill.name,
description: skill.definition.description ?? "",
location: mapScopeToLocation(skill.scope),
}))
return [...builtinAvailable, ...discoveredAvailable]
}

View File

@@ -0,0 +1,8 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import { createEnvContext } from "../env-context"
export function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig {
if (!directory || !config.prompt) return config
const envContext = createEnvContext()
return { ...config, prompt: config.prompt + envContext }
}

View File

@@ -0,0 +1,102 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrides, AgentPromptMetadata } from "../types"
import type { CategoryConfig, GitMasterConfig } from "../../config/schema"
import type { BrowserAutomationProvider } from "../../config/schema"
import type { AvailableAgent } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS, isModelAvailable } from "../../shared"
import { buildAgent, isFactory } from "../agent-builder"
import { applyOverrides } from "./agent-overrides"
import { applyEnvironmentContext } from "./environment-context"
import { applyModelResolution } from "./model-resolution"
export function collectPendingBuiltinAgents(input: {
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>>
disabledAgents: string[]
agentOverrides: AgentOverrides
directory?: string
systemDefaultModel?: string
mergedCategories: Record<string, CategoryConfig>
gitMasterConfig?: GitMasterConfig
browserProvider?: BrowserAutomationProvider
uiSelectedModel?: string
availableModels: Set<string>
disabledSkills?: Set<string>
}): { pendingAgentConfigs: Map<string, AgentConfig>; availableAgents: AvailableAgent[] } {
const {
agentSources,
agentMetadata,
disabledAgents,
agentOverrides,
directory,
systemDefaultModel,
mergedCategories,
gitMasterConfig,
browserProvider,
uiSelectedModel,
availableModels,
disabledSkills,
} = input
const availableAgents: AvailableAgent[] = []
const pendingAgentConfigs: Map<string, AgentConfig> = new Map()
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
if (agentName === "sisyphus") continue
if (agentName === "hephaestus") continue
if (agentName === "atlas") continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
const override = agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
if (requirement?.requiresModel && availableModels) {
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
continue
}
}
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
const resolution = applyModelResolution({
uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined,
userModel: override?.model,
requirement,
availableModels,
systemDefaultModel,
})
if (!resolution) continue
const { model, variant: resolvedVariant } = resolution
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
// Apply resolved variant from model fallback chain
if (resolvedVariant) {
config = { ...config, variant: resolvedVariant }
}
if (agentName === "librarian") {
config = applyEnvironmentContext(config, directory)
}
config = applyOverrides(config, override, mergedCategories)
// Store for later - will be added after sisyphus and hephaestus
pendingAgentConfigs.set(name, config)
const metadata = agentMetadata[agentName]
if (metadata) {
availableAgents.push({
name: agentName,
description: config.description ?? "",
metadata,
})
}
}
return { pendingAgentConfigs, availableAgents }
}

View File

@@ -0,0 +1,88 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrides } from "../types"
import type { CategoryConfig } from "../../config/schema"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS, isAnyProviderConnected } from "../../shared"
import { createHephaestusAgent } from "../hephaestus"
import { createEnvContext } from "../env-context"
import { applyCategoryOverride, mergeAgentConfig } from "./agent-overrides"
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
export function maybeCreateHephaestusConfig(input: {
disabledAgents: string[]
agentOverrides: AgentOverrides
availableModels: Set<string>
systemDefaultModel?: string
isFirstRunNoCache: boolean
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
availableCategories: AvailableCategory[]
mergedCategories: Record<string, CategoryConfig>
directory?: string
}): AgentConfig | undefined {
const {
disabledAgents,
agentOverrides,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
} = input
if (disabledAgents.includes("hephaestus")) return undefined
const hephaestusOverride = agentOverrides["hephaestus"]
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
const hasRequiredProvider =
!hephaestusRequirement?.requiresProvider ||
hasHephaestusExplicitConfig ||
isFirstRunNoCache ||
isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
if (!hasRequiredProvider) return undefined
let hephaestusResolution = applyModelResolution({
userModel: hephaestusOverride?.model,
requirement: hephaestusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !hephaestusOverride?.model) {
hephaestusResolution = getFirstFallbackModel(hephaestusRequirement)
}
if (!hephaestusResolution) return undefined
const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution
let hephaestusConfig = createHephaestusAgent(
hephaestusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (hepOverrideCategory) {
hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories)
}
if (directory && hephaestusConfig.prompt) {
const envContext = createEnvContext()
hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext }
}
if (hephaestusOverride) {
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
}
return hephaestusConfig
}

View File

@@ -0,0 +1,28 @@
import { resolveModelPipeline } from "../../shared"
export function applyModelResolution(input: {
uiSelectedModel?: string
userModel?: string
requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] }
availableModels: Set<string>
systemDefaultModel?: string
}) {
const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input
return resolveModelPipeline({
intent: { uiSelectedModel, userModel },
constraints: { availableModels },
policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel },
})
}
export function getFirstFallbackModel(requirement?: {
fallbackChain?: { providers: string[]; model: string; variant?: string }[]
}) {
const entry = requirement?.fallbackChain?.[0]
if (!entry || entry.providers.length === 0) return undefined
return {
model: `${entry.providers[0]}/${entry.model}`,
provenance: "provider-fallback" as const,
variant: entry.variant,
}
}

View File

@@ -0,0 +1,81 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrides } from "../types"
import type { CategoriesConfig, CategoryConfig } from "../../config/schema"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS, isAnyFallbackModelAvailable } from "../../shared"
import { applyEnvironmentContext } from "./environment-context"
import { applyOverrides } from "./agent-overrides"
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
import { createSisyphusAgent } from "../sisyphus"
export function maybeCreateSisyphusConfig(input: {
disabledAgents: string[]
agentOverrides: AgentOverrides
uiSelectedModel?: string
availableModels: Set<string>
systemDefaultModel?: string
isFirstRunNoCache: boolean
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
availableCategories: AvailableCategory[]
mergedCategories: Record<string, CategoryConfig>
directory?: string
userCategories?: CategoriesConfig
}): AgentConfig | undefined {
const {
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
} = input
const sisyphusOverride = agentOverrides["sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
const hasSisyphusExplicitConfig = sisyphusOverride !== undefined
const meetsSisyphusAnyModelRequirement =
!sisyphusRequirement?.requiresAnyModel ||
hasSisyphusExplicitConfig ||
isFirstRunNoCache ||
isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels)
if (disabledAgents.includes("sisyphus") || !meetsSisyphusAnyModelRequirement) return undefined
let sisyphusResolution = applyModelResolution({
uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel,
userModel: sisyphusOverride?.model,
requirement: sisyphusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) {
sisyphusResolution = getFirstFallbackModel(sisyphusRequirement)
}
if (!sisyphusResolution) return undefined
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
let sisyphusConfig = createSisyphusAgent(
sisyphusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
return sisyphusConfig
}

View File

@@ -0,0 +1,61 @@
import type { AgentPromptMetadata } from "./types"
import { truncateDescription } from "../shared/truncate-description"
type RegisteredAgentSummary = {
name: string
description: string
}
function sanitizeMarkdownTableCell(value: string): string {
return value
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|")
.replace(/\s+/g, " ")
.trim()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[] {
if (!Array.isArray(input)) return []
const result: RegisteredAgentSummary[] = []
for (const item of input) {
if (!isRecord(item)) continue
const name = typeof item.name === "string" ? item.name : undefined
if (!name) continue
const hidden = item.hidden
if (hidden === true) continue
const disabled = item.disabled
if (disabled === true) continue
const enabled = item.enabled
if (enabled === false) continue
const description = typeof item.description === "string" ? item.description : ""
result.push({ name: sanitizeMarkdownTableCell(name), description: sanitizeMarkdownTableCell(description) })
}
return result
}
export function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata {
const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description))
const safeAgentName = sanitizeMarkdownTableCell(agentName)
return {
category: "specialist",
cost: "CHEAP",
triggers: [
{
domain: `Custom agent: ${safeAgentName}`,
trigger: shortDescription || "Use when this agent's description matches the task",
},
],
}
}

33
src/agents/env-context.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Creates OmO-specific environment context (time, timezone, locale).
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
* so we only include fields that OpenCode doesn't provide to avoid duplication.
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
*/
export function createEnvContext(): string {
const now = new Date()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const locale = Intl.DateTimeFormat().resolvedOptions().locale
const dateStr = now.toLocaleDateString(locale, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
const timeStr = now.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
return `
<omo-env>
Current date: ${dateStr}
Current time: ${timeStr}
Timezone: ${timezone}
Locale: ${locale}
</omo-env>`
}

View File

@@ -1,5 +1,5 @@
export * from "./types" export * from "./types"
export { createBuiltinAgents } from "./utils" export { createBuiltinAgents } from "./builtin-agents"
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
export { createSisyphusAgent } from "./sisyphus" export { createSisyphusAgent } from "./sisyphus"
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"

View File

@@ -1,50 +1,4 @@
/** export { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "./system-prompt"
* Prometheus Planner System Prompt
*
* Named after the Titan who gave fire (knowledge/foresight) to humanity.
* Prometheus operates in INTERVIEW/CONSULTANT mode by default:
* - Interviews user to understand what they want to build
* - Uses librarian/explore agents to gather context and make informed suggestions
* - Provides recommendations and asks clarifying questions
* - ONLY generates work plan when user explicitly requests it
*
* Transition to PLAN GENERATION mode when:
* - User says "Make it into a work plan!" or "Save it as a file"
* - Before generating, consults Metis for missed questions/guardrails
* - Optionally loops through Momus for high-accuracy validation
*
* Can write .md files only (enforced by prometheus-md-only hook).
*/
import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
/**
* Combined Prometheus system prompt.
* Assembled from modular sections for maintainability.
*/
export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS}
${PROMETHEUS_INTERVIEW_MODE}
${PROMETHEUS_PLAN_GENERATION}
${PROMETHEUS_HIGH_ACCURACY_MODE}
${PROMETHEUS_PLAN_TEMPLATE}
${PROMETHEUS_BEHAVIORAL_SUMMARY}`
/**
* Prometheus planner permission configuration.
* Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook).
* Question permission allows agent to ask user questions via OpenCode's QuestionTool.
*/
export const PROMETHEUS_PERMISSION = {
edit: "allow" as const,
bash: "allow" as const,
webfetch: "allow" as const,
question: "allow" as const,
}
// Re-export individual sections for granular access // Re-export individual sections for granular access
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints" export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"

View File

@@ -0,0 +1,29 @@
import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
/**
* Combined Prometheus system prompt.
* Assembled from modular sections for maintainability.
*/
export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS}
${PROMETHEUS_INTERVIEW_MODE}
${PROMETHEUS_PLAN_GENERATION}
${PROMETHEUS_HIGH_ACCURACY_MODE}
${PROMETHEUS_PLAN_TEMPLATE}
${PROMETHEUS_BEHAVIORAL_SUMMARY}`
/**
* Prometheus planner permission configuration.
* Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook).
* Question permission allows agent to ask user questions via OpenCode's QuestionTool.
*/
export const PROMETHEUS_PERMISSION = {
edit: "allow" as const,
bash: "allow" as const,
webfetch: "allow" as const,
question: "allow" as const,
}

View File

@@ -0,0 +1,119 @@
/**
* Sisyphus-Junior - Focused Task Executor
*
* Executes delegated tasks directly without spawning other agents.
* Category-spawned executor with domain-specific configurations.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { isGptModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema"
import {
createAgentToolRestrictions,
type PermissionValue,
} from "../../shared/permission-compat"
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
const MODE: AgentMode = "subagent"
// Core tools that Sisyphus-Junior must NEVER have access to
// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian
const BLOCKED_TOOLS = ["task"]
export const SISYPHUS_JUNIOR_DEFAULTS = {
model: "anthropic/claude-sonnet-4-5",
temperature: 0.1,
} as const
export type SisyphusJuniorPromptSource = "default" | "gpt"
/**
* Determines which Sisyphus-Junior prompt to use based on model.
*/
export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
/**
* Builds the appropriate Sisyphus-Junior prompt based on model.
*/
export function buildSisyphusJuniorPrompt(
model: string | undefined,
useTaskSystem: boolean,
promptAppend?: string
): string {
const source = getSisyphusJuniorPromptSource(model)
switch (source) {
case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default":
default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
}
}
export function createSisyphusJuniorAgentWithOverrides(
override: AgentOverrideConfig | undefined,
systemDefaultModel?: string,
useTaskSystem = false
): AgentConfig {
if (override?.disable) {
override = undefined
}
const overrideModel = (override as { model?: string } | undefined)?.model
const model = overrideModel ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
const promptAppend = override?.prompt_append
const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend)
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
const userPermission = (override?.permission ?? {}) as Record<string, PermissionValue>
const basePermission = baseRestrictions.permission
const merged: Record<string, PermissionValue> = { ...userPermission }
for (const tool of BLOCKED_TOOLS) {
merged[tool] = "deny"
}
merged.call_omo_agent = "allow"
const toolsConfig = { permission: { ...merged, ...basePermission } }
const base: AgentConfig = {
description: override?.description ??
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
mode: MODE,
model,
temperature,
maxTokens: 64000,
prompt,
color: override?.color ?? "#20B2AA",
...toolsConfig,
}
if (override?.top_p !== undefined) {
base.top_p = override.top_p
}
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium" } as AgentConfig
}
return {
...base,
thinking: { type: "enabled", budgetTokens: 32000 },
} as AgentConfig
}
createSisyphusJuniorAgentWithOverrides.mode = MODE

View File

@@ -1,121 +1,10 @@
/**
* Sisyphus-Junior - Focused Task Executor
*
* Executes delegated tasks directly without spawning other agents.
* Category-spawned executor with domain-specific configurations.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { isGptModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema"
import {
createAgentToolRestrictions,
type PermissionValue,
} from "../../shared/permission-compat"
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
export { buildDefaultSisyphusJuniorPrompt } from "./default" export { buildDefaultSisyphusJuniorPrompt } from "./default"
export { buildGptSisyphusJuniorPrompt } from "./gpt" export { buildGptSisyphusJuniorPrompt } from "./gpt"
const MODE: AgentMode = "subagent" export {
SISYPHUS_JUNIOR_DEFAULTS,
// Core tools that Sisyphus-Junior must NEVER have access to getSisyphusJuniorPromptSource,
// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian buildSisyphusJuniorPrompt,
const BLOCKED_TOOLS = ["task"] createSisyphusJuniorAgentWithOverrides,
} from "./agent"
export const SISYPHUS_JUNIOR_DEFAULTS = { export type { SisyphusJuniorPromptSource } from "./agent"
model: "anthropic/claude-sonnet-4-5",
temperature: 0.1,
} as const
export type SisyphusJuniorPromptSource = "default" | "gpt"
/**
* Determines which Sisyphus-Junior prompt to use based on model.
*/
export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
/**
* Builds the appropriate Sisyphus-Junior prompt based on model.
*/
export function buildSisyphusJuniorPrompt(
model: string | undefined,
useTaskSystem: boolean,
promptAppend?: string
): string {
const source = getSisyphusJuniorPromptSource(model)
switch (source) {
case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default":
default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
}
}
export function createSisyphusJuniorAgentWithOverrides(
override: AgentOverrideConfig | undefined,
systemDefaultModel?: string,
useTaskSystem = false
): AgentConfig {
if (override?.disable) {
override = undefined
}
const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
const promptAppend = override?.prompt_append
const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend)
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
const userPermission = (override?.permission ?? {}) as Record<string, PermissionValue>
const basePermission = baseRestrictions.permission
const merged: Record<string, PermissionValue> = { ...userPermission }
for (const tool of BLOCKED_TOOLS) {
merged[tool] = "deny"
}
merged.call_omo_agent = "allow"
const toolsConfig = { permission: { ...merged, ...basePermission } }
const base: AgentConfig = {
description: override?.description ??
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
mode: MODE,
model,
temperature,
maxTokens: 64000,
prompt,
color: override?.color ?? "#20B2AA",
...toolsConfig,
}
if (override?.top_p !== undefined) {
base.top_p = override.top_p
}
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium" } as AgentConfig
}
return {
...base,
thinking: { type: "enabled", budgetTokens: 32000 },
} as AgentConfig
}
createSisyphusJuniorAgentWithOverrides.mode = MODE

View File

@@ -1,5 +1,7 @@
/// <reference types="bun-types" />
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
import { createBuiltinAgents } from "./utils" import { createBuiltinAgents } from "./builtin-agents"
import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentConfig } from "@opencode-ai/sdk"
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content" import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
import * as connectedProvidersCache from "../shared/connected-providers-cache" import * as connectedProvidersCache from "../shared/connected-providers-cache"
@@ -759,7 +761,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
}) })
describe("buildAgent with category and skills", () => { describe("buildAgent with category and skills", () => {
const { buildAgent } = require("./utils") const { buildAgent } = require("./agent-builder")
const TEST_MODEL = "anthropic/claude-opus-4-6" const TEST_MODEL = "anthropic/claude-opus-4-6"
beforeEach(() => { beforeEach(() => {

View File

@@ -1,571 +0,0 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
import {
deepMerge,
fetchAvailableModels,
resolveModelPipeline,
AGENT_MODEL_REQUIREMENTS,
readConnectedProvidersCache,
isModelAvailable,
isAnyFallbackModelAvailable,
isAnyProviderConnected,
migrateAgentConfig,
truncateDescription,
} from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../features/builtin-skills"
import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/types"
import type { BrowserAutomationProvider } from "../config/schema"
type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
sisyphus: createSisyphusAgent,
hephaestus: createHephaestusAgent,
oracle: createOracleAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
"multimodal-looker": createMultimodalLookerAgent,
metis: createMetisAgent,
momus: createMomusAgent,
// Note: Atlas is handled specially in createBuiltinAgents()
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as unknown as AgentFactory,
}
/**
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
* (Delegation Table, Tool Selection, Key Triggers, etc.)
*/
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
oracle: ORACLE_PROMPT_METADATA,
librarian: LIBRARIAN_PROMPT_METADATA,
explore: EXPLORE_PROMPT_METADATA,
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
metis: metisPromptMetadata,
momus: momusPromptMetadata,
atlas: atlasPromptMetadata,
}
function isFactory(source: AgentSource): source is AgentFactory {
return typeof source === "function"
}
type RegisteredAgentSummary = {
name: string
description: string
}
function sanitizeMarkdownTableCell(value: string): string {
return value
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|")
.replace(/\s+/g, " ")
.trim()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[] {
if (!Array.isArray(input)) return []
const result: RegisteredAgentSummary[] = []
for (const item of input) {
if (!isRecord(item)) continue
const name = typeof item.name === "string" ? item.name : undefined
if (!name) continue
const hidden = item.hidden
if (hidden === true) continue
const disabled = item.disabled
if (disabled === true) continue
const enabled = item.enabled
if (enabled === false) continue
const description = typeof item.description === "string" ? item.description : ""
result.push({ name, description: sanitizeMarkdownTableCell(description) })
}
return result
}
function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata {
const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description))
const safeAgentName = sanitizeMarkdownTableCell(agentName)
return {
category: "specialist",
cost: "CHEAP",
triggers: [
{
domain: `Custom agent: ${safeAgentName}`,
trigger: shortDescription || "Use when this agent's description matches the task",
},
],
}
}
export function buildAgent(
source: AgentSource,
model: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
browserProvider?: BrowserAutomationProvider,
disabledSkills?: Set<string>
): AgentConfig {
const base = isFactory(source) ? source(model) : source
const categoryConfigs: Record<string, CategoryConfig> = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
if (agentWithCategory.category) {
const categoryConfig = categoryConfigs[agentWithCategory.category]
if (categoryConfig) {
if (!base.model) {
base.model = categoryConfig.model
}
if (base.temperature === undefined && categoryConfig.temperature !== undefined) {
base.temperature = categoryConfig.temperature
}
if (base.variant === undefined && categoryConfig.variant !== undefined) {
base.variant = categoryConfig.variant
}
}
}
if (agentWithCategory.skills?.length) {
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
if (resolved.size > 0) {
const skillContent = Array.from(resolved.values()).join("\n\n")
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
}
}
return base
}
/**
* Creates OmO-specific environment context (time, timezone, locale).
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
* so we only include fields that OpenCode doesn't provide to avoid duplication.
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
*/
export function createEnvContext(): string {
const now = new Date()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const locale = Intl.DateTimeFormat().resolvedOptions().locale
const dateStr = now.toLocaleDateString(locale, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
const timeStr = now.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
return `
<omo-env>
Current date: ${dateStr}
Current time: ${timeStr}
Timezone: ${timezone}
Locale: ${locale}
</omo-env>`
}
/**
* Expands a category reference from an agent override into concrete config properties.
* Category properties are applied unconditionally (overwriting factory defaults),
* because the user's chosen category should take priority over factory base values.
* Direct override properties applied later via mergeAgentConfig() will supersede these.
*/
function applyCategoryOverride(
config: AgentConfig,
categoryName: string,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
const categoryConfig = mergedCategories[categoryName]
if (!categoryConfig) return config
const result = { ...config } as AgentConfig & Record<string, unknown>
if (categoryConfig.model) result.model = categoryConfig.model
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
return result as AgentConfig
}
function applyModelResolution(input: {
uiSelectedModel?: string
userModel?: string
requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] }
availableModels: Set<string>
systemDefaultModel?: string
}) {
const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input
return resolveModelPipeline({
intent: { uiSelectedModel, userModel },
constraints: { availableModels },
policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel },
})
}
function getFirstFallbackModel(requirement?: {
fallbackChain?: { providers: string[]; model: string; variant?: string }[]
}) {
const entry = requirement?.fallbackChain?.[0]
if (!entry || entry.providers.length === 0) return undefined
return {
model: `${entry.providers[0]}/${entry.model}`,
provenance: "provider-fallback" as const,
variant: entry.variant,
}
}
function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig {
if (!directory || !config.prompt) return config
const envContext = createEnvContext()
return { ...config, prompt: config.prompt + envContext }
}
function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
result = applyCategoryOverride(result, overrideCategory, mergedCategories)
}
if (override) {
result = mergeAgentConfig(result, override)
}
return result
}
function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
}
return merged
}
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
if (scope === "user" || scope === "opencode") return "user"
if (scope === "project" || scope === "opencode-project") return "project"
return "plugin"
}
export async function createBuiltinAgents(
disabledAgents: string[] = [],
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
customAgentSummaries?: unknown,
browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string,
disabledSkills?: Set<string>
): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache()
// IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization.
// This function is called from config handler, and calling client API causes deadlock.
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
const availableModels = await fetchAvailableModels(undefined, {
connectedProviders: connectedProviders ?? undefined,
})
const isFirstRunNoCache =
availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0)
const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = []
const mergedCategories = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
name,
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
}))
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
name: skill.name,
description: skill.description,
location: "plugin" as const,
}))
const discoveredAvailable: AvailableSkill[] = discoveredSkills
.filter(s => !builtinSkillNames.has(s.name))
.map((skill) => ({
name: skill.name,
description: skill.definition.description ?? "",
location: mapScopeToLocation(skill.scope),
}))
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable]
const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries)
const builtinAgentNames = new Set(Object.keys(agentSources).map((n) => n.toLowerCase()))
const disabledAgentNames = new Set(disabledAgents.map((n) => n.toLowerCase()))
// Collect general agents first (for availableAgents), but don't add to result yet
const pendingAgentConfigs: Map<string, AgentConfig> = new Map()
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
if (agentName === "sisyphus") continue
if (agentName === "hephaestus") continue
if (agentName === "atlas") continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
const override = agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
if (requirement?.requiresModel && availableModels) {
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
continue
}
}
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
const resolution = applyModelResolution({
uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined,
userModel: override?.model,
requirement,
availableModels,
systemDefaultModel,
})
if (!resolution) continue
const { model, variant: resolvedVariant } = resolution
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
// Apply resolved variant from model fallback chain
if (resolvedVariant) {
config = { ...config, variant: resolvedVariant }
}
// Expand override.category into concrete properties (higher priority than factory/resolved)
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
}
if (agentName === "librarian") {
config = applyEnvironmentContext(config, directory)
}
config = applyOverrides(config, override, mergedCategories)
// Store for later - will be added after sisyphus and hephaestus
pendingAgentConfigs.set(name, config)
const metadata = agentMetadata[agentName]
if (metadata) {
availableAgents.push({
name: agentName,
description: config.description ?? "",
metadata,
})
}
}
for (const agent of registeredAgents) {
const lowerName = agent.name.toLowerCase()
if (builtinAgentNames.has(lowerName)) continue
if (disabledAgentNames.has(lowerName)) continue
if (availableAgents.some((a) => a.name.toLowerCase() === lowerName)) continue
availableAgents.push({
name: agent.name,
description: agent.description,
metadata: buildCustomAgentMetadata(agent.name, agent.description),
})
}
const sisyphusOverride = agentOverrides["sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
const hasSisyphusExplicitConfig = sisyphusOverride !== undefined
const meetsSisyphusAnyModelRequirement =
!sisyphusRequirement?.requiresAnyModel ||
hasSisyphusExplicitConfig ||
isFirstRunNoCache ||
isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels)
if (!disabledAgents.includes("sisyphus") && meetsSisyphusAnyModelRequirement) {
let sisyphusResolution = applyModelResolution({
uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel,
userModel: sisyphusOverride?.model,
requirement: sisyphusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) {
sisyphusResolution = getFirstFallbackModel(sisyphusRequirement)
}
if (sisyphusResolution) {
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
let sisyphusConfig = createSisyphusAgent(
sisyphusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
result["sisyphus"] = sisyphusConfig
}
}
if (!disabledAgents.includes("hephaestus")) {
const hephaestusOverride = agentOverrides["hephaestus"]
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
const hasRequiredProvider =
!hephaestusRequirement?.requiresProvider ||
hasHephaestusExplicitConfig ||
isFirstRunNoCache ||
isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
if (hasRequiredProvider) {
let hephaestusResolution = applyModelResolution({
userModel: hephaestusOverride?.model,
requirement: hephaestusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !hephaestusOverride?.model) {
hephaestusResolution = getFirstFallbackModel(hephaestusRequirement)
}
if (hephaestusResolution) {
const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution
let hephaestusConfig = createHephaestusAgent(
hephaestusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
if (!hephaestusOverride?.variant) {
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
}
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (hepOverrideCategory) {
hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories)
}
if (directory && hephaestusConfig.prompt) {
const envContext = createEnvContext()
hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext }
}
if (hephaestusOverride) {
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
}
result["hephaestus"] = hephaestusConfig
}
}
}
// Add pending agents after sisyphus and hephaestus to maintain order
for (const [name, config] of pendingAgentConfigs) {
result[name] = config
}
if (!disabledAgents.includes("atlas")) {
const orchestratorOverride = agentOverrides["atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
const atlasResolution = applyModelResolution({
uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel,
userModel: orchestratorOverride?.model,
requirement: atlasRequirement,
availableModels,
systemDefaultModel,
})
if (atlasResolution) {
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
let orchestratorConfig = createAtlasAgent({
model: atlasModel,
availableAgents,
availableSkills,
userCategories: categories,
})
if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
result["atlas"] = orchestratorConfig
}
}
return result
}

164
src/cli/cli-installer.ts Normal file
View File

@@ -0,0 +1,164 @@
import color from "picocolors"
import type { InstallArgs } from "./types"
import {
addAuthPlugins,
addPluginToOpenCodeConfig,
addProviderConfig,
detectCurrentConfig,
getOpenCodeVersion,
isOpenCodeInstalled,
writeOmoConfig,
} from "./config-manager"
import {
SYMBOLS,
argsToConfig,
detectedToInitialValues,
formatConfigSummary,
printBox,
printError,
printHeader,
printInfo,
printStep,
printSuccess,
printWarning,
validateNonTuiArgs,
} from "./install-validators"
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
const validation = validateNonTuiArgs(args)
if (!validation.valid) {
printHeader(false)
printError("Validation failed:")
for (const err of validation.errors) {
console.log(` ${SYMBOLS.bullet} ${err}`)
}
console.log()
printInfo(
"Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --gemini=<no|yes> --copilot=<no|yes>",
)
console.log()
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
printHeader(isUpdate)
const totalSteps = 6
let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion()
if (!installed) {
printWarning(
"OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.",
)
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
}
if (isUpdate) {
const initial = detectedToInitialValues(detected)
printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
if (config.hasGemini) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
printError(`Failed: ${authResult.error}`)
return 1
}
printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`)
printStep(step++, totalSteps, "Adding provider configurations...")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
printError(`Failed: ${providerResult.error}`)
return 1
}
printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`)
} else {
step += 2
}
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
console.log(color.dim(" • Reduced orchestration quality"))
console.log(color.dim(" • Weaker tool selection and delegation"))
console.log(color.dim(" • Less reliable task completion"))
console.log()
console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience."))
console.log()
}
if (
!config.hasClaude &&
!config.hasOpenAI &&
!config.hasGemini &&
!config.hasCopilot &&
!config.hasOpencodeZen
) {
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
console.log()
printBox(
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"The Magic Word",
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`,
)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
printBox(
`Run ${color.cyan("opencode auth login")} and select your provider:\n` +
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
(config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") +
(config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""),
"Authenticate Your Providers",
)
}
return 0
}

191
src/cli/cli-program.ts Normal file
View File

@@ -0,0 +1,191 @@
import { Command } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import { createMcpOAuthCommand } from "./mcp-oauth"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
import type { DoctorOptions } from "./doctor"
import packageJson from "../../package.json" with { type: "json" }
const VERSION = packageJson.version
const program = new Command()
program
.name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
.version(VERSION, "-v, --version", "Show version number")
.enablePositionalOptions()
program
.command("install")
.description("Install and configure oh-my-opencode with interactive setup")
.option("--no-tui", "Run in non-interactive mode (requires all options)")
.option("--claude <value>", "Claude subscription: no, yes, max20")
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
OpenAI Native openai/ models (GPT-5.2 for Oracle)
Gemini Native google/ models (Gemini 3 Pro, Flash)
Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
`)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
process.exit(exitCode)
})
program
.command("run <message>")
.allowUnknownOption()
.passThroughOptions()
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
.option("--attach <url>", "Attach to existing opencode server URL")
.option("--on-complete <command>", "Shell command to run after completion")
.option("--json", "Output structured JSON result to stdout")
.option("--session-id <id>", "Resume existing session instead of creating new one")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode run "Fix the bug in index.ts"
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
$ bunx oh-my-opencode run --port 4321 "Fix the bug"
$ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug"
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
Agent resolution order:
1) --agent flag
2) OPENCODE_DEFAULT_AGENT
3) oh-my-opencode.json "default_run_agent"
4) Sisyphus (fallback)
Available core agents:
Sisyphus, Hephaestus, Prometheus, Atlas
Unlike 'opencode run', this command waits until:
- All todos are completed or cancelled
- All child sessions (background tasks) are idle
`)
.action(async (message: string, options) => {
if (options.port && options.attach) {
console.error("Error: --port and --attach are mutually exclusive")
process.exit(1)
}
const runOptions: RunOptions = {
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
port: options.port,
attach: options.attach,
onComplete: options.onComplete,
json: options.json ?? false,
sessionId: options.sessionId,
}
const exitCode = await run(runOptions)
process.exit(exitCode)
})
program
.command("get-local-version")
.description("Show current installed version and check for updates")
.option("-d, --directory <path>", "Working directory to check config from")
.option("--json", "Output in JSON format for scripting")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode get-local-version
$ bunx oh-my-opencode get-local-version --json
$ bunx oh-my-opencode get-local-version --directory /path/to/project
This command shows:
- Current installed version
- Latest available version on npm
- Whether you're up to date
- Special modes (local dev, pinned version)
`)
.action(async (options) => {
const versionOptions: GetLocalVersionOptions = {
directory: options.directory,
json: options.json ?? false,
}
const exitCode = await getLocalVersion(versionOptions)
process.exit(exitCode)
})
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode doctor
$ bunx oh-my-opencode doctor --verbose
$ bunx oh-my-opencode doctor --json
$ bunx oh-my-opencode doctor --category authentication
Categories:
installation Check OpenCode and plugin installation
configuration Validate configuration files
authentication Check auth provider status
dependencies Check external dependencies
tools Check LSP and MCP servers
updates Check for version updates
`)
.action(async (options) => {
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")
.action(() => {
console.log(`oh-my-opencode v${VERSION}`)
})
program.addCommand(createMcpOAuthCommand())
export function runCli(): void {
program.parse()
}

View File

@@ -1,667 +1,23 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs" export type { ConfigContext } from "./config-manager/config-context"
import { export {
parseJsonc, initConfigContext,
getOpenCodeConfigPaths, getConfigContext,
type OpenCodeBinaryType, resetConfigContext,
type OpenCodeConfigPaths, } from "./config-manager/config-context"
} from "../shared"
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
import { generateModelConfig } from "./model-fallback"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const export { fetchNpmDistTags } from "./config-manager/npm-dist-tags"
export { getPluginNameWithVersion } from "./config-manager/plugin-name-with-version"
export { addPluginToOpenCodeConfig } from "./config-manager/add-plugin-to-opencode-config"
interface ConfigContext { export { generateOmoConfig } from "./config-manager/generate-omo-config"
binary: OpenCodeBinaryType export { writeOmoConfig } from "./config-manager/write-omo-config"
version: string | null
paths: OpenCodeConfigPaths
}
let configContext: ConfigContext | null = null export { isOpenCodeInstalled, getOpenCodeVersion } from "./config-manager/opencode-binary"
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void { export { fetchLatestVersion, addAuthPlugins } from "./config-manager/auth-plugins"
const paths = getOpenCodeConfigPaths({ binary, version }) export { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager/antigravity-provider-configuration"
configContext = { binary, version, paths } export { addProviderConfig } from "./config-manager/add-provider-config"
} export { detectCurrentConfig } from "./config-manager/detect-current-config"
export function getConfigContext(): ConfigContext { export type { BunInstallResult } from "./config-manager/bun-install"
if (!configContext) { export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install"
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
configContext = { binary: "opencode", version: null, paths }
}
return configContext
}
export function resetConfigContext(): void {
configContext = null
}
function getConfigDir(): string {
return getConfigContext().paths.configDir
}
function getConfigJson(): string {
return getConfigContext().paths.configJson
}
function getConfigJsonc(): string {
return getConfigContext().paths.configJsonc
}
function getPackageJson(): string {
return getConfigContext().paths.packageJson
}
function getOmoConfig(): string {
return getConfigContext().paths.omoConfig
}
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
interface NodeError extends Error {
code?: string
}
function isPermissionError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM"
}
function isFileNotFoundError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "ENOENT"
}
function formatErrorWithSuggestion(err: unknown, context: string): string {
if (isPermissionError(err)) {
return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.`
}
if (isFileNotFoundError(err)) {
return `File not found while trying to ${context}. The file may have been deleted or moved.`
}
if (err instanceof SyntaxError) {
return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.`
}
const message = err instanceof Error ? err.message : String(err)
if (message.includes("ENOSPC")) {
return `Disk full: Cannot ${context}. Free up disk space and try again.`
}
if (message.includes("EROFS")) {
return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.`
}
return `Failed to ${context}: ${message}`
}
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
if (!res.ok) return null
const data = await res.json() as { version: string }
return data.version
} catch {
return null
}
}
interface NpmDistTags {
latest?: string
beta?: string
next?: string
[tag: string]: string | undefined
}
const NPM_FETCH_TIMEOUT_MS = 5000
export async function fetchNpmDistTags(packageName: string): Promise<NpmDistTags | null> {
try {
const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, {
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
})
if (!res.ok) return null
const data = await res.json() as NpmDistTags
return data
} catch {
return null
}
}
const PACKAGE_NAME = "oh-my-opencode"
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
if (distTags) {
const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)])
for (const tag of allTags) {
if (distTags[tag] === currentVersion) {
return `${PACKAGE_NAME}@${tag}`
}
}
}
return `${PACKAGE_NAME}@${currentVersion}`
}
type ConfigFormat = "json" | "jsonc" | "none"
interface OpenCodeConfig {
plugin?: string[]
[key: string]: unknown
}
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
const configJsonc = getConfigJsonc()
const configJson = getConfigJson()
if (existsSync(configJsonc)) {
return { format: "jsonc", path: configJsonc }
}
if (existsSync(configJson)) {
return { format: "json", path: configJson }
}
return { format: "none", path: configJson }
}
interface ParseConfigResult {
config: OpenCodeConfig | null
error?: string
}
function isEmptyOrWhitespace(content: string): boolean {
return content.trim().length === 0
}
function parseConfig(path: string, _isJsonc: boolean): OpenCodeConfig | null {
const result = parseConfigWithError(path)
return result.config
}
function parseConfigWithError(path: string): ParseConfigResult {
try {
const stat = statSync(path)
if (stat.size === 0) {
return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` }
}
const content = readFileSync(path, "utf-8")
if (isEmptyOrWhitespace(content)) {
return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` }
}
const config = parseJsonc<OpenCodeConfig>(content)
if (config === null || config === undefined) {
return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` }
}
if (typeof config !== "object" || Array.isArray(config)) {
return { config: null, error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}` }
}
return { config }
} catch (err) {
return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) }
}
}
function ensureConfigDir(): void {
const configDir = getConfigDir()
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
}
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
const pluginEntry = await getPluginNameWithVersion(currentVersion)
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginEntry] }
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
return { success: false, configPath: path, error: parseResult.error ?? "Failed to parse config file" }
}
const config = parseResult.config
const plugins = config.plugin ?? []
const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`))
if (existingIndex !== -1) {
if (plugins[existingIndex] === pluginEntry) {
return { success: true, configPath: path }
}
plugins[existingIndex] = pluginEntry
} else {
plugins.push(pluginEntry)
}
config.plugin = plugins
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
writeFileSync(path, newContent)
}
} else {
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "update opencode config") }
}
}
function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
const result = { ...target }
for (const key of Object.keys(source) as Array<keyof T>) {
const sourceValue = source[key]
const targetValue = result[key]
if (
sourceValue !== null &&
typeof sourceValue === "object" &&
!Array.isArray(sourceValue) &&
targetValue !== null &&
typeof targetValue === "object" &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
) as T[keyof T]
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T]
}
}
return result
}
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
return generateModelConfig(installConfig)
}
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const omoConfigPath = getOmoConfig()
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(omoConfigPath)) {
try {
const stat = statSync(omoConfigPath)
const content = readFileSync(omoConfigPath, "utf-8")
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const existing = parseJsonc<Record<string, unknown>>(content)
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const merged = deepMerge(existing, newConfig)
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
throw parseErr
}
} else {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: omoConfigPath }
} catch (err) {
return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
}
}
interface OpenCodeBinaryResult {
binary: OpenCodeBinaryType
version: string
}
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
const version = output.trim()
initConfigContext(binary, version)
return { binary, version }
}
} catch {
continue
}
}
return null
}
export async function isOpenCodeInstalled(): Promise<boolean> {
const result = await findOpenCodeBinaryWithVersion()
return result !== null
}
export async function getOpenCodeVersion(): Promise<string | null> {
const result = await findOpenCodeBinaryWithVersion()
return result?.version ?? null
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const plugins: string[] = existingConfig?.plugin ?? []
if (config.hasGemini) {
const version = await fetchLatestVersion("opencode-antigravity-auth")
const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth"
if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
plugins.push(pluginEntry)
}
}
const newConfig = { ...(existingConfig ?? {}), plugin: plugins }
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add auth plugins to config") }
}
}
export interface BunInstallResult {
success: boolean
timedOut?: boolean
error?: string
}
export async function runBunInstall(): Promise<boolean> {
const result = await runBunInstallWithDetails()
return result.success
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
cwd: getConfigDir(),
stdout: "pipe",
stderr: "pipe",
})
const timeoutPromise = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
)
const exitPromise = proc.exited.then(() => "completed" as const)
const result = await Promise.race([exitPromise, timeoutPromise])
if (result === "timeout") {
try {
proc.kill()
} catch {
/* intentionally empty - process may have already exited */
}
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`,
}
}
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
return {
success: false,
error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`,
}
}
return { success: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
success: false,
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
}
}
}
/**
* Antigravity Provider Configuration
*
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
* - `antigravity-gemini-3-pro` with variants: low, high
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
*
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
* but variants are the recommended approach.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
*/
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
models: {
"antigravity-gemini-3-pro": {
name: "Gemini 3 Pro (Antigravity)",
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingLevel: "low" },
high: { thinkingLevel: "high" },
},
},
"antigravity-gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
minimal: { thinkingLevel: "minimal" },
low: { thinkingLevel: "low" },
medium: { thinkingLevel: "medium" },
high: { thinkingLevel: "high" },
},
},
"antigravity-claude-sonnet-4-5": {
name: "Claude Sonnet 4.5 (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"antigravity-claude-sonnet-4-5-thinking": {
name: "Claude Sonnet 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
"antigravity-claude-opus-4-5-thinking": {
name: "Claude Opus 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
},
},
}
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const newConfig = { ...(existingConfig ?? {}) }
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
if (config.hasGemini) {
providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google
}
if (Object.keys(providers).length > 0) {
newConfig.provider = providers
}
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add provider config") }
}
}
function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean; hasKimiForCoding: boolean } {
const omoConfigPath = getOmoConfig()
if (!existsSync(omoConfigPath)) {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
try {
const content = readFileSync(omoConfigPath, "utf-8")
const omoConfig = parseJsonc<Record<string, unknown>>(content)
if (!omoConfig || typeof omoConfig !== "object") {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
const configStr = JSON.stringify(omoConfig)
const hasOpenAI = configStr.includes('"openai/')
const hasOpencodeZen = configStr.includes('"opencode/')
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding }
} catch {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
}
export function detectCurrentConfig(): DetectedConfig {
const result: DetectedConfig = {
isInstalled: false,
hasClaude: true,
isMax20: true,
hasOpenAI: true,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: true,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
const { format, path } = detectConfigFormat()
if (format === "none") {
return result
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
return result
}
const openCodeConfig = parseResult.config
const plugins = openCodeConfig.plugin ?? []
result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode"))
if (!result.isInstalled) {
return result
}
// Gemini auth plugin detection still works via plugin presence
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig()
result.hasOpenAI = hasOpenAI
result.hasOpencodeZen = hasOpencodeZen
result.hasZaiCodingPlan = hasZaiCodingPlan
result.hasKimiForCoding = hasKimiForCoding
return result
}

View File

@@ -0,0 +1,82 @@
import { readFileSync, writeFileSync } from "node:fs"
import type { ConfigMergeResult } from "../types"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
import { getPluginNameWithVersion } from "./plugin-name-with-version"
const PACKAGE_NAME = "oh-my-opencode"
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const { format, path } = detectConfigFormat()
const pluginEntry = await getPluginNameWithVersion(currentVersion)
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginEntry] }
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
const parseResult = parseOpenCodeConfigFileWithError(path)
if (!parseResult.config) {
return {
success: false,
configPath: path,
error: parseResult.error ?? "Failed to parse config file",
}
}
const config = parseResult.config
const plugins = config.plugin ?? []
const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`))
if (existingIndex !== -1) {
if (plugins[existingIndex] === pluginEntry) {
return { success: true, configPath: path }
}
plugins[existingIndex] = pluginEntry
} else {
plugins.push(pluginEntry)
}
config.plugin = plugins
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/(\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
writeFileSync(path, newContent)
}
} else {
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return {
success: false,
configPath: path,
error: formatErrorWithSuggestion(err, "update opencode config"),
}
}
}

View File

@@ -0,0 +1,111 @@
import { readFileSync, writeFileSync } from "node:fs"
import type { ConfigMergeResult, InstallConfig } from "../types"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
import { ANTIGRAVITY_PROVIDER_CONFIG } from "./antigravity-provider-configuration"
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseOpenCodeConfigFileWithError(path)
if (parseResult.error && !parseResult.config) {
return {
success: false,
configPath: path,
error: `Failed to parse config file: ${parseResult.error}`,
}
}
existingConfig = parseResult.config
}
const newConfig = { ...(existingConfig ?? {}) }
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
if (config.hasGemini) {
providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google
}
if (Object.keys(providers).length > 0) {
newConfig.provider = providers
}
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const providerJson = JSON.stringify(newConfig.provider, null, 2)
.split("\n")
.map((line, i) => (i === 0 ? line : ` ${line}`))
.join("\n")
// Match "provider" key with any indentation and nested brace depth
const providerIdx = content.indexOf('"provider"')
if (providerIdx !== -1) {
const colonIdx = content.indexOf(":", providerIdx + '"provider"'.length)
const braceStart = colonIdx !== -1 ? content.indexOf("{", colonIdx) : -1
if (braceStart === -1) {
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
} else {
let depth = 0
let braceEnd = braceStart
let inString = false
let escape = false
for (let i = braceStart; i < content.length; i++) {
const ch = content[i]
if (escape) {
escape = false
continue
}
if (ch === "\\") {
escape = true
continue
}
if (ch === '"') {
inString = !inString
continue
}
if (inString) continue
if (ch === "{") depth++
else if (ch === "}") {
depth--
if (depth === 0) {
braceEnd = i
break
}
}
}
const newContent =
content.slice(0, providerIdx) +
`"provider": ${providerJson}` +
content.slice(braceEnd + 1)
writeFileSync(path, newContent)
}
} else {
const newContent = content.replace(/(\{)/, `$1\n "provider": ${providerJson},`)
writeFileSync(path, newContent)
}
} else {
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return {
success: false,
configPath: path,
error: formatErrorWithSuggestion(err, "add provider config"),
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* Antigravity Provider Configuration
*
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
* - `antigravity-gemini-3-pro` with variants: low, high
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
*
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
* but variants are the recommended approach.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
*/
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
models: {
"antigravity-gemini-3-pro": {
name: "Gemini 3 Pro (Antigravity)",
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingLevel: "low" },
high: { thinkingLevel: "high" },
},
},
"antigravity-gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
minimal: { thinkingLevel: "minimal" },
low: { thinkingLevel: "low" },
medium: { thinkingLevel: "medium" },
high: { thinkingLevel: "high" },
},
},
"antigravity-claude-sonnet-4-5": {
name: "Claude Sonnet 4.5 (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"antigravity-claude-sonnet-4-5-thinking": {
name: "Claude Sonnet 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
"antigravity-claude-opus-4-5-thinking": {
name: "Claude Opus 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
},
},
}

View File

@@ -0,0 +1,88 @@
import { readFileSync, writeFileSync } from "node:fs"
import type { ConfigMergeResult, InstallConfig } from "../types"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`)
if (!res.ok) return null
const data = (await res.json()) as { version: string }
return data.version
} catch {
return null
}
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseOpenCodeConfigFileWithError(path)
if (parseResult.error && !parseResult.config) {
return {
success: false,
configPath: path,
error: `Failed to parse config file: ${parseResult.error}`,
}
}
existingConfig = parseResult.config
}
const rawPlugins = existingConfig?.plugin
const plugins: string[] = Array.isArray(rawPlugins) ? rawPlugins : []
if (config.hasGemini) {
const version = await fetchLatestVersion("opencode-antigravity-auth")
const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth"
if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
plugins.push(pluginEntry)
}
}
const newConfig = { ...(existingConfig ?? {}), plugin: plugins }
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
const newContent = content.replace(
pluginArrayRegex,
`"plugin": [\n ${formattedPlugins}\n ]`
)
writeFileSync(path, newContent)
} else {
const inlinePlugins = plugins.map((p) => `"${p}"`).join(", ")
const newContent = content.replace(/(\{)/, `$1\n "plugin": [${inlinePlugins}],`)
writeFileSync(path, newContent)
}
} else {
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return {
success: false,
configPath: path,
error: formatErrorWithSuggestion(err, "add auth plugins to config"),
}
}
}

View File

@@ -0,0 +1,61 @@
import { getConfigDir } from "./config-context"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
export interface BunInstallResult {
success: boolean
timedOut?: boolean
error?: string
}
export async function runBunInstall(): Promise<boolean> {
const result = await runBunInstallWithDetails()
return result.success
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
cwd: getConfigDir(),
stdout: "inherit",
stderr: "inherit",
})
let timeoutId: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<"timeout">((resolve) => {
timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
})
const exitPromise = proc.exited.then(() => "completed" as const)
const result = await Promise.race([exitPromise, timeoutPromise])
clearTimeout(timeoutId!)
if (result === "timeout") {
try {
proc.kill()
} catch {
/* intentionally empty - process may have already exited */
}
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`,
}
}
if (proc.exitCode !== 0) {
return {
success: false,
error: `bun install failed with exit code ${proc.exitCode}`,
}
}
return { success: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
success: false,
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
}
}
}

View File

@@ -0,0 +1,49 @@
import { getOpenCodeConfigPaths } from "../../shared"
import type {
OpenCodeBinaryType,
OpenCodeConfigPaths,
} from "../../shared/opencode-config-dir-types"
export interface ConfigContext {
binary: OpenCodeBinaryType
version: string | null
paths: OpenCodeConfigPaths
}
let configContext: ConfigContext | null = null
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
const paths = getOpenCodeConfigPaths({ binary, version })
configContext = { binary, version, paths }
}
export function getConfigContext(): ConfigContext {
if (!configContext) {
if (process.env.NODE_ENV !== "production") {
console.warn("[config-context] getConfigContext() called before initConfigContext(); defaulting to CLI paths.")
}
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
configContext = { binary: "opencode", version: null, paths }
}
return configContext
}
export function resetConfigContext(): void {
configContext = null
}
export function getConfigDir(): string {
return getConfigContext().paths.configDir
}
export function getConfigJson(): string {
return getConfigContext().paths.configJson
}
export function getConfigJsonc(): string {
return getConfigContext().paths.configJsonc
}
export function getOmoConfigPath(): string {
return getConfigContext().paths.omoConfig
}

View File

@@ -0,0 +1,30 @@
export function deepMergeRecord<TTarget extends Record<string, unknown>>(
target: TTarget,
source: Partial<TTarget>
): TTarget {
const result: TTarget = { ...target }
for (const key of Object.keys(source) as Array<keyof TTarget>) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue
const sourceValue = source[key]
const targetValue = result[key]
if (
sourceValue !== null &&
typeof sourceValue === "object" &&
!Array.isArray(sourceValue) &&
targetValue !== null &&
typeof targetValue === "object" &&
!Array.isArray(targetValue)
) {
result[key] = deepMergeRecord(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
) as TTarget[keyof TTarget]
} else if (sourceValue !== undefined) {
result[key] = sourceValue as TTarget[keyof TTarget]
}
}
return result
}

View File

@@ -0,0 +1,78 @@
import { existsSync, readFileSync } from "node:fs"
import { parseJsonc } from "../../shared"
import type { DetectedConfig } from "../types"
import { getOmoConfigPath } from "./config-context"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
function detectProvidersFromOmoConfig(): {
hasOpenAI: boolean
hasOpencodeZen: boolean
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean
} {
const omoConfigPath = getOmoConfigPath()
if (!existsSync(omoConfigPath)) {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
try {
const content = readFileSync(omoConfigPath, "utf-8")
const omoConfig = parseJsonc<Record<string, unknown>>(content)
if (!omoConfig || typeof omoConfig !== "object") {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
const configStr = JSON.stringify(omoConfig)
const hasOpenAI = configStr.includes('"openai/')
const hasOpencodeZen = configStr.includes('"opencode/')
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding }
} catch {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
}
export function detectCurrentConfig(): DetectedConfig {
const result: DetectedConfig = {
isInstalled: false,
hasClaude: true,
isMax20: true,
hasOpenAI: true,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: true,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
const { format, path } = detectConfigFormat()
if (format === "none") {
return result
}
const parseResult = parseOpenCodeConfigFileWithError(path)
if (!parseResult.config) {
return result
}
const openCodeConfig = parseResult.config
const plugins = openCodeConfig.plugin ?? []
result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode"))
if (!result.isInstalled) {
return result
}
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig()
result.hasOpenAI = hasOpenAI
result.hasOpencodeZen = hasOpencodeZen
result.hasZaiCodingPlan = hasZaiCodingPlan
result.hasKimiForCoding = hasKimiForCoding
return result
}

View File

@@ -0,0 +1,9 @@
import { existsSync, mkdirSync } from "node:fs"
import { getConfigDir } from "./config-context"
export function ensureConfigDirectoryExists(): void {
const configDir = getConfigDir()
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
}

View File

@@ -0,0 +1,39 @@
interface NodeError extends Error {
code?: string
}
function isPermissionError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM"
}
function isFileNotFoundError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "ENOENT"
}
export function formatErrorWithSuggestion(err: unknown, context: string): string {
if (isPermissionError(err)) {
return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.`
}
if (isFileNotFoundError(err)) {
return `File not found while trying to ${context}. The file may have been deleted or moved.`
}
if (err instanceof SyntaxError) {
return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.`
}
const message = err instanceof Error ? err.message : String(err)
if (message.includes("ENOSPC")) {
return `Disk full: Cannot ${context}. Free up disk space and try again.`
}
if (message.includes("EROFS")) {
return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.`
}
return `Failed to ${context}: ${message}`
}

View File

@@ -0,0 +1,6 @@
import type { InstallConfig } from "../types"
import { generateModelConfig } from "../model-fallback"
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
return generateModelConfig(installConfig)
}

View File

@@ -0,0 +1,21 @@
export interface NpmDistTags {
latest?: string
beta?: string
next?: string
[tag: string]: string | undefined
}
const NPM_FETCH_TIMEOUT_MS = 5000
export async function fetchNpmDistTags(packageName: string): Promise<NpmDistTags | null> {
try {
const res = await fetch(`https://registry.npmjs.org/-/package/${encodeURIComponent(packageName)}/dist-tags`, {
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
})
if (!res.ok) return null
const data = (await res.json()) as NpmDistTags
return data
} catch {
return null
}
}

View File

@@ -0,0 +1,40 @@
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
import { initConfigContext } from "./config-context"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
interface OpenCodeBinaryResult {
binary: OpenCodeBinaryType
version: string
}
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
const version = output.trim()
initConfigContext(binary, version)
return { binary, version }
}
} catch {
continue
}
}
return null
}
export async function isOpenCodeInstalled(): Promise<boolean> {
const result = await findOpenCodeBinaryWithVersion()
return result !== null
}
export async function getOpenCodeVersion(): Promise<string | null> {
const result = await findOpenCodeBinaryWithVersion()
return result?.version ?? null
}

View File

@@ -0,0 +1,17 @@
import { existsSync } from "node:fs"
import { getConfigJson, getConfigJsonc } from "./config-context"
export type ConfigFormat = "json" | "jsonc" | "none"
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
const configJsonc = getConfigJsonc()
const configJson = getConfigJson()
if (existsSync(configJsonc)) {
return { format: "jsonc", path: configJsonc }
}
if (existsSync(configJson)) {
return { format: "json", path: configJson }
}
return { format: "none", path: configJson }
}

View File

@@ -0,0 +1,48 @@
import { readFileSync, statSync } from "node:fs"
import { parseJsonc } from "../../shared"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
interface ParseConfigResult {
config: OpenCodeConfig | null
error?: string
}
export interface OpenCodeConfig {
plugin?: string[]
[key: string]: unknown
}
function isEmptyOrWhitespace(content: string): boolean {
return content.trim().length === 0
}
export function parseOpenCodeConfigFileWithError(path: string): ParseConfigResult {
try {
const stat = statSync(path)
if (stat.size === 0) {
return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` }
}
const content = readFileSync(path, "utf-8")
if (isEmptyOrWhitespace(content)) {
return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` }
}
const config = parseJsonc<OpenCodeConfig>(content)
if (config === null || config === undefined) {
return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` }
}
if (typeof config !== "object" || Array.isArray(config)) {
return {
config: null,
error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}`,
}
}
return { config }
} catch (err) {
return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) }
}
}

View File

@@ -0,0 +1,19 @@
import { fetchNpmDistTags } from "./npm-dist-tags"
const PACKAGE_NAME = "oh-my-opencode"
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
if (distTags) {
const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)])
for (const tag of allTags) {
if (distTags[tag] === currentVersion) {
return `${PACKAGE_NAME}@${tag}`
}
}
}
return `${PACKAGE_NAME}@${currentVersion}`
}

View File

@@ -0,0 +1,67 @@
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
import { parseJsonc } from "../../shared"
import type { ConfigMergeResult, InstallConfig } from "../types"
import { getConfigDir, getOmoConfigPath } from "./config-context"
import { deepMergeRecord } from "./deep-merge-record"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { generateOmoConfig } from "./generate-omo-config"
function isEmptyOrWhitespace(content: string): boolean {
return content.trim().length === 0
}
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const omoConfigPath = getOmoConfigPath()
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(omoConfigPath)) {
try {
const stat = statSync(omoConfigPath)
const content = readFileSync(omoConfigPath, "utf-8")
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const existing = parseJsonc<Record<string, unknown>>(content)
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const merged = deepMergeRecord(existing, newConfig)
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
throw parseErr
}
} else {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: omoConfigPath }
} catch (err) {
return {
success: false,
configPath: omoConfigPath,
error: formatErrorWithSuggestion(err, "write oh-my-opencode config"),
}
}
}

View File

@@ -15,6 +15,12 @@ export * from "./opencode"
export * from "./plugin" export * from "./plugin"
export * from "./config" export * from "./config"
export * from "./model-resolution" export * from "./model-resolution"
export * from "./model-resolution-types"
export * from "./model-resolution-cache"
export * from "./model-resolution-config"
export * from "./model-resolution-effective-model"
export * from "./model-resolution-variant"
export * from "./model-resolution-details"
export * from "./auth" export * from "./auth"
export * from "./dependencies" export * from "./dependencies"
export * from "./gh" export * from "./gh"

View File

@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../../../shared"
import type { AvailableModelsInfo } from "./model-resolution-types"
function getOpenCodeCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
export function loadAvailableModelsFromCache(): AvailableModelsInfo {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
return { providers: [], modelCount: 0, cacheExists: false }
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data = parseJsonc<Record<string, { models?: Record<string, unknown> }>>(content)
const providers = Object.keys(data)
let modelCount = 0
for (const providerId of providers) {
const models = data[providerId]?.models
if (models && typeof models === "object") {
modelCount += Object.keys(models).length
}
}
return { providers, modelCount, cacheExists: true }
} catch {
return { providers: [], modelCount: 0, cacheExists: false }
}
}

View File

@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { detectConfigFile, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
import type { OmoConfig } from "./model-resolution-types"
const PACKAGE_NAME = "oh-my-opencode"
const USER_CONFIG_BASE = join(
getOpenCodeConfigPaths({ binary: "opencode", version: null }).configDir,
PACKAGE_NAME
)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
export function loadOmoConfig(): OmoConfig | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
try {
const content = readFileSync(projectDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
try {
const content = readFileSync(userDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
return null
}

View File

@@ -0,0 +1,56 @@
import { join } from "node:path"
import { getOpenCodeCacheDir } from "../../../shared"
import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant"
export function buildModelResolutionDetails(options: {
info: ModelResolutionInfo
available: AvailableModelsInfo
config: OmoConfig
}): string[] {
const details: string[] = []
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
details.push("═══ Available Models (from cache) ═══")
details.push("")
if (options.available.cacheExists) {
details.push(` Providers in cache: ${options.available.providers.length}`)
details.push(
` Sample: ${options.available.providers.slice(0, 6).join(", ")}${options.available.providers.length > 6 ? "..." : ""}`
)
details.push(` Total models: ${options.available.modelCount}`)
details.push(` Cache: ${cacheFile}`)
details.push(` Runtime: only connected providers used`)
details.push(` Refresh: opencode models --refresh`)
} else {
details.push(" ⚠ Cache not found. Run 'opencode' to populate.")
}
details.push("")
details.push("═══ Configured Models ═══")
details.push("")
details.push("Agents:")
for (const agent of options.info.agents) {
const marker = agent.userOverride ? "●" : "○"
const display = formatModelWithVariant(
agent.effectiveModel,
getEffectiveVariant(agent.name, agent.requirement, options.config)
)
details.push(` ${marker} ${agent.name}: ${display}`)
}
details.push("")
details.push("Categories:")
for (const category of options.info.categories) {
const marker = category.userOverride ? "●" : "○"
const display = formatModelWithVariant(
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, options.config)
)
details.push(` ${marker} ${category.name}: ${display}`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")
return details
}

View File

@@ -0,0 +1,27 @@
import type { ModelRequirement } from "../../../shared/model-requirements"
function formatProviderChain(providers: string[]): string {
return providers.join(" → ")
}
export function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return userOverride
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "unknown"
}
return `${firstEntry.providers[0]}/${firstEntry.model}`
}
export function buildEffectiveResolution(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return `User override: ${userOverride}`
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "No fallback chain defined"
}
return `Provider fallback: ${formatProviderChain(firstEntry.providers)}${firstEntry.model}`
}

View File

@@ -0,0 +1,35 @@
import type { ModelRequirement } from "../../../shared/model-requirements"
export interface AgentResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface CategoryResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface ModelResolutionInfo {
agents: AgentResolutionInfo[]
categories: CategoryResolutionInfo[]
}
export interface OmoConfig {
agents?: Record<string, { model?: string; variant?: string; category?: string }>
categories?: Record<string, { model?: string; variant?: string }>
}
export interface AvailableModelsInfo {
providers: string[]
modelCount: number
cacheExists: boolean
}

View File

@@ -0,0 +1,55 @@
import type { ModelRequirement } from "../../../shared/model-requirements"
import type { OmoConfig } from "./model-resolution-types"
export function formatModelWithVariant(model: string, variant?: string): string {
return variant ? `${model} (${variant})` : model
}
function getAgentOverride(
agentName: string,
config: OmoConfig
): { variant?: string; category?: string } | undefined {
const agentOverrides = config.agents
if (!agentOverrides) return undefined
return (
agentOverrides[agentName] ??
Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
)
}
export function getEffectiveVariant(
agentName: string,
requirement: ModelRequirement,
config: OmoConfig
): string | undefined {
const agentOverride = getAgentOverride(agentName, config)
if (agentOverride?.variant) {
return agentOverride.variant
}
const categoryName = agentOverride?.category
if (categoryName) {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
export function getCategoryEffectiveVariant(
categoryName: string,
requirement: ModelRequirement,
config: OmoConfig
): string | undefined {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}

View File

@@ -1,132 +1,14 @@
import { readFileSync, existsSync } from "node:fs"
import type { CheckResult, CheckDefinition } from "../types" import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants" import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
import { import {
AGENT_MODEL_REQUIREMENTS, AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS,
type ModelRequirement,
} from "../../../shared/model-requirements" } from "../../../shared/model-requirements"
import { homedir } from "node:os" import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types"
import { join } from "node:path" import { loadAvailableModelsFromCache } from "./model-resolution-cache"
import { loadOmoConfig } from "./model-resolution-config"
function getOpenCodeCacheDir(): string { import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
const xdgCache = process.env.XDG_CACHE_HOME import { buildModelResolutionDetails } from "./model-resolution-details"
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
function loadAvailableModels(): { providers: string[]; modelCount: number; cacheExists: boolean } {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
return { providers: [], modelCount: 0, cacheExists: false }
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data = JSON.parse(content) as Record<string, { models?: Record<string, unknown> }>
const providers = Object.keys(data)
let modelCount = 0
for (const providerId of providers) {
const models = data[providerId]?.models
if (models && typeof models === "object") {
modelCount += Object.keys(models).length
}
}
return { providers, modelCount, cacheExists: true }
} catch {
return { providers: [], modelCount: 0, cacheExists: false }
}
}
const PACKAGE_NAME = "oh-my-opencode"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
export interface AgentResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface CategoryResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface ModelResolutionInfo {
agents: AgentResolutionInfo[]
categories: CategoryResolutionInfo[]
}
interface OmoConfig {
agents?: Record<string, { model?: string; variant?: string; category?: string }>
categories?: Record<string, { model?: string; variant?: string }>
}
function loadConfig(): OmoConfig | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
try {
const content = readFileSync(projectDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
try {
const content = readFileSync(userDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
return null
}
function formatProviderChain(providers: string[]): string {
return providers.join(" → ")
}
function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return userOverride
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "unknown"
}
return `${firstEntry.providers[0]}/${firstEntry.model}`
}
function buildEffectiveResolution(
requirement: ModelRequirement,
userOverride?: string,
): string {
if (userOverride) {
return `User override: ${userOverride}`
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "No fallback chain defined"
}
return `Provider fallback: ${formatProviderChain(firstEntry.providers)}${firstEntry.model}`
}
export function getModelResolutionInfo(): ModelResolutionInfo { export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
@@ -184,116 +66,10 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
return { agents, categories } return { agents, categories }
} }
function formatModelWithVariant(model: string, variant?: string): string {
return variant ? `${model} (${variant})` : model
}
function getAgentOverride(
agentName: string,
config: OmoConfig,
): { variant?: string; category?: string } | undefined {
const agentOverrides = config.agents
if (!agentOverrides) return undefined
// Direct lookup first, then case-insensitive lookup (matches agent-variant.ts)
return (
agentOverrides[agentName] ??
Object.entries(agentOverrides).find(
([key]) => key.toLowerCase() === agentName.toLowerCase()
)?.[1]
)
}
function getEffectiveVariant(
name: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const agentOverride = getAgentOverride(name, config)
// Priority 1: Agent's direct variant override
if (agentOverride?.variant) {
return agentOverride.variant
}
// Priority 2: Agent's category -> category's variant (matches agent-variant.ts)
const categoryName = agentOverride?.category
if (categoryName) {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
}
// Priority 3: Fall back to requirement's fallback chain
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
interface AvailableModelsInfo {
providers: string[]
modelCount: number
cacheExists: boolean
}
function getCategoryEffectiveVariant(
categoryName: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo, config: OmoConfig): string[] {
const details: string[] = []
details.push("═══ Available Models (from cache) ═══")
details.push("")
if (available.cacheExists) {
details.push(` Providers in cache: ${available.providers.length}`)
details.push(` Sample: ${available.providers.slice(0, 6).join(", ")}${available.providers.length > 6 ? "..." : ""}`)
details.push(` Total models: ${available.modelCount}`)
details.push(` Cache: ~/.cache/opencode/models.json`)
details.push(` Runtime: only connected providers used`)
details.push(` Refresh: opencode models --refresh`)
} else {
details.push(" ⚠ Cache not found. Run 'opencode' to populate.")
}
details.push("")
details.push("═══ Configured Models ═══")
details.push("")
details.push("Agents:")
for (const agent of info.agents) {
const marker = agent.userOverride ? "●" : "○"
const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.name, agent.requirement, config))
details.push(` ${marker} ${agent.name}: ${display}`)
}
details.push("")
details.push("Categories:")
for (const category of info.categories) {
const marker = category.userOverride ? "●" : "○"
const display = formatModelWithVariant(
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, config)
)
details.push(` ${marker} ${category.name}: ${display}`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")
return details
}
export async function checkModelResolution(): Promise<CheckResult> { export async function checkModelResolution(): Promise<CheckResult> {
const config = loadConfig() ?? {} const config = loadOmoConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config) const info = getModelResolutionInfoWithOverrides(config)
const available = loadAvailableModels() const available = loadAvailableModelsFromCache()
const agentCount = info.agents.length const agentCount = info.agents.length
const categoryCount = info.categories.length const categoryCount = info.categories.length
@@ -308,7 +84,7 @@ export async function checkModelResolution(): Promise<CheckResult> {
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION], name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
status: available.cacheExists ? "pass" : "warn", status: available.cacheExists ? "pass" : "warn",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`, message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
details: buildDetailsArray(info, available, config), details: buildModelResolutionDetails({ info, available, config }),
} }
} }

View File

@@ -0,0 +1,55 @@
import {
AGENT_MODEL_REQUIREMENTS,
type FallbackEntry,
} from "../shared/model-requirements"
import type { ProviderAvailability } from "./model-fallback-types"
import { isProviderAvailable } from "./provider-availability"
import { transformModelForProvider } from "./provider-model-id-transform"
export function resolveModelFromChain(
fallbackChain: FallbackEntry[],
availability: ProviderAvailability
): { model: string; variant?: string } | null {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (isProviderAvailable(provider, availability)) {
const transformedModel = transformModelForProvider(provider, entry.model)
return {
model: `${provider}/${transformedModel}`,
variant: entry.variant,
}
}
}
}
return null
}
export function getSisyphusFallbackChain(): FallbackEntry[] {
return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain
}
export function isAnyFallbackEntryAvailable(
fallbackChain: FallbackEntry[],
availability: ProviderAvailability
): boolean {
return fallbackChain.some((entry) =>
entry.providers.some((provider) => isProviderAvailable(provider, availability))
)
}
export function isRequiredModelAvailable(
requiresModel: string,
fallbackChain: FallbackEntry[],
availability: ProviderAvailability
): boolean {
const matchingEntry = fallbackChain.find((entry) => entry.model === requiresModel)
if (!matchingEntry) return false
return matchingEntry.providers.some((provider) => isProviderAvailable(provider, availability))
}
export function isRequiredProviderAvailable(
requiredProviders: string[],
availability: ProviderAvailability
): boolean {
return requiredProviders.some((provider) => isProviderAvailable(provider, availability))
}

View File

@@ -0,0 +1,112 @@
import {
findPluginEntry,
getCachedVersion,
getLatestVersion,
getLocalDevVersion,
isLocalDevMode,
} from "../../hooks/auto-update-checker/checker"
import type { GetLocalVersionOptions, VersionInfo } from "./types"
import { formatJsonOutput, formatVersionOutput } from "./formatter"
export async function getLocalVersion(
options: GetLocalVersionOptions = {}
): Promise<number> {
const directory = options.directory ?? process.cwd()
try {
if (isLocalDevMode(directory)) {
const currentVersion = getLocalDevVersion(directory) ?? getCachedVersion()
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: true,
isPinned: false,
pinnedVersion: null,
status: "local-dev",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const pluginInfo = findPluginEntry(directory)
if (pluginInfo?.isPinned) {
const info: VersionInfo = {
currentVersion: pluginInfo.pinnedVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: true,
pinnedVersion: pluginInfo.pinnedVersion,
status: "pinned",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const currentVersion = getCachedVersion()
if (!currentVersion) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "unknown",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
const { extractChannel } = await import("../../hooks/auto-update-checker/index")
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const isUpToDate = currentVersion === latestVersion
const info: VersionInfo = {
currentVersion,
latestVersion,
isUpToDate,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: isUpToDate ? "up-to-date" : "outdated",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
} catch (error) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
}

View File

@@ -1,106 +1,2 @@
import { getCachedVersion, getLatestVersion, isLocalDevMode, findPluginEntry } from "../../hooks/auto-update-checker/checker" export { getLocalVersion } from "./get-local-version"
import type { GetLocalVersionOptions, VersionInfo } from "./types"
import { formatVersionOutput, formatJsonOutput } from "./formatter"
export async function getLocalVersion(options: GetLocalVersionOptions = {}): Promise<number> {
const directory = options.directory ?? process.cwd()
try {
if (isLocalDevMode(directory)) {
const currentVersion = getCachedVersion()
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: true,
isPinned: false,
pinnedVersion: null,
status: "local-dev",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const pluginInfo = findPluginEntry(directory)
if (pluginInfo?.isPinned) {
const info: VersionInfo = {
currentVersion: pluginInfo.pinnedVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: true,
pinnedVersion: pluginInfo.pinnedVersion,
status: "pinned",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const currentVersion = getCachedVersion()
if (!currentVersion) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "unknown",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
const { extractChannel } = await import("../../hooks/auto-update-checker/index")
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const isUpToDate = currentVersion === latestVersion
const info: VersionInfo = {
currentVersion,
latestVersion,
isUpToDate,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: isUpToDate ? "up-to-date" : "outdated",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
} catch (error) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
}
export * from "./types" export * from "./types"

View File

@@ -1,190 +1,4 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Command } from "commander" import { runCli } from "./cli-program"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import { createMcpOAuthCommand } from "./mcp-oauth"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
import type { DoctorOptions } from "./doctor"
import packageJson from "../../package.json" with { type: "json" }
const VERSION = packageJson.version runCli()
const program = new Command()
program
.name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
.version(VERSION, "-v, --version", "Show version number")
.enablePositionalOptions()
program
.command("install")
.description("Install and configure oh-my-opencode with interactive setup")
.option("--no-tui", "Run in non-interactive mode (requires all options)")
.option("--claude <value>", "Claude subscription: no, yes, max20")
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
OpenAI Native openai/ models (GPT-5.2 for Oracle)
Gemini Native google/ models (Gemini 3 Pro, Flash)
Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
`)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
process.exit(exitCode)
})
program
.command("run <message>")
.allowUnknownOption()
.passThroughOptions()
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
.option("--attach <url>", "Attach to existing opencode server URL")
.option("--on-complete <command>", "Shell command to run after completion")
.option("--json", "Output structured JSON result to stdout")
.option("--session-id <id>", "Resume existing session instead of creating new one")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode run "Fix the bug in index.ts"
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
$ bunx oh-my-opencode run --port 4321 "Fix the bug"
$ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug"
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
Agent resolution order:
1) --agent flag
2) OPENCODE_DEFAULT_AGENT
3) oh-my-opencode.json "default_run_agent"
4) Sisyphus (fallback)
Available core agents:
Sisyphus, Hephaestus, Prometheus, Atlas
Unlike 'opencode run', this command waits until:
- All todos are completed or cancelled
- All child sessions (background tasks) are idle
`)
.action(async (message: string, options) => {
if (options.port && options.attach) {
console.error("Error: --port and --attach are mutually exclusive")
process.exit(1)
}
const runOptions: RunOptions = {
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
port: options.port,
attach: options.attach,
onComplete: options.onComplete,
json: options.json ?? false,
sessionId: options.sessionId,
}
const exitCode = await run(runOptions)
process.exit(exitCode)
})
program
.command("get-local-version")
.description("Show current installed version and check for updates")
.option("-d, --directory <path>", "Working directory to check config from")
.option("--json", "Output in JSON format for scripting")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode get-local-version
$ bunx oh-my-opencode get-local-version --json
$ bunx oh-my-opencode get-local-version --directory /path/to/project
This command shows:
- Current installed version
- Latest available version on npm
- Whether you're up to date
- Special modes (local dev, pinned version)
`)
.action(async (options) => {
const versionOptions: GetLocalVersionOptions = {
directory: options.directory,
json: options.json ?? false,
}
const exitCode = await getLocalVersion(versionOptions)
process.exit(exitCode)
})
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode doctor
$ bunx oh-my-opencode doctor --verbose
$ bunx oh-my-opencode doctor --json
$ bunx oh-my-opencode doctor --category authentication
Categories:
installation Check OpenCode and plugin installation
configuration Validate configuration files
authentication Check auth provider status
dependencies Check external dependencies
tools Check LSP and MCP servers
updates Check for version updates
`)
.action(async (options) => {
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")
.action(() => {
console.log(`oh-my-opencode v${VERSION}`)
})
program.addCommand(createMcpOAuthCommand())
program.parse()

View File

@@ -0,0 +1,189 @@
import color from "picocolors"
import type {
BooleanArg,
ClaudeSubscription,
DetectedConfig,
InstallArgs,
InstallConfig,
} from "./types"
export const SYMBOLS = {
check: color.green("[OK]"),
cross: color.red("[X]"),
arrow: color.cyan("->"),
bullet: color.dim("*"),
info: color.blue("[i]"),
warn: color.yellow("[!]"),
star: color.yellow("*"),
}
function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○")
const label = enabled ? color.white(name) : color.dim(name)
const suffix = detail ? color.dim(` (${detail})`) : ""
return ` ${status} ${label}${suffix}`
}
export function formatConfigSummary(config: InstallConfig): string {
const lines: string[] = []
lines.push(color.bold(color.white("Configuration Summary")))
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.2 for Oracle"))
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
lines.push("")
lines.push(color.dim("─".repeat(40)))
lines.push("")
lines.push(color.bold(color.white("Model Assignment")))
lines.push("")
lines.push(` ${SYMBOLS.info} Models auto-configured based on provider priority`)
lines.push(` ${SYMBOLS.bullet} Priority: Native > Copilot > OpenCode Zen > Z.ai`)
return lines.join("\n")
}
export function printHeader(isUpdate: boolean): void {
const mode = isUpdate ? "Update" : "Install"
console.log()
console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `)))
console.log()
}
export function printStep(step: number, total: number, message: string): void {
const progress = color.dim(`[${step}/${total}]`)
console.log(`${progress} ${message}`)
}
export function printSuccess(message: string): void {
console.log(`${SYMBOLS.check} ${message}`)
}
export function printError(message: string): void {
console.log(`${SYMBOLS.cross} ${color.red(message)}`)
}
export function printInfo(message: string): void {
console.log(`${SYMBOLS.info} ${message}`)
}
export function printWarning(message: string): void {
console.log(`${SYMBOLS.warn} ${color.yellow(message)}`)
}
export function printBox(content: string, title?: string): void {
const lines = content.split("\n")
const maxWidth =
Math.max(
...lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").length),
title?.length ?? 0,
) + 4
const border = color.dim("─".repeat(maxWidth))
console.log()
if (title) {
console.log(
color.dim("┌─") +
color.bold(` ${title} `) +
color.dim("─".repeat(maxWidth - title.length - 4)) +
color.dim("┐"),
)
} else {
console.log(color.dim("┌") + border + color.dim("┐"))
}
for (const line of lines) {
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "")
const padding = maxWidth - stripped.length
console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│"))
}
console.log(color.dim("└") + border + color.dim("┘"))
console.log()
}
export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)")
} else if (!["no", "yes", "max20"].includes(args.claude)) {
errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`)
}
if (args.gemini === undefined) {
errors.push("--gemini is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.gemini)) {
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
}
if (args.copilot === undefined) {
errors.push("--copilot is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.copilot)) {
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
}
if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`)
}
if (args.opencodeZen !== undefined && !["no", "yes"].includes(args.opencodeZen)) {
errors.push(`Invalid --opencode-zen value: ${args.opencodeZen} (expected: no, yes)`)
}
if (args.zaiCodingPlan !== undefined && !["no", "yes"].includes(args.zaiCodingPlan)) {
errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`)
}
if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) {
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
}
return { valid: errors.length === 0, errors }
}
export function argsToConfig(args: InstallArgs): InstallConfig {
return {
hasClaude: args.claude !== "no",
isMax20: args.claude === "max20",
hasOpenAI: args.openai === "yes",
hasGemini: args.gemini === "yes",
hasCopilot: args.copilot === "yes",
hasOpencodeZen: args.opencodeZen === "yes",
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes",
}
}
export function detectedToInitialValues(detected: DetectedConfig): {
claude: ClaudeSubscription
openai: BooleanArg
gemini: BooleanArg
copilot: BooleanArg
opencodeZen: BooleanArg
zaiCodingPlan: BooleanArg
kimiForCoding: BooleanArg
} {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
claude = detected.isMax20 ? "max20" : "yes"
}
return {
claude,
openai: detected.hasOpenAI ? "yes" : "no",
gemini: detected.hasGemini ? "yes" : "no",
copilot: detected.hasCopilot ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
}
}

View File

@@ -1,542 +1,10 @@
import * as p from "@clack/prompts"
import color from "picocolors"
import type { InstallArgs, InstallConfig, ClaudeSubscription, BooleanArg, DetectedConfig } from "./types"
import {
addPluginToOpenCodeConfig,
writeOmoConfig,
isOpenCodeInstalled,
getOpenCodeVersion,
addAuthPlugins,
addProviderConfig,
detectCurrentConfig,
} from "./config-manager"
import { shouldShowChatGPTOnlyWarning } from "./model-fallback"
import packageJson from "../../package.json" with { type: "json" } import packageJson from "../../package.json" with { type: "json" }
import type { InstallArgs } from "./types"
import { runCliInstaller } from "./cli-installer"
import { runTuiInstaller } from "./tui-installer"
const VERSION = packageJson.version const VERSION = packageJson.version
const SYMBOLS = {
check: color.green("[OK]"),
cross: color.red("[X]"),
arrow: color.cyan("->"),
bullet: color.dim("*"),
info: color.blue("[i]"),
warn: color.yellow("[!]"),
star: color.yellow("*"),
}
function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○")
const label = enabled ? color.white(name) : color.dim(name)
const suffix = detail ? color.dim(` (${detail})`) : ""
return ` ${status} ${label}${suffix}`
}
function formatConfigSummary(config: InstallConfig): string {
const lines: string[] = []
lines.push(color.bold(color.white("Configuration Summary")))
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.2 for Oracle"))
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
lines.push("")
lines.push(color.dim("─".repeat(40)))
lines.push("")
lines.push(color.bold(color.white("Model Assignment")))
lines.push("")
lines.push(` ${SYMBOLS.info} Models auto-configured based on provider priority`)
lines.push(` ${SYMBOLS.bullet} Priority: Native > Copilot > OpenCode Zen > Z.ai`)
return lines.join("\n")
}
function printHeader(isUpdate: boolean): void {
const mode = isUpdate ? "Update" : "Install"
console.log()
console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `)))
console.log()
}
function printStep(step: number, total: number, message: string): void {
const progress = color.dim(`[${step}/${total}]`)
console.log(`${progress} ${message}`)
}
function printSuccess(message: string): void {
console.log(`${SYMBOLS.check} ${message}`)
}
function printError(message: string): void {
console.log(`${SYMBOLS.cross} ${color.red(message)}`)
}
function printInfo(message: string): void {
console.log(`${SYMBOLS.info} ${message}`)
}
function printWarning(message: string): void {
console.log(`${SYMBOLS.warn} ${color.yellow(message)}`)
}
function printBox(content: string, title?: string): void {
const lines = content.split("\n")
const maxWidth = Math.max(...lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, "").length), title?.length ?? 0) + 4
const border = color.dim("─".repeat(maxWidth))
console.log()
if (title) {
console.log(color.dim("┌─") + color.bold(` ${title} `) + color.dim("─".repeat(maxWidth - title.length - 4)) + color.dim("┐"))
} else {
console.log(color.dim("┌") + border + color.dim("┐"))
}
for (const line of lines) {
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "")
const padding = maxWidth - stripped.length
console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│"))
}
console.log(color.dim("└") + border + color.dim("┘"))
console.log()
}
function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)")
} else if (!["no", "yes", "max20"].includes(args.claude)) {
errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`)
}
if (args.gemini === undefined) {
errors.push("--gemini is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.gemini)) {
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
}
if (args.copilot === undefined) {
errors.push("--copilot is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.copilot)) {
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
}
if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`)
}
if (args.opencodeZen !== undefined && !["no", "yes"].includes(args.opencodeZen)) {
errors.push(`Invalid --opencode-zen value: ${args.opencodeZen} (expected: no, yes)`)
}
if (args.zaiCodingPlan !== undefined && !["no", "yes"].includes(args.zaiCodingPlan)) {
errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`)
}
if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) {
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
}
return { valid: errors.length === 0, errors }
}
function argsToConfig(args: InstallArgs): InstallConfig {
return {
hasClaude: args.claude !== "no",
isMax20: args.claude === "max20",
hasOpenAI: args.openai === "yes",
hasGemini: args.gemini === "yes",
hasCopilot: args.copilot === "yes",
hasOpencodeZen: args.opencodeZen === "yes",
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes",
}
}
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; openai: BooleanArg; gemini: BooleanArg; copilot: BooleanArg; opencodeZen: BooleanArg; zaiCodingPlan: BooleanArg; kimiForCoding: BooleanArg } {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
claude = detected.isMax20 ? "max20" : "yes"
}
return {
claude,
openai: detected.hasOpenAI ? "yes" : "no",
gemini: detected.hasGemini ? "yes" : "no",
copilot: detected.hasCopilot ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
}
}
async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | null> {
const initial = detectedToInitialValues(detected)
const claude = await p.select({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
initialValue: initial.claude,
})
if (p.isCancel(claude)) {
p.cancel("Installation cancelled.")
return null
}
const openai = await p.select({
message: "Do you have an OpenAI/ChatGPT Plus subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Oracle will use fallback models" },
{ value: "yes" as const, label: "Yes", hint: "GPT-5.2 for Oracle (high-IQ debugging)" },
],
initialValue: initial.openai,
})
if (p.isCancel(openai)) {
p.cancel("Installation cancelled.")
return null
}
const gemini = await p.select({
message: "Will you integrate Google Gemini?",
options: [
{ value: "no" as const, label: "No", hint: "Frontend/docs agents will use fallback" },
{ value: "yes" as const, label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" },
],
initialValue: initial.gemini,
})
if (p.isCancel(gemini)) {
p.cancel("Installation cancelled.")
return null
}
const copilot = await p.select({
message: "Do you have a GitHub Copilot subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Only native providers will be used" },
{ value: "yes" as const, label: "Yes", hint: "Fallback option when native providers unavailable" },
],
initialValue: initial.copilot,
})
if (p.isCancel(copilot)) {
p.cancel("Installation cancelled.")
return null
}
const opencodeZen = await p.select({
message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." },
],
initialValue: initial.opencodeZen,
})
if (p.isCancel(opencodeZen)) {
p.cancel("Installation cancelled.")
return null
}
const zaiCodingPlan = await p.select({
message: "Do you have a Z.ai Coding Plan subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" },
],
initialValue: initial.zaiCodingPlan,
})
if (p.isCancel(zaiCodingPlan)) {
p.cancel("Installation cancelled.")
return null
}
const kimiForCoding = await p.select({
message: "Do you have a Kimi For Coding subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" },
],
initialValue: initial.kimiForCoding,
})
if (p.isCancel(kimiForCoding)) {
p.cancel("Installation cancelled.")
return null
}
return {
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasOpenAI: openai === "yes",
hasGemini: gemini === "yes",
hasCopilot: copilot === "yes",
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
}
}
async function runNonTuiInstall(args: InstallArgs): Promise<number> {
const validation = validateNonTuiArgs(args)
if (!validation.valid) {
printHeader(false)
printError("Validation failed:")
for (const err of validation.errors) {
console.log(` ${SYMBOLS.bullet} ${err}`)
}
console.log()
printInfo("Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --gemini=<no|yes> --copilot=<no|yes>")
console.log()
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
printHeader(isUpdate)
const totalSteps = 6
let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
const version = await getOpenCodeVersion()
if (!installed) {
printWarning("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${version ?? ""} detected`)
}
if (isUpdate) {
const initial = detectedToInitialValues(detected)
printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`)
if (config.hasGemini) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
printError(`Failed: ${authResult.error}`)
return 1
}
printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`)
printStep(step++, totalSteps, "Adding provider configurations...")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
printError(`Failed: ${providerResult.error}`)
return 1
}
printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`)
} else {
step += 2
}
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
console.log(color.dim(" • Reduced orchestration quality"))
console.log(color.dim(" • Weaker tool selection and delegation"))
console.log(color.dim(" • Less reliable task completion"))
console.log()
console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience."))
console.log()
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
console.log()
printBox(
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"The Magic Word"
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
printBox(
`Run ${color.cyan("opencode auth login")} and select your provider:\n` +
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
(config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") +
(config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""),
"Authenticate Your Providers"
)
}
return 0
}
export async function install(args: InstallArgs): Promise<number> { export async function install(args: InstallArgs): Promise<number> {
if (!args.tui) { return args.tui ? runTuiInstaller(args, VERSION) : runCliInstaller(args, VERSION)
return runNonTuiInstall(args)
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... ")))
if (isUpdate) {
const initial = detectedToInitialValues(detected)
p.log.info(`Existing configuration detected: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const s = p.spinner()
s.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
const version = await getOpenCodeVersion()
if (!installed) {
s.stop(`OpenCode binary not found ${color.yellow("[!]")}`)
p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else {
s.stop(`OpenCode ${version ?? "installed"} ${color.green("[OK]")}`)
}
const config = await runTuiMode(detected)
if (!config) return 1
s.start("Adding oh-my-opencode to OpenCode config")
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
if (!pluginResult.success) {
s.stop(`Failed to add plugin: ${pluginResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
if (config.hasGemini) {
s.start("Adding auth plugins (fetching latest versions)")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
s.stop(`Failed to add auth plugins: ${authResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Auth plugins added to ${color.cyan(authResult.configPath)}`)
s.start("Adding provider configurations")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
s.stop(`Failed to add provider config: ${providerResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`)
}
s.start("Writing oh-my-opencode configuration")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
s.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
console.log(color.dim(" • Reduced orchestration quality"))
console.log(color.dim(" • Weaker tool selection and delegation"))
console.log(color.dim(" • Less reliable task completion"))
console.log()
console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience."))
console.log()
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`)
p.note(
`Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"The Magic Word"
)
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
p.outro(color.green("oMoMoMoMo... Enjoy!"))
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
const providers: string[] = []
if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`)
if (config.hasGemini) providers.push(`Google ${color.gray("→ OAuth with Antigravity")}`)
if (config.hasCopilot) providers.push(`GitHub ${color.gray("→ Copilot")}`)
console.log()
console.log(color.bold("Authenticate Your Providers"))
console.log()
console.log(` Run ${color.cyan("opencode auth login")} and select:`)
for (const provider of providers) {
console.log(` ${SYMBOLS.bullet} ${provider}`)
}
console.log()
}
return 0
} }

View File

@@ -0,0 +1,29 @@
export interface ProviderAvailability {
native: {
claude: boolean
openai: boolean
gemini: boolean
}
opencodeZen: boolean
copilot: boolean
zai: boolean
kimiForCoding: boolean
isMaxPlan: boolean
}
export interface AgentConfig {
model: string
variant?: string
}
export interface CategoryConfig {
model: string
variant?: string
}
export interface GeneratedOmoConfig {
$schema: string
agents?: Record<string, AgentConfig>
categories?: Record<string, CategoryConfig>
[key: string]: unknown
}

View File

@@ -1,133 +1,27 @@
import { import {
AGENT_MODEL_REQUIREMENTS, AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS,
type FallbackEntry,
} from "../shared/model-requirements" } from "../shared/model-requirements"
import type { InstallConfig } from "./types" import type { InstallConfig } from "./types"
interface ProviderAvailability { import type { AgentConfig, CategoryConfig, GeneratedOmoConfig } from "./model-fallback-types"
native: { import { toProviderAvailability } from "./provider-availability"
claude: boolean import {
openai: boolean getSisyphusFallbackChain,
gemini: boolean isAnyFallbackEntryAvailable,
} isRequiredModelAvailable,
opencodeZen: boolean isRequiredProviderAvailable,
copilot: boolean resolveModelFromChain,
zai: boolean } from "./fallback-chain-resolution"
kimiForCoding: boolean
isMaxPlan: boolean
}
interface AgentConfig { export type { GeneratedOmoConfig } from "./model-fallback-types"
model: string
variant?: string
}
interface CategoryConfig {
model: string
variant?: string
}
export interface GeneratedOmoConfig {
$schema: string
agents?: Record<string, AgentConfig>
categories?: Record<string, CategoryConfig>
[key: string]: unknown
}
const ZAI_MODEL = "zai-coding-plan/glm-4.7" const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free" const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json" const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
function toProviderAvailability(config: InstallConfig): ProviderAvailability {
return {
native: {
claude: config.hasClaude,
openai: config.hasOpenAI,
gemini: config.hasGemini,
},
opencodeZen: config.hasOpencodeZen,
copilot: config.hasCopilot,
zai: config.hasZaiCodingPlan,
kimiForCoding: config.hasKimiForCoding,
isMaxPlan: config.isMax20,
}
}
function isProviderAvailable(provider: string, avail: ProviderAvailability): boolean {
const mapping: Record<string, boolean> = {
anthropic: avail.native.claude,
openai: avail.native.openai,
google: avail.native.gemini,
"github-copilot": avail.copilot,
opencode: avail.opencodeZen,
"zai-coding-plan": avail.zai,
"kimi-for-coding": avail.kimiForCoding,
}
return mapping[provider] ?? false
}
function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
.replace("gemini-3-pro", "gemini-3-pro-preview")
.replace("gemini-3-flash", "gemini-3-flash-preview")
}
return model
}
function resolveModelFromChain(
fallbackChain: FallbackEntry[],
avail: ProviderAvailability
): { model: string; variant?: string } | null {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (isProviderAvailable(provider, avail)) {
const transformedModel = transformModelForProvider(provider, entry.model)
return {
model: `${provider}/${transformedModel}`,
variant: entry.variant,
}
}
}
}
return null
}
function getSisyphusFallbackChain(): FallbackEntry[] {
return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain
}
function isAnyFallbackEntryAvailable(
fallbackChain: FallbackEntry[],
avail: ProviderAvailability
): boolean {
return fallbackChain.some((entry) =>
entry.providers.some((provider) => isProviderAvailable(provider, avail))
)
}
function isRequiredModelAvailable(
requiresModel: string,
fallbackChain: FallbackEntry[],
avail: ProviderAvailability
): boolean {
const matchingEntry = fallbackChain.find((entry) => entry.model === requiresModel)
if (!matchingEntry) return false
return matchingEntry.providers.some((provider) => isProviderAvailable(provider, avail))
}
function isRequiredProviderAvailable(
requiredProviders: string[],
avail: ProviderAvailability
): boolean {
return requiredProviders.some((provider) => isProviderAvailable(provider, avail))
}
export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
const avail = toProviderAvailability(config) const avail = toProviderAvailability(config)

View File

@@ -0,0 +1,30 @@
import type { InstallConfig } from "./types"
import type { ProviderAvailability } from "./model-fallback-types"
export function toProviderAvailability(config: InstallConfig): ProviderAvailability {
return {
native: {
claude: config.hasClaude,
openai: config.hasOpenAI,
gemini: config.hasGemini,
},
opencodeZen: config.hasOpencodeZen,
copilot: config.hasCopilot,
zai: config.hasZaiCodingPlan,
kimiForCoding: config.hasKimiForCoding,
isMaxPlan: config.isMax20,
}
}
export function isProviderAvailable(provider: string, availability: ProviderAvailability): boolean {
const mapping: Record<string, boolean> = {
anthropic: availability.native.claude,
openai: availability.native.openai,
google: availability.native.gemini,
"github-copilot": availability.copilot,
opencode: availability.opencodeZen,
"zai-coding-plan": availability.zai,
"kimi-for-coding": availability.kimiForCoding,
}
return mapping[provider] ?? false
}

View File

@@ -0,0 +1,12 @@
export function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
.replace("gemini-3-pro", "gemini-3-pro-preview")
.replace("gemini-3-flash", "gemini-3-flash-preview")
}
return model
}

View File

@@ -0,0 +1,140 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
SessionErrorProps,
} from "./types"
export function serializeError(error: unknown): string {
if (!error) return "Unknown error"
if (error instanceof Error) {
const parts = [error.message]
if (error.cause) {
parts.push(`Cause: ${serializeError(error.cause)}`)
}
return parts.join(" | ")
}
if (typeof error === "string") {
return error
}
if (typeof error === "object") {
const obj = error as Record<string, unknown>
const messagePaths = [
obj.message,
obj.error,
(obj.data as Record<string, unknown>)?.message,
(obj.data as Record<string, unknown>)?.error,
(obj.error as Record<string, unknown>)?.message,
]
for (const msg of messagePaths) {
if (typeof msg === "string" && msg.length > 0) {
return msg
}
}
try {
const json = JSON.stringify(error, null, 2)
if (json !== "{}") {
return json
}
} catch (_) {
void _
}
}
return String(error)
}
function getSessionTag(ctx: RunContext, payload: EventPayload): string {
const props = payload.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = props?.sessionID ?? info?.sessionID
const isMainSession = sessionID === ctx.sessionID
if (isMainSession) return pc.green("[MAIN]")
if (sessionID) return pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
return pc.dim("[system]")
}
export function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const sessionTag = getSessionTag(ctx, payload)
const props = payload.properties as Record<string, unknown> | undefined
switch (payload.type) {
case "session.idle":
case "session.status": {
const status = (props?.status as { type?: string })?.type ?? "idle"
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
break
}
case "message.part.updated": {
const partProps = props as MessagePartUpdatedProps | undefined
const part = partProps?.part
if (part?.type === "tool-invocation") {
const toolPart = part as { toolName?: string; state?: string }
console.error(pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`))
} else if (part?.type === "text" && part.text) {
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
console.error(pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`))
}
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const model = msgProps?.info?.modelID
const agent = msgProps?.info?.agent
const details = [role, agent, model].filter(Boolean).join(", ")
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
break
}
case "tool.execute": {
const toolProps = props as ToolExecuteProps | undefined
const toolName = toolProps?.name ?? "unknown"
const input = toolProps?.input ?? {}
let inputStr: string
try {
inputStr = JSON.stringify(input)
} catch {
try {
inputStr = String(input)
} catch {
inputStr = "[unserializable]"
}
}
const inputPreview = inputStr.slice(0, 150)
console.error(pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`))
console.error(pc.dim(` input: ${inputPreview}${inputStr.length >= 150 ? "..." : ""}`))
break
}
case "tool.result": {
const resultProps = props as ToolResultProps | undefined
const output = resultProps?.output ?? ""
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
console.error(pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`))
break
}
case "session.error": {
const errorProps = props as SessionErrorProps | undefined
const errorMsg = serializeError(errorProps?.error)
console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`))
break
}
default:
console.error(pc.dim(`${sessionTag} ${payload.type}`))
}
}

View File

@@ -0,0 +1,121 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
SessionIdleProps,
SessionStatusProps,
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
} from "./types"
import type { EventState } from "./event-state"
import { serializeError } from "./event-formatting"
export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.idle") return
const props = payload.properties as SessionIdleProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionIdle = true
}
}
export function handleSessionStatus(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.status") return
const props = payload.properties as SessionStatusProps | undefined
if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") {
state.mainSessionIdle = false
}
}
export function handleSessionError(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.error") return
const props = payload.properties as SessionErrorProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionError = true
state.lastError = serializeError(props?.error)
console.error(pc.red(`\n[session.error] ${state.lastError}`))
}
}
export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "message.part.updated") return
const props = payload.properties as MessagePartUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const part = props.part
if (!part) return
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
state.hasReceivedMeaningfulWork = true
}
state.lastPartText = part.text
}
}
export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "message.updated") return
const props = payload.properties as MessageUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
state.hasReceivedMeaningfulWork = true
state.messageCount++
state.lastPartText = ""
}
export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "tool.execute") return
const props = payload.properties as ToolExecuteProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const toolName = props?.name || "unknown"
state.currentTool = toolName
let inputPreview = ""
if (props?.input) {
const input = props.input
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
}
export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "tool.result") return
const props = payload.properties as ToolResultProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const output = props?.output || ""
const maxLen = 200
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
}
state.currentTool = null
state.lastPartText = ""
}

View File

@@ -0,0 +1,25 @@
export interface EventState {
mainSessionIdle: boolean
mainSessionError: boolean
lastError: string | null
lastOutput: string
lastPartText: string
currentTool: string | null
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
hasReceivedMeaningfulWork: boolean
/** Count of assistant messages for the main session */
messageCount: number
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
hasReceivedMeaningfulWork: false,
messageCount: 0,
}
}

View File

@@ -0,0 +1,43 @@
import pc from "picocolors"
import type { RunContext, EventPayload } from "./types"
import type { EventState } from "./event-state"
import { logEventVerbose } from "./event-formatting"
import {
handleSessionError,
handleSessionIdle,
handleSessionStatus,
handleMessagePartUpdated,
handleMessageUpdated,
handleToolExecute,
handleToolResult,
} from "./event-handlers"
export async function processEvents(
ctx: RunContext,
stream: AsyncIterable<unknown>,
state: EventState
): Promise<void> {
for await (const event of stream) {
if (ctx.abortController.signal.aborted) break
try {
const payload = event as EventPayload
if (!payload?.type) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
continue
}
logEventVerbose(ctx, payload)
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)
} catch (err) {
console.error(pc.red(`[event error] ${err}`))
}
}
}

View File

@@ -1,329 +1,4 @@
import pc from "picocolors" export type { EventState } from "./event-state"
import type { export { createEventState } from "./event-state"
RunContext, export { serializeError } from "./event-formatting"
EventPayload, export { processEvents } from "./event-stream-processor"
SessionIdleProps,
SessionStatusProps,
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
} from "./types"
export function serializeError(error: unknown): string {
if (!error) return "Unknown error"
if (error instanceof Error) {
const parts = [error.message]
if (error.cause) {
parts.push(`Cause: ${serializeError(error.cause)}`)
}
return parts.join(" | ")
}
if (typeof error === "string") {
return error
}
if (typeof error === "object") {
const obj = error as Record<string, unknown>
const messagePaths = [
obj.message,
obj.error,
(obj.data as Record<string, unknown>)?.message,
(obj.data as Record<string, unknown>)?.error,
(obj.error as Record<string, unknown>)?.message,
]
for (const msg of messagePaths) {
if (typeof msg === "string" && msg.length > 0) {
return msg
}
}
try {
const json = JSON.stringify(error, null, 2)
if (json !== "{}") {
return json
}
} catch (_) {
void _
}
}
return String(error)
}
export interface EventState {
mainSessionIdle: boolean
mainSessionError: boolean
lastError: string | null
lastOutput: string
lastPartText: string
currentTool: string | null
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
hasReceivedMeaningfulWork: boolean
/** Count of assistant messages for the main session */
messageCount: number
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
hasReceivedMeaningfulWork: false,
messageCount: 0,
}
}
export async function processEvents(
ctx: RunContext,
stream: AsyncIterable<unknown>,
state: EventState
): Promise<void> {
for await (const event of stream) {
if (ctx.abortController.signal.aborted) break
try {
const payload = event as EventPayload
if (!payload?.type) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
continue
}
logEventVerbose(ctx, payload)
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)
} catch (err) {
console.error(pc.red(`[event error] ${err}`))
}
}
}
function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const props = payload.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = props?.sessionID ?? info?.sessionID
const isMainSession = sessionID === ctx.sessionID
const sessionTag = isMainSession
? pc.green("[MAIN]")
: sessionID
? pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
: pc.dim("[system]")
switch (payload.type) {
case "session.idle":
case "session.status": {
const status = (props?.status as { type?: string })?.type ?? "idle"
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
break
}
case "message.part.updated": {
const partProps = props as MessagePartUpdatedProps | undefined
const part = partProps?.part
if (part?.type === "tool-invocation") {
const toolPart = part as { toolName?: string; state?: string }
console.error(
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
)
} else if (part?.type === "text" && part.text) {
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)
)
}
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const model = msgProps?.info?.modelID
const agent = msgProps?.info?.agent
const details = [role, agent, model].filter(Boolean).join(", ")
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
break
}
case "tool.execute": {
const toolProps = props as ToolExecuteProps | undefined
const toolName = toolProps?.name ?? "unknown"
const input = toolProps?.input ?? {}
const inputStr = JSON.stringify(input).slice(0, 150)
console.error(
pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`)
)
console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`))
break
}
case "tool.result": {
const resultProps = props as ToolResultProps | undefined
const output = resultProps?.output ?? ""
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
console.error(
pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
)
break
}
case "session.error": {
const errorProps = props as SessionErrorProps | undefined
const errorMsg = serializeError(errorProps?.error)
console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`))
break
}
default:
console.error(pc.dim(`${sessionTag} ${payload.type}`))
}
}
function handleSessionIdle(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.idle") return
const props = payload.properties as SessionIdleProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionIdle = true
}
}
function handleSessionStatus(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.status") return
const props = payload.properties as SessionStatusProps | undefined
if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") {
state.mainSessionIdle = false
}
}
function handleSessionError(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.error") return
const props = payload.properties as SessionErrorProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionError = true
state.lastError = serializeError(props?.error)
console.error(pc.red(`\n[session.error] ${state.lastError}`))
}
}
function handleMessagePartUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.part.updated") return
const props = payload.properties as MessagePartUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const part = props.part
if (!part) return
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
state.hasReceivedMeaningfulWork = true
}
state.lastPartText = part.text
}
}
function handleMessageUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.updated") return
const props = payload.properties as MessageUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
state.hasReceivedMeaningfulWork = true
state.messageCount++
}
function handleToolExecute(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.execute") return
const props = payload.properties as ToolExecuteProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const toolName = props?.name || "unknown"
state.currentTool = toolName
let inputPreview = ""
if (props?.input) {
const input = props.input
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
}
function handleToolResult(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.result") return
const props = payload.properties as ToolResultProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const output = props?.output || ""
const maxLen = 200
const preview = output.length > maxLen
? output.slice(0, maxLen) + "..."
: output
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
}
state.currentTool = null
state.lastPartText = ""
}

View File

@@ -4,4 +4,6 @@ export { createServerConnection } from "./server-connection"
export { resolveSession } from "./session-resolver" export { resolveSession } from "./session-resolver"
export { createJsonOutputManager } from "./json-output" export { createJsonOutputManager } from "./json-output"
export { executeOnCompleteHook } from "./on-complete-hook" export { executeOnCompleteHook } from "./on-complete-hook"
export { createEventState, processEvents, serializeError } from "./events"
export type { EventState } from "./events"
export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types" export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types"

View File

@@ -0,0 +1,113 @@
import * as p from "@clack/prompts"
import type { Option } from "@clack/prompts"
import type {
ClaudeSubscription,
DetectedConfig,
InstallConfig,
} from "./types"
import { detectedToInitialValues } from "./install-validators"
async function selectOrCancel<TValue extends Readonly<string | boolean | number>>(params: {
message: string
options: Option<TValue>[]
initialValue: TValue
}): Promise<TValue | null> {
if (!process.stdin.isTTY || !process.stdout.isTTY) return null
const value = await p.select<TValue>({
message: params.message,
options: params.options,
initialValue: params.initialValue,
})
if (p.isCancel(value)) {
p.cancel("Installation cancelled.")
return null
}
return value as TValue
}
export async function promptInstallConfig(detected: DetectedConfig): Promise<InstallConfig | null> {
const initial = detectedToInitialValues(detected)
const claude = await selectOrCancel<ClaudeSubscription>({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no", label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "yes", label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20", label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
initialValue: initial.claude,
})
if (!claude) return null
const openai = await selectOrCancel({
message: "Do you have an OpenAI/ChatGPT Plus subscription?",
options: [
{ value: "no", label: "No", hint: "Oracle will use fallback models" },
{ value: "yes", label: "Yes", hint: "GPT-5.2 for Oracle (high-IQ debugging)" },
],
initialValue: initial.openai,
})
if (!openai) return null
const gemini = await selectOrCancel({
message: "Will you integrate Google Gemini?",
options: [
{ value: "no", label: "No", hint: "Frontend/docs agents will use fallback" },
{ value: "yes", label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" },
],
initialValue: initial.gemini,
})
if (!gemini) return null
const copilot = await selectOrCancel({
message: "Do you have a GitHub Copilot subscription?",
options: [
{ value: "no", label: "No", hint: "Only native providers will be used" },
{ value: "yes", label: "Yes", hint: "Fallback option when native providers unavailable" },
],
initialValue: initial.copilot,
})
if (!copilot) return null
const opencodeZen = await selectOrCancel({
message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [
{ value: "no", label: "No", hint: "Will use other configured providers" },
{ value: "yes", label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." },
],
initialValue: initial.opencodeZen,
})
if (!opencodeZen) return null
const zaiCodingPlan = await selectOrCancel({
message: "Do you have a Z.ai Coding Plan subscription?",
options: [
{ value: "no", label: "No", hint: "Will use other configured providers" },
{ value: "yes", label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" },
],
initialValue: initial.zaiCodingPlan,
})
if (!zaiCodingPlan) return null
const kimiForCoding = await selectOrCancel({
message: "Do you have a Kimi For Coding subscription?",
options: [
{ value: "no", label: "No", hint: "Will use other configured providers" },
{ value: "yes", label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" },
],
initialValue: initial.kimiForCoding,
})
if (!kimiForCoding) return null
return {
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasOpenAI: openai === "yes",
hasGemini: gemini === "yes",
hasCopilot: copilot === "yes",
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
}
}

140
src/cli/tui-installer.ts Normal file
View File

@@ -0,0 +1,140 @@
import * as p from "@clack/prompts"
import color from "picocolors"
import type { InstallArgs } from "./types"
import {
addAuthPlugins,
addPluginToOpenCodeConfig,
addProviderConfig,
detectCurrentConfig,
getOpenCodeVersion,
isOpenCodeInstalled,
writeOmoConfig,
} from "./config-manager"
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
import { promptInstallConfig } from "./tui-install-prompts"
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error("Error: Interactive installer requires a TTY. Use --non-interactive or set environment variables directly.")
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... ")))
if (isUpdate) {
const initial = detectedToInitialValues(detected)
p.log.info(`Existing configuration detected: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const spinner = p.spinner()
spinner.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion()
if (!installed) {
spinner.stop(`OpenCode binary not found ${color.yellow("[!]")}`)
p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else {
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
}
const config = await promptInstallConfig(detected)
if (!config) return 1
spinner.start("Adding oh-my-opencode to OpenCode config")
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
spinner.stop(`Failed to add plugin: ${pluginResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
spinner.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
if (config.hasGemini) {
spinner.start("Adding auth plugins (fetching latest versions)")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
spinner.stop(`Failed to add auth plugins: ${authResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
spinner.stop(`Auth plugins added to ${color.cyan(authResult.configPath)}`)
spinner.start("Adding provider configurations")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
spinner.stop(`Failed to add provider config: ${providerResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
spinner.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`)
}
spinner.start("Writing oh-my-opencode configuration")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
spinner.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
console.log(color.dim(" • Reduced orchestration quality"))
console.log(color.dim(" • Weaker tool selection and delegation"))
console.log(color.dim(" • Less reliable task completion"))
console.log()
console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience."))
console.log()
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`)
p.note(
`Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"The Magic Word",
)
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message(
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`,
)
p.outro(color.green("oMoMoMoMo... Enjoy!"))
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
const providers: string[] = []
if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`)
if (config.hasGemini) providers.push(`Google ${color.gray("→ OAuth with Antigravity")}`)
if (config.hasCopilot) providers.push(`GitHub ${color.gray("→ Copilot")}`)
console.log()
console.log(color.bold("Authenticate Your Providers"))
console.log()
console.log(` Run ${color.cyan("opencode auth login")} and select:`)
for (const provider of providers) {
console.log(` ${SYMBOLS.bullet} ${provider}`)
}
console.log()
}
return 0
}

View File

@@ -1,464 +1,23 @@
import { z } from "zod" export * from "./schema/agent-names"
import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types" export * from "./schema/agent-overrides"
export * from "./schema/babysitting"
const PermissionValue = z.enum(["ask", "allow", "deny"]) export * from "./schema/background-task"
export * from "./schema/browser-automation"
const BashPermission = z.union([ export * from "./schema/categories"
PermissionValue, export * from "./schema/claude-code"
z.record(z.string(), PermissionValue), export * from "./schema/comment-checker"
]) export * from "./schema/commands"
export * from "./schema/dynamic-context-pruning"
const AgentPermissionSchema = z.object({ export * from "./schema/experimental"
edit: PermissionValue.optional(), export * from "./schema/git-master"
bash: BashPermission.optional(), export * from "./schema/hooks"
webfetch: PermissionValue.optional(), export * from "./schema/notification"
task: PermissionValue.optional(), export * from "./schema/oh-my-opencode-config"
doom_loop: PermissionValue.optional(), export * from "./schema/ralph-loop"
external_directory: PermissionValue.optional(), export * from "./schema/skills"
}) export * from "./schema/sisyphus"
export * from "./schema/sisyphus-agent"
export const BuiltinAgentNameSchema = z.enum([ export * from "./schema/tmux"
"sisyphus", export * from "./schema/websearch"
"hephaestus",
"prometheus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"metis",
"momus",
"atlas",
])
export const BuiltinSkillNameSchema = z.enum([
"playwright",
"agent-browser",
"dev-browser",
"frontend-ui-ux",
"git-master",
])
export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"sisyphus",
"hephaestus",
"sisyphus-junior",
"OpenCode-Builder",
"prometheus",
"metis",
"momus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"atlas",
])
export const AgentNameSchema = BuiltinAgentNameSchema
export const HookNameSchema = z.enum([
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
"session-notification",
"comment-checker",
"grep-output-truncator",
"tool-output-truncator",
"question-label-truncator",
"directory-agents-injector",
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"subagent-question-blocker",
"anthropic-context-window-limit-recovery",
"preemptive-compaction",
"rules-injector",
"background-notification",
"auto-update-checker",
"startup-toast",
"keyword-detector",
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"thinking-block-validator",
"ralph-loop",
"category-skill-reminder",
"compaction-context-injector",
"compaction-todo-preserver",
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
"delegate-task-retry",
"prometheus-md-only",
"sisyphus-junior-notepad",
"start-work",
"atlas",
"unstable-agent-babysitter",
"task-reminder",
"task-resume-info",
"stop-continuation-guard",
"tasks-todowrite-disabler",
"write-existing-file-guard",
"anthropic-effort",
])
export const BuiltinCommandNameSchema = z.enum([
"init-deep",
"ralph-loop",
"ulw-loop",
"cancel-ralph",
"refactor",
"start-work",
"stop-continuation",
])
export const AgentOverrideConfigSchema = z.object({
/** @deprecated Use `category` instead. Model is inherited from category defaults. */
model: z.string().optional(),
variant: z.string().optional(),
/** Category name to inherit model and other settings from CategoryConfig */
category: z.string().optional(),
/** Skill names to inject into agent prompt */
skills: z.array(z.string()).optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
prompt: z.string().optional(),
prompt_append: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]).optional(),
color: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional(),
permission: AgentPermissionSchema.optional(),
/** Maximum tokens for response. Passed directly to OpenCode SDK. */
maxTokens: z.number().optional(),
/** Extended thinking configuration (Anthropic). Overrides category and default settings. */
thinking: z.object({
type: z.enum(["enabled", "disabled"]),
budgetTokens: z.number().optional(),
}).optional(),
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
/** Text verbosity level. */
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
/** Provider-specific options. Passed directly to OpenCode SDK. */
providerOptions: z.record(z.string(), z.unknown()).optional(),
})
export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
sisyphus: AgentOverrideConfigSchema.optional(),
hephaestus: AgentOverrideConfigSchema.optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(),
metis: AgentOverrideConfigSchema.optional(),
momus: AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
explore: AgentOverrideConfigSchema.optional(),
"multimodal-looker": AgentOverrideConfigSchema.optional(),
atlas: AgentOverrideConfigSchema.optional(),
})
export const ClaudeCodeConfigSchema = z.object({
mcp: z.boolean().optional(),
commands: z.boolean().optional(),
skills: z.boolean().optional(),
agents: z.boolean().optional(),
hooks: z.boolean().optional(),
plugins: z.boolean().optional(),
plugins_override: z.record(z.string(), z.boolean()).optional(),
})
export const SisyphusAgentConfigSchema = z.object({
disabled: z.boolean().optional(),
default_builder_enabled: z.boolean().optional(),
planner_enabled: z.boolean().optional(),
replace_plan: z.boolean().optional(),
})
export const CategoryConfigSchema = z.object({
/** Human-readable description of the category's purpose. Shown in task prompt. */
description: z.string().optional(),
model: z.string().optional(),
variant: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
maxTokens: z.number().optional(),
thinking: z.object({
type: z.enum(["enabled", "disabled"]),
budgetTokens: z.number().optional(),
}).optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(),
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
is_unstable_agent: z.boolean().optional(),
})
export const BuiltinCategoryNameSchema = z.enum([
"visual-engineering",
"ultrabrain",
"deep",
"artistry",
"quick",
"unspecified-low",
"unspecified-high",
"writing",
])
export const CategoriesConfigSchema = z.record(z.string(), CategoryConfigSchema)
export const CommentCheckerConfigSchema = z.object({
/** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */
custom_prompt: z.string().optional(),
})
export const DynamicContextPruningConfigSchema = z.object({
/** Enable dynamic context pruning (default: false) */
enabled: z.boolean().default(false),
/** Notification level: off, minimal, or detailed (default: detailed) */
notification: z.enum(["off", "minimal", "detailed"]).default("detailed"),
/** Turn protection - prevent pruning recent tool outputs */
turn_protection: z.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(10).default(3),
}).optional(),
/** Tools that should never be pruned */
protected_tools: z.array(z.string()).default([
"task", "todowrite", "todoread",
"lsp_rename",
"session_read", "session_write", "session_search",
]),
/** Pruning strategies configuration */
strategies: z.object({
/** Remove duplicate tool calls (same tool + same args) */
deduplication: z.object({
enabled: z.boolean().default(true),
}).optional(),
/** Prune write inputs when file subsequently read */
supersede_writes: z.object({
enabled: z.boolean().default(true),
/** Aggressive mode: prune any write if ANY subsequent read */
aggressive: z.boolean().default(false),
}).optional(),
/** Prune errored tool inputs after N turns */
purge_errors: z.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(20).default(5),
}).optional(),
}).optional(),
})
export const ExperimentalConfigSchema = z.object({
aggressive_truncation: z.boolean().optional(),
auto_resume: z.boolean().optional(),
preemptive_compaction: z.boolean().optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */
truncate_all_tool_outputs: z.boolean().optional(),
/** Dynamic context pruning configuration */
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
/** Enable experimental task system for Todowrite disabler hook */
task_system: z.boolean().optional(),
/** Timeout in ms for loadAllPluginComponents during config handler init (default: 10000, min: 1000) */
plugin_load_timeout_ms: z.number().min(1000).optional(),
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
safe_hook_creation: z.boolean().optional(),
})
export const SkillSourceSchema = z.union([
z.string(),
z.object({
path: z.string(),
recursive: z.boolean().optional(),
glob: z.string().optional(),
}),
])
export const SkillDefinitionSchema = z.object({
description: z.string().optional(),
template: z.string().optional(),
from: z.string().optional(),
model: z.string().optional(),
agent: z.string().optional(),
subtask: z.boolean().optional(),
"argument-hint": z.string().optional(),
license: z.string().optional(),
compatibility: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
"allowed-tools": z.array(z.string()).optional(),
disable: z.boolean().optional(),
})
export const SkillEntrySchema = z.union([
z.boolean(),
SkillDefinitionSchema,
])
export const SkillsConfigSchema = z.union([
z.array(z.string()),
z.record(z.string(), SkillEntrySchema).and(z.object({
sources: z.array(SkillSourceSchema).optional(),
enable: z.array(z.string()).optional(),
disable: z.array(z.string()).optional(),
}).partial()),
])
export const RalphLoopConfigSchema = z.object({
/** Enable ralph loop functionality (default: false - opt-in feature) */
enabled: z.boolean().default(false),
/** Default max iterations if not specified in command (default: 100) */
default_max_iterations: z.number().min(1).max(1000).default(100),
/** Custom state file directory relative to project root (default: .opencode/) */
state_dir: z.string().optional(),
})
export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
})
export const NotificationConfigSchema = z.object({
/** Force enable session-notification even if external notification plugins are detected (default: false) */
force_enable: z.boolean().optional(),
})
export const BabysittingConfigSchema = z.object({
timeout_ms: z.number().default(120000),
})
export const GitMasterConfigSchema = z.object({
/** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */
commit_footer: z.union([z.boolean(), z.string()]).default(true),
/** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */
include_co_authored_by: z.boolean().default(true),
})
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser", "dev-browser"])
export const BrowserAutomationConfigSchema = z.object({
/**
* Browser automation provider to use for the "playwright" skill.
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
* - "dev-browser": Uses dev-browser skill with persistent browser state
*/
provider: BrowserAutomationProviderSchema.default("playwright"),
})
export const WebsearchProviderSchema = z.enum(["exa", "tavily"])
export const WebsearchConfigSchema = z.object({
/**
* Websearch provider to use.
* - "exa": Uses Exa websearch (default, works without API key)
* - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY)
*/
provider: WebsearchProviderSchema.optional(),
})
export const TmuxLayoutSchema = z.enum([
'main-horizontal', // main pane top, agent panes bottom stack
'main-vertical', // main pane left, agent panes right stack (default)
'tiled', // all panes same size grid
'even-horizontal', // all panes horizontal row
'even-vertical', // all panes vertical stack
])
export const TmuxConfigSchema = z.object({
enabled: z.boolean().default(false),
layout: TmuxLayoutSchema.default('main-vertical'),
main_pane_size: z.number().min(20).max(80).default(60),
main_pane_min_width: z.number().min(40).default(120),
agent_pane_min_width: z.number().min(20).default(40),
})
export const SisyphusTasksConfigSchema = z.object({
/** Absolute or relative storage path override. When set, bypasses global config dir. */
storage_path: z.string().optional(),
/** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */
task_list_id: z.string().optional(),
/** Enable Claude Code path compatibility mode */
claude_code_compat: z.boolean().default(false),
})
export const SisyphusConfigSchema = z.object({
tasks: SisyphusTasksConfigSchema.optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
/** Enable new task system (default: false) */
new_task_system_enabled: z.boolean().optional(),
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
default_run_agent: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(),
agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
comment_checker: CommentCheckerConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
auto_update: z.boolean().optional(),
skills: SkillsConfigSchema.optional(),
ralph_loop: RalphLoopConfigSchema.optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
websearch: WebsearchConfigSchema.optional(),
tmux: TmuxConfigSchema.optional(),
sisyphus: SisyphusConfigSchema.optional(),
/** Migration history to prevent re-applying migrations (e.g., model version upgrades) */
_migrations: z.array(z.string()).optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
export type AgentName = z.infer<typeof AgentNameSchema>
export type HookName = z.infer<typeof HookNameSchema>
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
export type BuiltinSkillName = z.infer<typeof BuiltinSkillNameSchema>
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
export type CategoriesConfig = z.infer<typeof CategoriesConfigSchema>
export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProviderSchema>
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
export type WebsearchProvider = z.infer<typeof WebsearchProviderSchema>
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -0,0 +1,44 @@
import { z } from "zod"
export const BuiltinAgentNameSchema = z.enum([
"sisyphus",
"hephaestus",
"prometheus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"metis",
"momus",
"atlas",
])
export const BuiltinSkillNameSchema = z.enum([
"playwright",
"agent-browser",
"dev-browser",
"frontend-ui-ux",
"git-master",
])
export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"sisyphus",
"hephaestus",
"sisyphus-junior",
"OpenCode-Builder",
"prometheus",
"metis",
"momus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"atlas",
])
export const AgentNameSchema = BuiltinAgentNameSchema
export type AgentName = z.infer<typeof AgentNameSchema>
export type BuiltinSkillName = z.infer<typeof BuiltinSkillNameSchema>

View File

@@ -0,0 +1,60 @@
import { z } from "zod"
import { AgentPermissionSchema } from "./internal/permission"
export const AgentOverrideConfigSchema = z.object({
/** @deprecated Use `category` instead. Model is inherited from category defaults. */
model: z.string().optional(),
variant: z.string().optional(),
/** Category name to inherit model and other settings from CategoryConfig */
category: z.string().optional(),
/** Skill names to inject into agent prompt */
skills: z.array(z.string()).optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
prompt: z.string().optional(),
prompt_append: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]).optional(),
color: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional(),
permission: AgentPermissionSchema.optional(),
/** Maximum tokens for response. Passed directly to OpenCode SDK. */
maxTokens: z.number().optional(),
/** Extended thinking configuration (Anthropic). Overrides category and default settings. */
thinking: z
.object({
type: z.enum(["enabled", "disabled"]),
budgetTokens: z.number().optional(),
})
.optional(),
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
/** Text verbosity level. */
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
/** Provider-specific options. Passed directly to OpenCode SDK. */
providerOptions: z.record(z.string(), z.unknown()).optional(),
})
export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
sisyphus: AgentOverrideConfigSchema.optional(),
hephaestus: AgentOverrideConfigSchema.optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(),
metis: AgentOverrideConfigSchema.optional(),
momus: AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
explore: AgentOverrideConfigSchema.optional(),
"multimodal-looker": AgentOverrideConfigSchema.optional(),
atlas: AgentOverrideConfigSchema.optional(),
})
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>

View File

@@ -0,0 +1,7 @@
import { z } from "zod"
export const BabysittingConfigSchema = z.object({
timeout_ms: z.number().default(120000),
})
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>

View File

@@ -0,0 +1,11 @@
import { z } from "zod"
export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
})
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>

View File

@@ -0,0 +1,22 @@
import { z } from "zod"
export const BrowserAutomationProviderSchema = z.enum([
"playwright",
"agent-browser",
"dev-browser",
])
export const BrowserAutomationConfigSchema = z.object({
/**
* Browser automation provider to use for the "playwright" skill.
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
* - "dev-browser": Uses dev-browser skill with persistent browser state
*/
provider: BrowserAutomationProviderSchema.default("playwright"),
})
export type BrowserAutomationProvider = z.infer<
typeof BrowserAutomationProviderSchema
>
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>

View File

@@ -0,0 +1,40 @@
import { z } from "zod"
export const CategoryConfigSchema = z.object({
/** Human-readable description of the category's purpose. Shown in task prompt. */
description: z.string().optional(),
model: z.string().optional(),
variant: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
maxTokens: z.number().optional(),
thinking: z
.object({
type: z.enum(["enabled", "disabled"]),
budgetTokens: z.number().optional(),
})
.optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(),
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
is_unstable_agent: z.boolean().optional(),
})
export const BuiltinCategoryNameSchema = z.enum([
"visual-engineering",
"ultrabrain",
"deep",
"artistry",
"quick",
"unspecified-low",
"unspecified-high",
"writing",
])
export const CategoriesConfigSchema = z.record(z.string(), CategoryConfigSchema)
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
export type CategoriesConfig = z.infer<typeof CategoriesConfigSchema>
export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
export const ClaudeCodeConfigSchema = z.object({
mcp: z.boolean().optional(),
commands: z.boolean().optional(),
skills: z.boolean().optional(),
agents: z.boolean().optional(),
hooks: z.boolean().optional(),
plugins: z.boolean().optional(),
plugins_override: z.record(z.string(), z.boolean()).optional(),
})
export type ClaudeCodeConfig = z.infer<typeof ClaudeCodeConfigSchema>

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
export const BuiltinCommandNameSchema = z.enum([
"init-deep",
"ralph-loop",
"ulw-loop",
"cancel-ralph",
"refactor",
"start-work",
"stop-continuation",
])
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const CommentCheckerConfigSchema = z.object({
/** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */
custom_prompt: z.string().optional(),
})
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>

View File

@@ -0,0 +1,55 @@
import { z } from "zod"
export const DynamicContextPruningConfigSchema = z.object({
/** Enable dynamic context pruning (default: false) */
enabled: z.boolean().default(false),
/** Notification level: off, minimal, or detailed (default: detailed) */
notification: z.enum(["off", "minimal", "detailed"]).default("detailed"),
/** Turn protection - prevent pruning recent tool outputs */
turn_protection: z
.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(10).default(3),
})
.optional(),
/** Tools that should never be pruned */
protected_tools: z.array(z.string()).default([
"task",
"todowrite",
"todoread",
"lsp_rename",
"session_read",
"session_write",
"session_search",
]),
/** Pruning strategies configuration */
strategies: z
.object({
/** Remove duplicate tool calls (same tool + same args) */
deduplication: z
.object({
enabled: z.boolean().default(true),
})
.optional(),
/** Prune write inputs when file subsequently read */
supersede_writes: z
.object({
enabled: z.boolean().default(true),
/** Aggressive mode: prune any write if ANY subsequent read */
aggressive: z.boolean().default(false),
})
.optional(),
/** Prune errored tool inputs after N turns */
purge_errors: z
.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(20).default(5),
})
.optional(),
})
.optional(),
})
export type DynamicContextPruningConfig = z.infer<
typeof DynamicContextPruningConfigSchema
>

View File

@@ -0,0 +1,20 @@
import { z } from "zod"
import { DynamicContextPruningConfigSchema } from "./dynamic-context-pruning"
export const ExperimentalConfigSchema = z.object({
aggressive_truncation: z.boolean().optional(),
auto_resume: z.boolean().optional(),
preemptive_compaction: z.boolean().optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */
truncate_all_tool_outputs: z.boolean().optional(),
/** Dynamic context pruning configuration */
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
/** Enable experimental task system for Todowrite disabler hook */
task_system: z.boolean().optional(),
/** Timeout in ms for loadAllPluginComponents during config handler init (default: 10000, min: 1000) */
plugin_load_timeout_ms: z.number().min(1000).optional(),
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
safe_hook_creation: z.boolean().optional(),
})
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
export const GitMasterConfigSchema = z.object({
/** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */
commit_footer: z.union([z.boolean(), z.string()]).default(true),
/** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */
include_co_authored_by: z.boolean().default(true),
})
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>

View File

@@ -0,0 +1,51 @@
import { z } from "zod"
export const HookNameSchema = z.enum([
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
"session-notification",
"comment-checker",
"grep-output-truncator",
"tool-output-truncator",
"question-label-truncator",
"directory-agents-injector",
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"subagent-question-blocker",
"anthropic-context-window-limit-recovery",
"preemptive-compaction",
"rules-injector",
"background-notification",
"auto-update-checker",
"startup-toast",
"keyword-detector",
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"thinking-block-validator",
"ralph-loop",
"category-skill-reminder",
"compaction-context-injector",
"compaction-todo-preserver",
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
"delegate-task-retry",
"prometheus-md-only",
"sisyphus-junior-notepad",
"start-work",
"atlas",
"unstable-agent-babysitter",
"task-reminder",
"task-resume-info",
"stop-continuation-guard",
"tasks-todowrite-disabler",
"write-existing-file-guard",
"anthropic-effort",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

@@ -0,0 +1,20 @@
import { z } from "zod"
export const PermissionValueSchema = z.enum(["ask", "allow", "deny"])
export type PermissionValue = z.infer<typeof PermissionValueSchema>
const BashPermissionSchema = z.union([
PermissionValueSchema,
z.record(z.string(), PermissionValueSchema),
])
export const AgentPermissionSchema = z.object({
edit: PermissionValueSchema.optional(),
bash: BashPermissionSchema.optional(),
webfetch: PermissionValueSchema.optional(),
task: PermissionValueSchema.optional(),
doom_loop: PermissionValueSchema.optional(),
external_directory: PermissionValueSchema.optional(),
})
export type AgentPermission = z.infer<typeof AgentPermissionSchema>

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const NotificationConfigSchema = z.object({
/** Force enable session-notification even if external notification plugins are detected (default: false) */
force_enable: z.boolean().optional(),
})
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>

View File

@@ -0,0 +1,57 @@
import { z } from "zod"
import { AnyMcpNameSchema } from "../../mcp/types"
import { BuiltinAgentNameSchema, BuiltinSkillNameSchema } from "./agent-names"
import { AgentOverridesSchema } from "./agent-overrides"
import { BabysittingConfigSchema } from "./babysitting"
import { BackgroundTaskConfigSchema } from "./background-task"
import { BrowserAutomationConfigSchema } from "./browser-automation"
import { CategoriesConfigSchema } from "./categories"
import { ClaudeCodeConfigSchema } from "./claude-code"
import { CommentCheckerConfigSchema } from "./comment-checker"
import { BuiltinCommandNameSchema } from "./commands"
import { ExperimentalConfigSchema } from "./experimental"
import { GitMasterConfigSchema } from "./git-master"
import { HookNameSchema } from "./hooks"
import { NotificationConfigSchema } from "./notification"
import { RalphLoopConfigSchema } from "./ralph-loop"
import { SkillsConfigSchema } from "./skills"
import { SisyphusConfigSchema } from "./sisyphus"
import { SisyphusAgentConfigSchema } from "./sisyphus-agent"
import { TmuxConfigSchema } from "./tmux"
import { WebsearchConfigSchema } from "./websearch"
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
/** Enable new task system (default: false) */
new_task_system_enabled: z.boolean().optional(),
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
default_run_agent: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(),
agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
comment_checker: CommentCheckerConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
auto_update: z.boolean().optional(),
skills: SkillsConfigSchema.optional(),
ralph_loop: RalphLoopConfigSchema.optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
websearch: WebsearchConfigSchema.optional(),
tmux: TmuxConfigSchema.optional(),
sisyphus: SisyphusConfigSchema.optional(),
/** Migration history to prevent re-applying migrations (e.g., model version upgrades) */
_migrations: z.array(z.string()).optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>

View File

@@ -0,0 +1,12 @@
import { z } from "zod"
export const RalphLoopConfigSchema = z.object({
/** Enable ralph loop functionality (default: false - opt-in feature) */
enabled: z.boolean().default(false),
/** Default max iterations if not specified in command (default: 100) */
default_max_iterations: z.number().min(1).max(1000).default(100),
/** Custom state file directory relative to project root (default: .opencode/) */
state_dir: z.string().optional(),
})
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
export const SisyphusAgentConfigSchema = z.object({
disabled: z.boolean().optional(),
default_builder_enabled: z.boolean().optional(),
planner_enabled: z.boolean().optional(),
replace_plan: z.boolean().optional(),
})
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>

View File

@@ -0,0 +1,17 @@
import { z } from "zod"
export const SisyphusTasksConfigSchema = z.object({
/** Absolute or relative storage path override. When set, bypasses global config dir. */
storage_path: z.string().optional(),
/** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */
task_list_id: z.string().optional(),
/** Enable Claude Code path compatibility mode */
claude_code_compat: z.boolean().default(false),
})
export const SisyphusConfigSchema = z.object({
tasks: SisyphusTasksConfigSchema.optional(),
})
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>

View File

@@ -0,0 +1,45 @@
import { z } from "zod"
export const SkillSourceSchema = z.union([
z.string(),
z.object({
path: z.string(),
recursive: z.boolean().optional(),
glob: z.string().optional(),
}),
])
export const SkillDefinitionSchema = z.object({
description: z.string().optional(),
template: z.string().optional(),
from: z.string().optional(),
model: z.string().optional(),
agent: z.string().optional(),
subtask: z.boolean().optional(),
"argument-hint": z.string().optional(),
license: z.string().optional(),
compatibility: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
"allowed-tools": z.array(z.string()).optional(),
disable: z.boolean().optional(),
})
export const SkillEntrySchema = z.union([z.boolean(), SkillDefinitionSchema])
export const SkillsConfigSchema = z.union([
z.array(z.string()),
z
.record(z.string(), SkillEntrySchema)
.and(
z
.object({
sources: z.array(SkillSourceSchema).optional(),
enable: z.array(z.string()).optional(),
disable: z.array(z.string()).optional(),
})
.partial()
),
])
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>

20
src/config/schema/tmux.ts Normal file
View File

@@ -0,0 +1,20 @@
import { z } from "zod"
export const TmuxLayoutSchema = z.enum([
"main-horizontal", // main pane top, agent panes bottom stack
"main-vertical", // main pane left, agent panes right stack (default)
"tiled", // all panes same size grid
"even-horizontal", // all panes horizontal row
"even-vertical", // all panes vertical stack
])
export const TmuxConfigSchema = z.object({
enabled: z.boolean().default(false),
layout: TmuxLayoutSchema.default("main-vertical"),
main_pane_size: z.number().min(20).max(80).default(60),
main_pane_min_width: z.number().min(40).default(120),
agent_pane_min_width: z.number().min(20).default(40),
})
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>

View File

@@ -0,0 +1,15 @@
import { z } from "zod"
export const WebsearchProviderSchema = z.enum(["exa", "tavily"])
export const WebsearchConfigSchema = z.object({
/**
* Websearch provider to use.
* - "exa": Uses Exa websearch (default, works without API key)
* - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY)
*/
provider: WebsearchProviderSchema.optional(),
})
export type WebsearchProvider = z.infer<typeof WebsearchProviderSchema>
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>

61
src/create-hooks.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"
import type { HookName, OhMyOpenCodeConfig } from "./config"
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
import type { BackgroundManager } from "./features/background-agent"
import type { PluginContext } from "./plugin/types"
import { createCoreHooks } from "./plugin/hooks/create-core-hooks"
import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks"
import { createSkillHooks } from "./plugin/hooks/create-skill-hooks"
export type CreatedHooks = ReturnType<typeof createHooks>
export function createHooks(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
backgroundManager: BackgroundManager
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
mergedSkills: LoadedSkill[]
availableSkills: AvailableSkill[]
}) {
const {
ctx,
pluginConfig,
backgroundManager,
isHookEnabled,
safeHookEnabled,
mergedSkills,
availableSkills,
} = args
const core = createCoreHooks({
ctx,
pluginConfig,
isHookEnabled,
safeHookEnabled,
})
const continuation = createContinuationHooks({
ctx,
pluginConfig,
isHookEnabled,
safeHookEnabled,
backgroundManager,
sessionRecovery: core.sessionRecovery,
})
const skill = createSkillHooks({
ctx,
isHookEnabled,
safeHookEnabled,
mergedSkills,
availableSkills,
})
return {
...core,
...continuation,
...skill,
}
}

79
src/create-managers.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { OhMyOpenCodeConfig } from "./config"
import type { ModelCacheState } from "./plugin-state"
import type { PluginContext, TmuxConfig } from "./plugin/types"
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
import { BackgroundManager } from "./features/background-agent"
import { SkillMcpManager } from "./features/skill-mcp-manager"
import { initTaskToastManager } from "./features/task-toast-manager"
import { TmuxSessionManager } from "./features/tmux-subagent"
import { createConfigHandler } from "./plugin-handlers"
import { log } from "./shared"
export type Managers = {
tmuxSessionManager: TmuxSessionManager
backgroundManager: BackgroundManager
skillMcpManager: SkillMcpManager
configHandler: ReturnType<typeof createConfigHandler>
}
export function createManagers(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
tmuxConfig: TmuxConfig
modelCacheState: ModelCacheState
}): Managers {
const { ctx, pluginConfig, tmuxConfig, modelCacheState } = args
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig)
const backgroundManager = new BackgroundManager(
ctx,
pluginConfig.background_task,
{
tmuxConfig,
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
log("[index] onSubagentSessionCreated callback received", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
})
await tmuxSessionManager.onSessionCreated({
type: "session.created",
properties: {
info: {
id: event.sessionID,
parentID: event.parentID,
title: event.title,
},
},
})
log("[index] onSubagentSessionCreated callback completed")
},
onShutdown: () => {
tmuxSessionManager.cleanup().catch((error) => {
log("[index] tmux cleanup error during shutdown:", error)
})
},
},
)
initTaskToastManager(ctx.client)
const skillMcpManager = new SkillMcpManager()
const configHandler = createConfigHandler({
ctx: { directory: ctx.directory, client: ctx.client },
pluginConfig,
modelCacheState,
})
return {
tmuxSessionManager,
backgroundManager,
skillMcpManager,
configHandler,
}
}

53
src/create-tools.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { AvailableCategory, AvailableSkill } from "./agents/dynamic-agent-prompt-builder"
import type { OhMyOpenCodeConfig } from "./config"
import type { BrowserAutomationProvider } from "./config/schema/browser-automation"
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
import type { PluginContext, ToolsRecord } from "./plugin/types"
import type { Managers } from "./create-managers"
import { createAvailableCategories } from "./plugin/available-categories"
import { createSkillContext } from "./plugin/skill-context"
import { createToolRegistry } from "./plugin/tool-registry"
export type CreateToolsResult = {
filteredTools: ToolsRecord
mergedSkills: LoadedSkill[]
availableSkills: AvailableSkill[]
availableCategories: AvailableCategory[]
browserProvider: BrowserAutomationProvider
disabledSkills: Set<string>
taskSystemEnabled: boolean
}
export async function createTools(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
}): Promise<CreateToolsResult> {
const { ctx, pluginConfig, managers } = args
const skillContext = await createSkillContext({
directory: ctx.directory,
pluginConfig,
})
const availableCategories = createAvailableCategories(pluginConfig)
const { filteredTools, taskSystemEnabled } = createToolRegistry({
ctx,
pluginConfig,
managers,
skillContext,
availableCategories,
})
return {
filteredTools,
mergedSkills: skillContext.mergedSkills,
availableSkills: skillContext.availableSkills,
availableCategories,
browserProvider: skillContext.browserProvider,
disabledSkills: skillContext.disabledSkills,
taskSystemEnabled,
}
}

View File

@@ -0,0 +1,199 @@
import { log } from "../../shared"
import { MIN_IDLE_TIME_MS } from "./constants"
import { subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask } from "./types"
type Event = { type: string; properties?: Record<string, unknown> }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key]
return typeof value === "string" ? value : undefined
}
export function handleBackgroundEvent(args: {
event: Event
findBySession: (sessionID: string) => BackgroundTask | undefined
getAllDescendantTasks: (sessionID: string) => BackgroundTask[]
cancelTask: (
taskId: string,
options: { source: string; reason: string; skipNotification: true }
) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean>
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
completionTimers: Map<string, ReturnType<typeof setTimeout>>
tasks: Map<string, BackgroundTask>
cleanupPendingByParent: (task: BackgroundTask) => void
clearNotificationsForTask: (taskId: string) => void
emitIdleEvent: (sessionID: string) => void
}): void {
const {
event,
findBySession,
getAllDescendantTasks,
cancelTask,
tryCompleteTask,
validateSessionHasOutput,
checkSessionTodos,
idleDeferralTimers,
completionTimers,
tasks,
cleanupPendingByParent,
clearNotificationsForTask,
emitIdleEvent,
} = args
const props = event.properties
if (event.type === "message.part.updated") {
if (!props || !isRecord(props)) return
const sessionID = getString(props, "sessionID")
if (!sessionID) return
const task = findBySession(sessionID)
if (!task) return
const existingTimer = idleDeferralTimers.get(task.id)
if (existingTimer) {
clearTimeout(existingTimer)
idleDeferralTimers.delete(task.id)
}
const type = getString(props, "type")
const tool = getString(props, "tool")
if (type === "tool" || tool) {
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.toolCalls += 1
task.progress.lastTool = tool
task.progress.lastUpdate = new Date()
}
}
if (event.type === "session.idle") {
if (!props || !isRecord(props)) return
const sessionID = getString(props, "sessionID")
if (!sessionID) return
const task = findBySession(sessionID)
if (!task || task.status !== "running") return
const startedAt = task.startedAt
if (!startedAt) return
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", {
elapsedMs,
remainingMs,
taskId: task.id,
})
const timer = setTimeout(() => {
idleDeferralTimers.delete(task.id)
emitIdleEvent(sessionID)
}, remainingMs)
idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
validateSessionHasOutput(sessionID)
.then(async (hasValidOutput) => {
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await checkSessionTodos(sessionID)
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await tryCompleteTask(task, "session.idle event")
})
.catch((err) => {
log("[background-agent] Error in session.idle handler:", err)
})
}
if (event.type === "session.deleted") {
if (!props || !isRecord(props)) return
const infoRaw = props["info"]
if (!isRecord(infoRaw)) return
const sessionID = getString(infoRaw, "id")
if (!sessionID) return
const tasksToCancel = new Map<string, BackgroundTask>()
const directTask = findBySession(sessionID)
if (directTask) {
tasksToCancel.set(directTask.id, directTask)
}
for (const descendant of getAllDescendantTasks(sessionID)) {
tasksToCancel.set(descendant.id, descendant)
}
if (tasksToCancel.size === 0) return
for (const task of tasksToCancel.values()) {
if (task.status === "running" || task.status === "pending") {
void cancelTask(task.id, {
source: "session.deleted",
reason: "Session deleted",
skipNotification: true,
}).catch((err) => {
log("[background-agent] Failed to cancel task on session.deleted:", {
taskId: task.id,
error: err,
})
})
}
const completionTimer = completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
completionTimers.delete(task.id)
}
const idleTimer = idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
idleDeferralTimers.delete(task.id)
}
cleanupPendingByParent(task)
tasks.delete(task.id)
clearNotificationsForTask(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}
}
}

View File

@@ -0,0 +1,82 @@
import { log } from "../../shared"
import type { BackgroundTask, LaunchInput } from "./types"
import type { ConcurrencyManager } from "./concurrency"
import type { PluginInput } from "@opencode-ai/plugin"
type QueueItem = { task: BackgroundTask; input: LaunchInput }
export function shutdownBackgroundManager(args: {
shutdownTriggered: { value: boolean }
stopPolling: () => void
tasks: Map<string, BackgroundTask>
client: PluginInput["client"]
onShutdown?: () => void
concurrencyManager: ConcurrencyManager
completionTimers: Map<string, ReturnType<typeof setTimeout>>
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
notifications: Map<string, BackgroundTask[]>
pendingByParent: Map<string, Set<string>>
queuesByKey: Map<string, QueueItem[]>
processingKeys: Set<string>
unregisterProcessCleanup: () => void
}): void {
const {
shutdownTriggered,
stopPolling,
tasks,
client,
onShutdown,
concurrencyManager,
completionTimers,
idleDeferralTimers,
notifications,
pendingByParent,
queuesByKey,
processingKeys,
unregisterProcessCleanup,
} = args
if (shutdownTriggered.value) return
shutdownTriggered.value = true
log("[background-agent] Shutting down BackgroundManager")
stopPolling()
for (const task of tasks.values()) {
if (task.status === "running" && task.sessionID) {
client.session.abort({ path: { id: task.sessionID } }).catch(() => {})
}
}
if (onShutdown) {
try {
onShutdown()
} catch (error) {
log("[background-agent] Error in onShutdown callback:", error)
}
}
for (const task of tasks.values()) {
if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
}
for (const timer of completionTimers.values()) clearTimeout(timer)
completionTimers.clear()
for (const timer of idleDeferralTimers.values()) clearTimeout(timer)
idleDeferralTimers.clear()
concurrencyManager.clear()
tasks.clear()
notifications.clear()
pendingByParent.clear()
queuesByKey.clear()
processingKeys.clear()
unregisterProcessCleanup()
log("[background-agent] Shutdown complete")
}

View File

@@ -0,0 +1,40 @@
import type { BackgroundTask } from "./types"
import type { ResultHandlerContext } from "./result-handler-context"
import { log } from "../../shared"
import { notifyParentSession } from "./parent-session-notifier"
export async function tryCompleteTask(
task: BackgroundTask,
source: string,
ctx: ResultHandlerContext
): Promise<boolean> {
const { concurrencyManager, state } = ctx
if (task.status !== "running") {
log("[background-agent] Task already completed, skipping:", {
taskId: task.id,
status: task.status,
source,
})
return false
}
task.status = "completed"
task.completedAt = new Date()
if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
state.markForNotification(task)
try {
await notifyParentSession(task, ctx)
log(`[background-agent] Task completed via ${source}:`, task.id)
} catch (error) {
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error })
}
return true
}

View File

@@ -0,0 +1,46 @@
import type { BackgroundTask } from "./types"
export type BackgroundTaskNotificationStatus = "COMPLETED" | "CANCELLED"
export function buildBackgroundTaskNotificationText(input: {
task: BackgroundTask
duration: string
statusText: BackgroundTaskNotificationStatus
allComplete: boolean
remainingCount: number
completedTasks: BackgroundTask[]
}): string {
const { task, duration, statusText, allComplete, remainingCount, completedTasks } = input
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
if (allComplete) {
const completedTasksText = completedTasks
.map((t) => `- \`${t.id}\`: ${t.description}`)
.join("\n")
return `<system-reminder>
[ALL BACKGROUND TASKS COMPLETE]
**Completed:**
${completedTasksText || `- \`${task.id}\`: ${task.description}`}
Use \`background_output(task_id="<id>")\` to retrieve each result.
</system-reminder>`
}
const agentInfo = task.category ? `${task.agent} (${task.category})` : task.agent
return `<system-reminder>
[BACKGROUND TASK ${statusText}]
**ID:** \`${task.id}\`
**Description:** ${task.description}
**Agent:** ${agentInfo}
**Duration:** ${duration}${errorInfo}
**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete.
Do NOT poll - continue productive work.
Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready.
</system-reminder>`
}

View File

@@ -0,0 +1,14 @@
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
}
return `${seconds}s`
}

Some files were not shown because too many files have changed in this diff Show More