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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
52
src/agents/agent-builder.ts
Normal file
52
src/agents/agent-builder.ts
Normal 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
142
src/agents/atlas/agent.ts
Normal 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",
|
||||||
|
}
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
|
|||||||
179
src/agents/builtin-agents.ts
Normal file
179
src/agents/builtin-agents.ts
Normal 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
|
||||||
|
}
|
||||||
65
src/agents/builtin-agents/agent-overrides.ts
Normal file
65
src/agents/builtin-agents/agent-overrides.ts
Normal 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
|
||||||
|
}
|
||||||
63
src/agents/builtin-agents/atlas-agent.ts
Normal file
63
src/agents/builtin-agents/atlas-agent.ts
Normal 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
|
||||||
|
}
|
||||||
35
src/agents/builtin-agents/available-skills.ts
Normal file
35
src/agents/builtin-agents/available-skills.ts
Normal 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]
|
||||||
|
}
|
||||||
8
src/agents/builtin-agents/environment-context.ts
Normal file
8
src/agents/builtin-agents/environment-context.ts
Normal 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 }
|
||||||
|
}
|
||||||
102
src/agents/builtin-agents/general-agents.ts
Normal file
102
src/agents/builtin-agents/general-agents.ts
Normal 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 }
|
||||||
|
}
|
||||||
88
src/agents/builtin-agents/hephaestus-agent.ts
Normal file
88
src/agents/builtin-agents/hephaestus-agent.ts
Normal 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
|
||||||
|
}
|
||||||
28
src/agents/builtin-agents/model-resolution.ts
Normal file
28
src/agents/builtin-agents/model-resolution.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/agents/builtin-agents/sisyphus-agent.ts
Normal file
81
src/agents/builtin-agents/sisyphus-agent.ts
Normal 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
|
||||||
|
}
|
||||||
61
src/agents/custom-agent-summaries.ts
Normal file
61
src/agents/custom-agent-summaries.ts
Normal 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
33
src/agents/env-context.ts
Normal 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>`
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
29
src/agents/prometheus/system-prompt.ts
Normal file
29
src/agents/prometheus/system-prompt.ts
Normal 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,
|
||||||
|
}
|
||||||
119
src/agents/sisyphus-junior/agent.ts
Normal file
119
src/agents/sisyphus-junior/agent.ts
Normal 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
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
164
src/cli/cli-installer.ts
Normal 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
191
src/cli/cli-program.ts
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
82
src/cli/config-manager/add-plugin-to-opencode-config.ts
Normal file
82
src/cli/config-manager/add-plugin-to-opencode-config.ts
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/cli/config-manager/add-provider-config.ts
Normal file
111
src/cli/config-manager/add-provider-config.ts
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/cli/config-manager/antigravity-provider-configuration.ts
Normal file
64
src/cli/config-manager/antigravity-provider-configuration.ts
Normal 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 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
88
src/cli/config-manager/auth-plugins.ts
Normal file
88
src/cli/config-manager/auth-plugins.ts
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/cli/config-manager/bun-install.ts
Normal file
61
src/cli/config-manager/bun-install.ts
Normal 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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/cli/config-manager/config-context.ts
Normal file
49
src/cli/config-manager/config-context.ts
Normal 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
|
||||||
|
}
|
||||||
30
src/cli/config-manager/deep-merge-record.ts
Normal file
30
src/cli/config-manager/deep-merge-record.ts
Normal 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
|
||||||
|
}
|
||||||
78
src/cli/config-manager/detect-current-config.ts
Normal file
78
src/cli/config-manager/detect-current-config.ts
Normal 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
|
||||||
|
}
|
||||||
9
src/cli/config-manager/ensure-config-directory-exists.ts
Normal file
9
src/cli/config-manager/ensure-config-directory-exists.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/cli/config-manager/format-error-with-suggestion.ts
Normal file
39
src/cli/config-manager/format-error-with-suggestion.ts
Normal 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}`
|
||||||
|
}
|
||||||
6
src/cli/config-manager/generate-omo-config.ts
Normal file
6
src/cli/config-manager/generate-omo-config.ts
Normal 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)
|
||||||
|
}
|
||||||
21
src/cli/config-manager/npm-dist-tags.ts
Normal file
21
src/cli/config-manager/npm-dist-tags.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/cli/config-manager/opencode-binary.ts
Normal file
40
src/cli/config-manager/opencode-binary.ts
Normal 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
|
||||||
|
}
|
||||||
17
src/cli/config-manager/opencode-config-format.ts
Normal file
17
src/cli/config-manager/opencode-config-format.ts
Normal 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 }
|
||||||
|
}
|
||||||
48
src/cli/config-manager/parse-opencode-config-file.ts
Normal file
48
src/cli/config-manager/parse-opencode-config-file.ts
Normal 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}`) }
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/cli/config-manager/plugin-name-with-version.ts
Normal file
19
src/cli/config-manager/plugin-name-with-version.ts
Normal 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}`
|
||||||
|
}
|
||||||
67
src/cli/config-manager/write-omo-config.ts
Normal file
67
src/cli/config-manager/write-omo-config.ts
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
37
src/cli/doctor/checks/model-resolution-cache.ts
Normal file
37
src/cli/doctor/checks/model-resolution-cache.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/cli/doctor/checks/model-resolution-config.ts
Normal file
35
src/cli/doctor/checks/model-resolution-config.ts
Normal 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
|
||||||
|
}
|
||||||
56
src/cli/doctor/checks/model-resolution-details.ts
Normal file
56
src/cli/doctor/checks/model-resolution-details.ts
Normal 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
|
||||||
|
}
|
||||||
27
src/cli/doctor/checks/model-resolution-effective-model.ts
Normal file
27
src/cli/doctor/checks/model-resolution-effective-model.ts
Normal 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}`
|
||||||
|
}
|
||||||
35
src/cli/doctor/checks/model-resolution-types.ts
Normal file
35
src/cli/doctor/checks/model-resolution-types.ts
Normal 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
|
||||||
|
}
|
||||||
55
src/cli/doctor/checks/model-resolution-variant.ts
Normal file
55
src/cli/doctor/checks/model-resolution-variant.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
55
src/cli/fallback-chain-resolution.ts
Normal file
55
src/cli/fallback-chain-resolution.ts
Normal 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))
|
||||||
|
}
|
||||||
112
src/cli/get-local-version/get-local-version.ts
Normal file
112
src/cli/get-local-version/get-local-version.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
190
src/cli/index.ts
190
src/cli/index.ts
@@ -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()
|
|
||||||
|
|||||||
189
src/cli/install-validators.ts
Normal file
189
src/cli/install-validators.ts
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/cli/model-fallback-types.ts
Normal file
29
src/cli/model-fallback-types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
30
src/cli/provider-availability.ts
Normal file
30
src/cli/provider-availability.ts
Normal 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
|
||||||
|
}
|
||||||
12
src/cli/provider-model-id-transform.ts
Normal file
12
src/cli/provider-model-id-transform.ts
Normal 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
|
||||||
|
}
|
||||||
140
src/cli/run/event-formatting.ts
Normal file
140
src/cli/run/event-formatting.ts
Normal 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}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/cli/run/event-handlers.ts
Normal file
121
src/cli/run/event-handlers.ts
Normal 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 = ""
|
||||||
|
}
|
||||||
25
src/cli/run/event-state.ts
Normal file
25
src/cli/run/event-state.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/cli/run/event-stream-processor.ts
Normal file
43
src/cli/run/event-stream-processor.ts
Normal 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}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
113
src/cli/tui-install-prompts.ts
Normal file
113
src/cli/tui-install-prompts.ts
Normal 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
140
src/cli/tui-installer.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
44
src/config/schema/agent-names.ts
Normal file
44
src/config/schema/agent-names.ts
Normal 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>
|
||||||
60
src/config/schema/agent-overrides.ts
Normal file
60
src/config/schema/agent-overrides.ts
Normal 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>
|
||||||
7
src/config/schema/babysitting.ts
Normal file
7
src/config/schema/babysitting.ts
Normal 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>
|
||||||
11
src/config/schema/background-task.ts
Normal file
11
src/config/schema/background-task.ts
Normal 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>
|
||||||
22
src/config/schema/browser-automation.ts
Normal file
22
src/config/schema/browser-automation.ts
Normal 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>
|
||||||
40
src/config/schema/categories.ts
Normal file
40
src/config/schema/categories.ts
Normal 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>
|
||||||
13
src/config/schema/claude-code.ts
Normal file
13
src/config/schema/claude-code.ts
Normal 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>
|
||||||
13
src/config/schema/commands.ts
Normal file
13
src/config/schema/commands.ts
Normal 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>
|
||||||
8
src/config/schema/comment-checker.ts
Normal file
8
src/config/schema/comment-checker.ts
Normal 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>
|
||||||
55
src/config/schema/dynamic-context-pruning.ts
Normal file
55
src/config/schema/dynamic-context-pruning.ts
Normal 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
|
||||||
|
>
|
||||||
20
src/config/schema/experimental.ts
Normal file
20
src/config/schema/experimental.ts
Normal 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>
|
||||||
10
src/config/schema/git-master.ts
Normal file
10
src/config/schema/git-master.ts
Normal 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>
|
||||||
51
src/config/schema/hooks.ts
Normal file
51
src/config/schema/hooks.ts
Normal 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>
|
||||||
20
src/config/schema/internal/permission.ts
Normal file
20
src/config/schema/internal/permission.ts
Normal 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>
|
||||||
8
src/config/schema/notification.ts
Normal file
8
src/config/schema/notification.ts
Normal 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>
|
||||||
57
src/config/schema/oh-my-opencode-config.ts
Normal file
57
src/config/schema/oh-my-opencode-config.ts
Normal 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>
|
||||||
12
src/config/schema/ralph-loop.ts
Normal file
12
src/config/schema/ralph-loop.ts
Normal 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>
|
||||||
10
src/config/schema/sisyphus-agent.ts
Normal file
10
src/config/schema/sisyphus-agent.ts
Normal 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>
|
||||||
17
src/config/schema/sisyphus.ts
Normal file
17
src/config/schema/sisyphus.ts
Normal 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>
|
||||||
45
src/config/schema/skills.ts
Normal file
45
src/config/schema/skills.ts
Normal 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
20
src/config/schema/tmux.ts
Normal 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>
|
||||||
15
src/config/schema/websearch.ts
Normal file
15
src/config/schema/websearch.ts
Normal 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
61
src/create-hooks.ts
Normal 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
79
src/create-managers.ts
Normal 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
53
src/create-tools.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/features/background-agent/background-event-handler.ts
Normal file
199
src/features/background-agent/background-event-handler.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/features/background-agent/background-manager-shutdown.ts
Normal file
82
src/features/background-agent/background-manager-shutdown.ts
Normal 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")
|
||||||
|
}
|
||||||
40
src/features/background-agent/background-task-completer.ts
Normal file
40
src/features/background-agent/background-task-completer.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>`
|
||||||
|
}
|
||||||
14
src/features/background-agent/duration-formatter.ts
Normal file
14
src/features/background-agent/duration-formatter.ts
Normal 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
Reference in New Issue
Block a user